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