rustio_admin/admin/redact.rs
1//! Sanitisation helpers for log lines, audit summaries, and error
2//! messages.
3//!
4//! Doctrine 11: never log secrets. Recovery flows route every secret-
5//! adjacent string through one of these helpers before it reaches the
6//! audit trail or any log target. The functions return either a
7//! fixed placeholder string (for genuinely opaque secrets like
8//! passwords and MFA secrets) or a short fingerprint (for tokens that
9//! benefit from being correlatable in support traffic without leaking
10//! the full value).
11//!
12//! Adopted by:
13//!
14//! - `audit::record` summary text generation
15//! - the upcoming `Mailer` debug logging path
16//! - any handler that needs to format a status string mentioning a
17//! token or password
18//!
19//! If you find yourself wanting to log a secret directly, ask
20//! whether the log line is more useful than the risk of disclosure.
21//! In every case the framework has shipped so far, the answer is no.
22
23use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
24use sha2::{Digest, Sha256};
25
26// public:
27/// Replace any password-like value with a fixed placeholder. Use this
28/// in summary strings and error messages — never the real password,
29/// not even truncated.
30pub const fn redact_password() -> &'static str {
31 "<password>"
32}
33
34// public:
35/// Render a short, privacy-preserving fingerprint of a token. The
36/// returned string includes the first 8 chars of `sha256(token)` —
37/// just enough for an operator to correlate two log lines about the
38/// same token without disclosing it. Never reverses to the original.
39///
40/// Used for: session-cookie tokens, password-reset tokens, future
41/// API keys.
42pub fn redact_token(token: &str) -> String {
43 let digest = Sha256::digest(token.as_bytes());
44 let hex = URL_SAFE_NO_PAD.encode(digest);
45 // 8 chars of the b64-encoded sha256 — ~48 bits of fingerprint.
46 // Plenty for correlation; not reversible.
47 let prefix: String = hex.chars().take(8).collect();
48 format!("<token:…{prefix}>")
49}
50
51// public:
52/// Replace an MFA secret with a fixed placeholder. MFA secrets are
53/// always stored encrypted at rest; this helper exists so a stray
54/// log statement during development can't accidentally write the
55/// plaintext.
56pub const fn redact_mfa_secret() -> &'static str {
57 "<mfa-secret>"
58}
59
60// public:
61/// Replace a backup code with a fixed placeholder. Codes are
62/// short-lived single-use; like passwords, the right log line is
63/// "redacted" with no fingerprint.
64pub const fn redact_backup_code() -> &'static str {
65 "<backup-code>"
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71
72 #[test]
73 fn redact_password_returns_fixed_placeholder() {
74 assert_eq!(redact_password(), "<password>");
75 }
76
77 #[test]
78 fn redact_token_is_deterministic() {
79 let a = redact_token("abc123");
80 let b = redact_token("abc123");
81 assert_eq!(a, b);
82 }
83
84 #[test]
85 fn redact_token_changes_per_token() {
86 assert_ne!(redact_token("aaa"), redact_token("bbb"));
87 }
88
89 #[test]
90 fn redact_token_reveals_no_substring_of_input() {
91 // Property: the redacted form must not contain any 4-char
92 // substring of the input. Catches accidental "show last N
93 // chars" regressions.
94 let plaintext = "secretSESSIONcookie";
95 let r = redact_token(plaintext);
96 for win in plaintext.as_bytes().windows(4) {
97 let needle = std::str::from_utf8(win).unwrap();
98 assert!(!r.contains(needle), "redaction leaked {needle:?}: {r}");
99 }
100 }
101
102 #[test]
103 fn redact_token_format_is_recognizable() {
104 let r = redact_token("anything");
105 assert!(r.starts_with("<token:…"));
106 assert!(r.ends_with('>'));
107 // 8-char fingerprint between the marker and closing >.
108 assert_eq!(r.len(), "<token:…".len() + 8 + ">".len());
109 }
110
111 #[test]
112 fn redact_mfa_secret_is_constant() {
113 assert_eq!(redact_mfa_secret(), "<mfa-secret>");
114 }
115
116 #[test]
117 fn redact_backup_code_is_constant() {
118 assert_eq!(redact_backup_code(), "<backup-code>");
119 }
120}