Skip to main content

keyhog_core/
credential.rs

1//! Opaque, zeroize-on-drop credential bytes.
2//!
3//! Replaces the previous `Arc<str>` credential field with a type that:
4//!
5//! 1. Zeroes its bytes on drop (`zeroize` crate). Heap pages keyhog freed
6//!    while a scan was in flight no longer leak credentials to the next
7//!    allocator request, swap, or post-mortem core dump.
8//! 2. Refuses `Debug` / `Display` printing — every leak path through `{:?}`
9//!    or `{}` becomes `<redacted N bytes>` instead of the bytes themselves.
10//!    To get the bytes you must call `expose_secret()` explicitly, which
11//!    grep'ing the codebase for can audit every credential touch site.
12//! 3. Is `Clone` and serializable via `serde` (uses the `expose_secret()`
13//!    bytes for `Serialize`, decodes back to a fresh `Credential` for
14//!    `Deserialize`). The serialization channel is the responsibility of
15//!    the caller — find emitters that go to disk/JSON and either redact
16//!    them or wrap the entire output in EnvSeal seal.
17//!
18//! When EnvSeal embeds keyhog, this type is the only place credential
19//! bytes ever appear in process memory; an mlock + memfd backing can be
20//! added behind the `lockdown` feature gate without touching call sites.
21
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23use std::cmp::Ordering;
24use std::hash::{Hash, Hasher};
25use std::sync::Arc;
26use zeroize::Zeroizing;
27
28/// Opaque credential bytes. The inner `Arc<Zeroizing<Box<[u8]>>>` clones are
29/// cheap (refcount bump) but every owning `Credential` zeroizes on drop.
30/// `Arc` lets the engine intern identical credentials without copying;
31/// when the last ref drops, `Zeroizing<Box<[u8]>>` overwrites the heap
32/// allocation before `Box::drop` returns it to the allocator.
33#[derive(Clone)]
34pub struct Credential {
35    inner: Arc<Zeroizing<Box<[u8]>>>,
36}
37
38impl Credential {
39    /// Build a `Credential` from raw bytes. The bytes are copied into a
40    /// fresh `Zeroizing<Box<[u8]>>` and the input slice is unchanged
41    /// (caller is responsible for zeroizing whatever it came from).
42    #[must_use]
43    pub fn from_bytes(bytes: &[u8]) -> Self {
44        Self {
45            inner: Arc::new(Zeroizing::new(bytes.to_vec().into_boxed_slice())),
46        }
47    }
48
49    /// Build a `Credential` from a borrowed `str`. Same semantics as
50    /// `from_bytes` — bytes are copied into the zeroizing allocation.
51    /// Named `from_text` (not `from_str`) to avoid the
52    /// `clippy::should_implement_trait` lint and to keep the API
53    /// distinct from `core::str::FromStr` (which has different error
54    /// semantics — we never fail to construct a Credential).
55    #[must_use]
56    pub fn from_text(s: &str) -> Self {
57        Self::from_bytes(s.as_bytes())
58    }
59
60    /// Length in bytes.
61    #[must_use]
62    pub fn len(&self) -> usize {
63        self.inner.len()
64    }
65
66    #[must_use]
67    pub fn is_empty(&self) -> bool {
68        self.inner.is_empty()
69    }
70
71    /// Expose the underlying bytes. Every call site MUST be auditable —
72    /// `git grep expose_secret` should surface every place credentials
73    /// leave the opaque wrapper. Treat each one as a security review item.
74    ///
75    /// Returns a `&[u8]` rather than `&str` because credentials may be
76    /// non-UTF-8 (binary-encoded keys, raw private-key bytes, etc).
77    #[must_use]
78    pub fn expose_secret(&self) -> &[u8] {
79        &self.inner
80    }
81
82    /// Expose the credential as a `&str` if it's valid UTF-8, otherwise
83    /// `None`. Most production credentials ARE valid UTF-8 (provider keys,
84    /// tokens, base64) so this is the common path.
85    #[must_use]
86    pub fn expose_str(&self) -> Option<&str> {
87        std::str::from_utf8(&self.inner).ok()
88    }
89}
90
91impl From<&str> for Credential {
92    fn from(s: &str) -> Self {
93        Self::from_text(s)
94    }
95}
96
97impl From<String> for Credential {
98    fn from(s: String) -> Self {
99        // The input `String`'s buffer is dropped without zeroizing — the
100        // caller should ideally pass `&str` so the bytes never sit in a
101        // non-zeroizing `String`. We do the right thing for our own
102        // allocation either way.
103        Self::from_bytes(s.as_bytes())
104    }
105}
106
107impl From<&[u8]> for Credential {
108    fn from(b: &[u8]) -> Self {
109        Self::from_bytes(b)
110    }
111}
112
113impl From<Vec<u8>> for Credential {
114    fn from(v: Vec<u8>) -> Self {
115        Self::from_bytes(&v)
116    }
117}
118
119impl PartialEq for Credential {
120    fn eq(&self, other: &Self) -> bool {
121        // Constant-time equality. Credentials are compared during dedup
122        // and inflight de-duplication; using `==` on naked bytes leaks
123        // information through CPU branch timing in pathological cases.
124        // The cost is one extra XOR per byte vs `==`, negligible at the
125        // sizes of credentials (<1 KiB typical).
126        if self.inner.len() != other.inner.len() {
127            return false;
128        }
129        let mut diff: u8 = 0;
130        for (a, b) in self.inner.iter().zip(other.inner.iter()) {
131            diff |= a ^ b;
132        }
133        diff == 0
134    }
135}
136
137impl Eq for Credential {}
138
139impl PartialOrd for Credential {
140    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
141        Some(self.cmp(other))
142    }
143}
144
145impl Ord for Credential {
146    fn cmp(&self, other: &Self) -> Ordering {
147        self.inner
148            .as_ref()
149            .as_ref()
150            .cmp(other.inner.as_ref().as_ref())
151    }
152}
153
154impl Hash for Credential {
155    fn hash<H: Hasher>(&self, state: &mut H) {
156        self.inner.as_ref().as_ref().hash(state);
157    }
158}
159
160impl std::fmt::Debug for Credential {
161    /// Refuse to format the bytes. This is a compile-time leak guard —
162    /// every place that did `eprintln!("{:?}", cred)` or `tracing::error!(?cred)`
163    /// now prints `Credential(<redacted N bytes>)` instead of the secret.
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        write!(f, "Credential(<redacted {} bytes>)", self.inner.len())
166    }
167}
168
169impl std::fmt::Display for Credential {
170    /// Same redaction as `Debug` — `format!("{}", cred)` returns the
171    /// redacted form, never the bytes.
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        write!(f, "<redacted {} bytes>", self.inner.len())
174    }
175}
176
177impl Serialize for Credential {
178    /// Serialize as a tagged JSON object so the encoding is unambiguous.
179    /// kimi-wave2 §Critical: the previous `"b64:<base64>"` string-prefix
180    /// scheme round-tripped a UTF-8 credential like `"b64:SGVsbG8="`
181    /// (a literal user-typed value) through the deserializer as if it
182    /// were base64-encoded bytes, silently corrupting it. The tagged
183    /// variant `{"text":"…"}` / `{"b64":"…"}` cannot be confused with
184    /// either form.
185    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
186        use serde::ser::SerializeMap;
187        let mut m = serializer.serialize_map(Some(1))?;
188        match self.expose_str() {
189            Some(s) => m.serialize_entry("text", s)?,
190            None => m.serialize_entry("b64", &base64_encode(&self.inner))?,
191        }
192        m.end()
193    }
194}
195
196impl<'de> Deserialize<'de> for Credential {
197    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
198        // Accept the new tagged form (preferred) OR the legacy
199        // `b64:<base64>` / plain string forms (so on-disk artifacts
200        // from earlier versions still load). The legacy ambiguity is
201        // exactly what kimi-wave2 §Critical flagged; new writers must
202        // use the tagged form.
203        #[derive(Deserialize)]
204        #[serde(untagged)]
205        enum Wire {
206            Tagged {
207                #[serde(default)]
208                text: Option<String>,
209                #[serde(default)]
210                b64: Option<String>,
211            },
212            Legacy(String),
213        }
214        match Wire::deserialize(deserializer)? {
215            Wire::Tagged {
216                text: Some(t),
217                b64: None,
218            } => Ok(Credential::from_text(&t)),
219            Wire::Tagged {
220                text: None,
221                b64: Some(b),
222            } => {
223                let bytes = crate::encoding::decode_standard_base64(&b)
224                    .map_err(serde::de::Error::custom)?;
225                Ok(Credential::from_bytes(&bytes))
226            }
227            Wire::Tagged { .. } => Err(serde::de::Error::custom(
228                "Credential must specify exactly one of `text` or `b64`",
229            )),
230            Wire::Legacy(s) => {
231                if let Some(rest) = s.strip_prefix("b64:") {
232                    let bytes = crate::encoding::decode_standard_base64(rest)
233                        .map_err(serde::de::Error::custom)?;
234                    Ok(Credential::from_bytes(&bytes))
235                } else {
236                    Ok(Credential::from_text(&s))
237                }
238            }
239        }
240    }
241}
242
243/// Minimal base64 encoder so this module doesn't need a `base64` crate dep.
244fn base64_encode(input: &[u8]) -> String {
245    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
246    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
247    for chunk in input.chunks(3) {
248        let b0 = chunk[0];
249        let b1 = chunk.get(1).copied().unwrap_or(0);
250        let b2 = chunk.get(2).copied().unwrap_or(0);
251        out.push(TABLE[(b0 >> 2) as usize] as char);
252        out.push(TABLE[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
253        if chunk.len() > 1 {
254            out.push(TABLE[(((b1 & 0x0F) << 2) | (b2 >> 6)) as usize] as char);
255        } else {
256            out.push('=');
257        }
258        if chunk.len() > 2 {
259            out.push(TABLE[(b2 & 0x3F) as usize] as char);
260        } else {
261            out.push('=');
262        }
263    }
264    out
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn debug_redacts_bytes() {
273        let c = Credential::from_text("AKIAIOSFODNN7EXAMPLE");
274        let s = format!("{c:?}");
275        assert!(s.contains("redacted"));
276        assert!(!s.contains("AKIA"));
277    }
278
279    #[test]
280    fn display_redacts_bytes() {
281        let c = Credential::from_text("ghp_abcdef1234567890");
282        let s = format!("{c}");
283        assert!(s.contains("redacted"));
284        assert!(!s.contains("ghp_"));
285    }
286
287    #[test]
288    fn expose_secret_returns_bytes() {
289        let c = Credential::from_text("hello");
290        assert_eq!(c.expose_secret(), b"hello");
291        assert_eq!(c.expose_str(), Some("hello"));
292    }
293
294    #[test]
295    fn equality_constant_time() {
296        let a = Credential::from_text("aaa");
297        let b = Credential::from_text("aaa");
298        let c = Credential::from_text("aab");
299        assert_eq!(a, b);
300        assert_ne!(a, c);
301    }
302
303    #[test]
304    fn serialize_utf8_credential_as_tagged_text() {
305        // kimi-wave2 §Critical: the wire format is now an explicit tagged
306        // object, NOT a string-with-prefix. The tag eliminates the
307        // ambiguity where `"b64:SGVsbG8="` (a literal user-typed string)
308        // round-tripped as base64-decoded bytes.
309        let c = Credential::from_text("AKIA1234");
310        let json = serde_json::to_string(&c).unwrap();
311        assert_eq!(json, "{\"text\":\"AKIA1234\"}");
312    }
313
314    #[test]
315    fn serialize_binary_credential_as_tagged_b64() {
316        let c = Credential::from_bytes(&[0xFF, 0xFE, 0x00, 0x42]);
317        let json = serde_json::to_string(&c).unwrap();
318        assert!(
319            json.starts_with("{\"b64\":\""),
320            "expected tagged b64 envelope, got {json}"
321        );
322    }
323
324    #[test]
325    fn legacy_b64_prefix_still_deserializes() {
326        // Backwards compat: on-disk artifacts written by older keyhog
327        // versions used the `"b64:<base64>"` string form. The new
328        // deserializer falls back to that path.
329        let bytes = [0xFF, 0xFE, 0x00, 0x42];
330        let legacy = format!("\"b64:{}\"", super::base64_encode(&bytes));
331        let back: Credential = serde_json::from_str(&legacy).unwrap();
332        assert_eq!(back.expose_secret(), &bytes);
333    }
334
335    #[test]
336    fn legacy_plain_string_still_deserializes() {
337        let back: Credential = serde_json::from_str("\"AKIA1234\"").unwrap();
338        assert_eq!(back.expose_str(), Some("AKIA1234"));
339    }
340
341    #[test]
342    fn round_trip_serde() {
343        let c = Credential::from_text("xoxb-1234-5678-abc");
344        let json = serde_json::to_string(&c).unwrap();
345        let back: Credential = serde_json::from_str(&json).unwrap();
346        assert_eq!(c, back);
347    }
348
349    #[test]
350    fn round_trip_binary_serde() {
351        let c = Credential::from_bytes(&[0x00, 0x01, 0xFF, 0xFE]);
352        let json = serde_json::to_string(&c).unwrap();
353        let back: Credential = serde_json::from_str(&json).unwrap();
354        assert_eq!(c, back);
355    }
356
357    #[test]
358    fn cloning_does_not_duplicate_buffer() {
359        let a = Credential::from_text("shared");
360        let b = a.clone();
361        // Same Arc backing; addresses match.
362        assert!(std::ptr::eq(
363            a.expose_secret().as_ptr(),
364            b.expose_secret().as_ptr()
365        ));
366    }
367}
368
369/// A heap-allocated string that is zeroized on drop.
370#[derive(Clone, Default)]
371pub struct SensitiveString {
372    inner: Arc<Zeroizing<String>>,
373}
374
375impl SensitiveString {
376    pub fn new(s: String) -> Self {
377        Self {
378            inner: Arc::new(Zeroizing::new(s)),
379        }
380    }
381
382    pub fn join(parts: &[SensitiveString], sep: &str) -> Self {
383        let mut s = String::new();
384        for (i, p) in parts.iter().enumerate() {
385            if i > 0 {
386                s.push_str(sep);
387            }
388            s.push_str(p.as_str());
389        }
390        Self::new(s)
391    }
392
393    pub fn as_str(&self) -> &str {
394        self.inner.as_str()
395    }
396
397    pub fn as_bytes(&self) -> &[u8] {
398        self.inner.as_bytes()
399    }
400
401    pub fn len(&self) -> usize {
402        self.inner.len()
403    }
404
405    pub fn is_empty(&self) -> bool {
406        self.inner.is_empty()
407    }
408}
409
410impl std::ops::Deref for SensitiveString {
411    type Target = str;
412    fn deref(&self) -> &Self::Target {
413        self.as_str()
414    }
415}
416
417impl AsRef<str> for SensitiveString {
418    fn as_ref(&self) -> &str {
419        self.as_str()
420    }
421}
422
423impl From<String> for SensitiveString {
424    fn from(s: String) -> Self {
425        Self::new(s)
426    }
427}
428
429impl From<&str> for SensitiveString {
430    fn from(s: &str) -> Self {
431        Self::new(s.to_string())
432    }
433}
434
435impl From<&String> for SensitiveString {
436    fn from(s: &String) -> Self {
437        Self::new(s.clone())
438    }
439}
440
441impl std::fmt::Display for SensitiveString {
442    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
443        write!(f, "{}", self.as_str())
444    }
445}
446
447impl std::fmt::Debug for SensitiveString {
448    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449        write!(f, "SensitiveString({:?})", self.as_str())
450    }
451}
452
453impl Serialize for SensitiveString {
454    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
455        self.as_str().serialize(serializer)
456    }
457}
458
459impl<'de> Deserialize<'de> for SensitiveString {
460    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
461        String::deserialize(deserializer).map(Self::new)
462    }
463}