keyhog_core/
credential.rs1use serde::{Deserialize, Deserializer, Serialize, Serializer};
23use std::cmp::Ordering;
24use std::hash::{Hash, Hasher};
25use std::sync::Arc;
26use zeroize::Zeroizing;
27
28#[derive(Clone)]
34pub struct Credential {
35 inner: Arc<Zeroizing<Box<[u8]>>>,
36}
37
38impl Credential {
39 #[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 #[must_use]
56 pub fn from_text(s: &str) -> Self {
57 Self::from_bytes(s.as_bytes())
58 }
59
60 #[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 #[must_use]
78 pub fn expose_secret(&self) -> &[u8] {
79 &self.inner
80 }
81
82 #[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 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 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 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 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 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 #[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
243fn 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 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 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 assert!(std::ptr::eq(
363 a.expose_secret().as_ptr(),
364 b.expose_secret().as_ptr()
365 ));
366 }
367}
368
369#[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}