Skip to main content

keyhog_core/
credential.rs

1//! Opaque, zeroize-on-drop credential bytes.
2//!
3//! Debt bucket (`#![allow(missing_docs)]` below): 7 items predating the crate
4//! floor raising `missing_docs` to `warn`. Remove once each carries a doc.
5//!
6//! Replaces the previous `Arc<str>` credential field with a type that:
7//!
8//! 1. Zeroes its bytes on drop (`zeroize` crate). Heap pages keyhog freed
9//!    while a scan was in flight no longer leak credentials to the next
10//!    allocator request, swap, or post-mortem core dump.
11//! 2. Refuses `Debug` / `Display` printing - every leak path through `{:?}`
12//!    or `{}` becomes `<redacted N bytes>` instead of the bytes themselves
13//!    (`Credential` and `SensitiveString` both redact Display, KH-1424).
14//!    Raw byte access is via `expose_secret` / `SensitiveString::as_str`;
15//!    integration tests reach it only through the intentional surface.
16//! 3. Refuses implicit `Serialize` for both secret wrappers. Public DTOs that
17//!    contain source or credential bytes therefore fail closed instead of
18//!    writing plaintext or encoded binary data. Intentional private
19//!    serialization must explicitly expose `SensitiveString::as_str` or use
20//!    the owning crate's private `Credential` byte boundary, keeping every
21//!    plaintext boundary searchable.
22//!
23//! When EnvSeal embeds keyhog, this type is the only place credential
24//! bytes ever appear in process memory; an mlock + memfd backing can be
25//! added behind the `lockdown` feature gate without touching call sites.
26
27#![allow(missing_docs)]
28
29use serde::{Deserialize, Deserializer, Serialize, Serializer};
30use std::cmp::Ordering;
31use std::hash::{Hash, Hasher};
32use std::sync::Arc;
33use zeroize::Zeroizing;
34
35/// Opaque credential bytes. The inner `Arc<Zeroizing<Box<[u8]>>>` clones are
36/// cheap (refcount bump) but every owning `Credential` zeroizes on drop.
37/// `Arc` lets the engine intern identical credentials without copying;
38/// when the last ref drops, `Zeroizing<Box<[u8]>>` overwrites the heap
39/// allocation before `Box::drop` returns it to the allocator.
40#[derive(Clone)]
41pub struct Credential {
42    inner: Arc<Zeroizing<Box<[u8]>>>,
43}
44
45impl Credential {
46    /// Build a `Credential` from raw bytes. The bytes are copied into a
47    /// fresh `Zeroizing<Box<[u8]>>` and the input slice is unchanged
48    /// (caller is responsible for zeroizing whatever it came from).
49    #[must_use]
50    pub(crate) fn from_bytes(bytes: &[u8]) -> Self {
51        Self {
52            inner: Arc::new(Zeroizing::new(bytes.to_vec().into_boxed_slice())),
53        }
54    }
55
56    /// Build a `Credential` from a borrowed `str`. Same semantics as
57    /// `from_bytes` - bytes are copied into the zeroizing allocation.
58    /// Named `from_text` (not `from_str`) to avoid the
59    /// `clippy::should_implement_trait` lint and to keep the API
60    /// distinct from `core::str::FromStr` (which has different error
61    /// semantics - we never fail to construct a Credential).
62    #[must_use]
63    pub(crate) fn from_text(s: &str) -> Self {
64        Self::from_bytes(s.as_bytes())
65    }
66
67    /// Expose the underlying bytes. Every call site MUST be auditable -
68    /// `git grep expose_secret` should surface every place credentials
69    /// leave the opaque wrapper. Treat each one as a security review item.
70    ///
71    /// Returns a `&[u8]` rather than `&str` because credentials may be
72    /// non-UTF-8 (binary-encoded keys, raw private-key bytes, etc).
73    #[must_use]
74    pub(crate) fn expose_secret(&self) -> &[u8] {
75        &self.inner
76    }
77
78    /// Expose the credential as a `&str` if it's valid UTF-8, otherwise
79    /// `None`. Most production credentials ARE valid UTF-8 (provider keys,
80    /// tokens, base64) so this is the common path.
81    #[must_use]
82    pub(crate) fn expose_str(&self) -> Option<&str> {
83        // The `Option<&str>` return IS the loud surface: a non-UTF-8 credential
84        // (raw key bytes, binary token) maps to `None`, which every caller must
85        // handle, and the raw bytes remain available via `expose_secret()`.
86        std::str::from_utf8(&self.inner).ok() // LAW10: Option return is the surface, raw bytes kept via expose_secret(), see note
87    }
88}
89
90impl From<&str> for Credential {
91    fn from(s: &str) -> Self {
92        Self::from_text(s)
93    }
94}
95
96impl From<String> for Credential {
97    fn from(s: String) -> Self {
98        // The input `String`'s buffer is dropped without zeroizing - the
99        // caller should ideally pass `&str` so the bytes never sit in a
100        // non-zeroizing `String`. We do the right thing for our own
101        // allocation either way.
102        Self::from_bytes(s.as_bytes())
103    }
104}
105
106impl From<&[u8]> for Credential {
107    fn from(b: &[u8]) -> Self {
108        Self::from_bytes(b)
109    }
110}
111
112impl From<Vec<u8>> for Credential {
113    fn from(v: Vec<u8>) -> Self {
114        Self::from_bytes(&v)
115    }
116}
117
118/// Constant-time byte-slice equality, the ONE owner of timing-safe secret
119/// comparison shared by every credential-bearing type in this module
120/// ([`Credential`], [`SensitiveString`]). Compares in time proportional to the
121/// input length regardless of WHERE the first mismatch is, so equality checks
122/// during dedup / inflight de-duplication cannot leak secret bytes through CPU
123/// branch timing. Length inequality short-circuits (a value's length is not
124/// itself secret material), then every remaining byte is folded into one XOR
125/// accumulator. The cost is one extra XOR per byte vs `==`, negligible at
126/// credential sizes (<1 KiB typical).
127pub(crate) fn constant_time_bytes_eq(a: &[u8], b: &[u8]) -> bool {
128    if a.len() != b.len() {
129        return false;
130    }
131    let mut diff: u8 = 0;
132    for (x, y) in a.iter().zip(b.iter()) {
133        diff |= x ^ y;
134    }
135    diff == 0
136}
137
138impl PartialEq for Credential {
139    fn eq(&self, other: &Self) -> bool {
140        constant_time_bytes_eq(&self.inner, &other.inner)
141    }
142}
143
144impl Eq for Credential {}
145
146impl PartialOrd for Credential {
147    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
148        Some(self.cmp(other))
149    }
150}
151
152impl Ord for Credential {
153    fn cmp(&self, other: &Self) -> Ordering {
154        self.inner
155            .as_ref()
156            .as_ref()
157            .cmp(other.inner.as_ref().as_ref())
158    }
159}
160
161impl Hash for Credential {
162    fn hash<H: Hasher>(&self, state: &mut H) {
163        self.inner.as_ref().as_ref().hash(state);
164    }
165}
166
167impl std::fmt::Debug for Credential {
168    /// Refuse to format the bytes. This is a compile-time leak guard -
169    /// every place that did `eprintln!("{:?}", cred)` or `tracing::error!(?cred)`
170    /// now prints `Credential(<redacted N bytes>)` instead of the secret.
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        write!(f, "Credential(<redacted {} bytes>)", self.inner.len())
173    }
174}
175
176impl std::fmt::Display for Credential {
177    /// Same redaction as `Debug` - `format!("{}", cred)` returns the
178    /// redacted form, never the bytes.
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        write!(f, "<redacted {} bytes>", self.inner.len())
181    }
182}
183
184impl Serialize for Credential {
185    /// Refuse implicit serialization because credentials are plaintext or
186    /// binary secret material. A protected private DTO must explicitly expose
187    /// the bytes through its crate-private credential boundary.
188    fn serialize<S: Serializer>(&self, _serializer: S) -> Result<S::Ok, S::Error> {
189        Err(serde::ser::Error::custom(
190            "Credential refuses implicit plaintext serialization; expose bytes explicitly only for a protected private channel",
191        ))
192    }
193}
194
195impl<'de> Deserialize<'de> for Credential {
196    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
197        // Accept tagged `text` / `b64` objects and legacy `b64:<base64>` /
198        // plain string forms so historical protected artifacts still load.
199        // No implicit writer emits any of these plaintext-bearing forms.
200        #[derive(Deserialize)]
201        #[serde(untagged)]
202        enum Wire {
203            Tagged {
204                #[serde(default)]
205                text: Option<String>,
206                #[serde(default)]
207                b64: Option<String>,
208            },
209            Legacy(String),
210        }
211        match Wire::deserialize(deserializer)? {
212            Wire::Tagged {
213                text: Some(t),
214                b64: None,
215            } => Ok(Credential::from_text(&t)),
216            Wire::Tagged {
217                text: None,
218                b64: Some(b),
219            } => {
220                let bytes = crate::encoding::decode_standard_base64(&b)
221                    .map_err(serde::de::Error::custom)?;
222                Ok(Credential::from_bytes(&bytes))
223            }
224            Wire::Tagged { .. } => Err(serde::de::Error::custom(
225                "Credential must specify exactly one of `text` or `b64`",
226            )),
227            Wire::Legacy(s) => {
228                if let Some(rest) = s.strip_prefix("b64:") {
229                    let bytes = crate::encoding::decode_standard_base64(rest)
230                        .map_err(serde::de::Error::custom)?;
231                    Ok(Credential::from_bytes(&bytes))
232                } else {
233                    Ok(Credential::from_text(&s))
234                }
235            }
236        }
237    }
238}
239
240/// A heap-allocated string that is zeroized on drop.
241#[derive(Clone, Default)]
242pub struct SensitiveString {
243    inner: Arc<Zeroizing<String>>,
244}
245
246impl SensitiveString {
247    fn new(s: String) -> Self {
248        Self {
249            inner: Arc::new(Zeroizing::new(s)),
250        }
251    }
252
253    /// Explicit plaintext access. `Display`/`Debug` redact (KH-1424); every
254    /// intentional reveal goes through this method (or `Deref`/`AsRef`) so
255    /// `git grep as_str` / format surfaces stay auditable.
256    #[must_use]
257    pub fn as_str(&self) -> &str {
258        self.inner.as_str()
259    }
260}
261
262impl std::ops::Deref for SensitiveString {
263    type Target = str;
264    fn deref(&self) -> &Self::Target {
265        self.as_str()
266    }
267}
268
269impl AsRef<str> for SensitiveString {
270    fn as_ref(&self) -> &str {
271        self.as_str()
272    }
273}
274
275impl std::borrow::Borrow<str> for SensitiveString {
276    fn borrow(&self) -> &str {
277        self.as_str()
278    }
279}
280
281impl PartialEq for SensitiveString {
282    fn eq(&self, other: &Self) -> bool {
283        // Timing-safe: `SensitiveString` wraps secret material (zeroized on
284        // drop), so its equality must not leak bytes through branch timing any
285        // more than `Credential`'s does, both route through the single
286        // constant-time owner. Byte-length equality implies char-boundary
287        // equality for equal-length UTF-8 comparison purposes here.
288        constant_time_bytes_eq(self.as_str().as_bytes(), other.as_str().as_bytes())
289    }
290}
291
292impl Eq for SensitiveString {}
293
294impl PartialOrd for SensitiveString {
295    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
296        Some(self.cmp(other))
297    }
298}
299
300impl Ord for SensitiveString {
301    fn cmp(&self, other: &Self) -> Ordering {
302        self.as_str().cmp(other.as_str())
303    }
304}
305
306impl Hash for SensitiveString {
307    fn hash<H: Hasher>(&self, state: &mut H) {
308        self.as_str().hash(state);
309    }
310}
311
312impl From<String> for SensitiveString {
313    fn from(s: String) -> Self {
314        Self::new(s)
315    }
316}
317
318impl From<&str> for SensitiveString {
319    fn from(s: &str) -> Self {
320        Self::new(s.to_string())
321    }
322}
323
324impl From<&String> for SensitiveString {
325    fn from(s: &String) -> Self {
326        Self::new(s.clone())
327    }
328}
329
330impl std::fmt::Display for SensitiveString {
331    /// Same redaction as `Debug` / `Credential::Display` (KH-1424). Plaintext
332    /// leaves only through [`Self::as_str`] / `Deref` / `AsRef`.
333    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334        write!(f, "<redacted {} bytes>", self.inner.len())
335    }
336}
337
338impl std::fmt::Debug for SensitiveString {
339    /// Refuse to print the inner string. `SensitiveString` backs scan-chunk
340    /// data (`Chunk::data`), which can contain raw credential material -
341    /// decoded secrets, `.env` lines, archive-entry bytes. The previous impl
342    /// emitted `SensitiveString("<raw content>")`, leaking those bytes into
343    /// any `{:?}` print, `tracing::debug!(?chunk)` span, or panic message.
344    /// Mirror the `Credential::Debug` byte-count redaction (kimi-wave1
345    /// finding 1.1). `Display` also redacts (KH-1424); use [`Self::as_str`].
346    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347        write!(f, "SensitiveString(<redacted {} bytes>)", self.inner.len())
348    }
349}
350
351impl Serialize for SensitiveString {
352    /// Refuse implicit serialization because this value can contain source
353    /// text or credential plaintext. Callers that intentionally own a private,
354    /// protected wire format must serialize [`Self::as_str`] explicitly.
355    fn serialize<S: Serializer>(&self, _serializer: S) -> Result<S::Ok, S::Error> {
356        Err(serde::ser::Error::custom(
357            "SensitiveString refuses implicit plaintext serialization; call as_str() explicitly only for a protected private channel",
358        ))
359    }
360}
361
362impl<'de> Deserialize<'de> for SensitiveString {
363    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
364        String::deserialize(deserializer).map(Self::new)
365    }
366}
367
368// Tests for the constant-time equality contract of `Credential` /
369// `SensitiveString` live in `crates/core/tests/property/credential_contract.rs`.
370// The crate forbids inline cfg-test modules in `credential.rs` (enforced by the
371// `credential_no_inline_tests` / `no_inline_tests_in_src` gates, which reject the
372// literal cfg-test attribute anywhere in this file (hence the paraphrase here)).
373// The property suite subsumes and strengthens the removed inline cases: the
374// equal-prefix / differing-suffix / length-mismatch / empty cases are covered
375// over 10k arbitrary inputs by `prop_credential_eq_iff_bytes_eq` and
376// `prop_sensitive_eq_iff_str_eq`.