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`.