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