dwara_core/config/
credentials.rs

1//! Credential hashing: the selector and stored-hash formats of the
2//! credential schema contract (DW-018/DW-019, peppered per #124).
3//!
4//! The lookup SELECTOR (`hex(sha256(value))`) and the stored-hash formats
5//! are shared by every credential holder — config-seeded consumers hashed
6//! at startup, the state store's credential records, and the
7//! authenticator's presentation path — so the formats are defined here
8//! with the schema instead of in any one consumer. A selector is never
9//! plaintext: an indexed store of selectors leaks nothing about key
10//! material.
11//!
12//! # Stored-hash formats (#124)
13//!
14//! - `hmac-sha256:<hex(HMAC-SHA256(pepper, secret)))>` — the PEPPERED
15//!   format every NEW write uses when a per-deployment pepper is
16//!   configured (resolved through the `SecretSource` extension seam and
17//!   threaded down as raw bytes; the security domain never touches the
18//!   extension itself). A state-DB leak alone cannot verify guesses: the
19//!   search also needs the pepper. Verification is constant-time over
20//!   the hex encodings, exactly like the sha256 path.
21//! - `sha256:<hex(sha256(secret))>` — the LEGACY unpeppered format.
22//!   Existing entries keep verifying during the transition; on
23//!   successful legacy verification with a pepper configured, the store
24//!   path re-hashes the row to the peppered format in place (the state
25//!   domain's `StateStore::rehash_credential`; config cannot link the
26//!   state path — dependency direction is downward only), so the
27//!   transition completes lazily without credential re-issue.
28//! - PHC argon2id strings (`$argon2id$...`) — the memory-hard
29//!   store-managed Basic path, unchanged by the pepper.
30//!
31//! The SELECTOR stays `hex(sha256(value))` in both modes deliberately:
32//! it must remain computable from the presented material alone (no
33//! secret input) or indexed lookups could not find the row, and a
34//! sha256 selector leaks nothing about the secret. The pepper is SECRET
35//! material: never logged, never in `Debug`, never in error text.
36//!
37//! # Secret references (DW-045)
38//!
39//! A secret-bearing config field (today: `consumers[].credentials[]`
40//! `api_key.key`) may carry either the value INLINE or a `${...}`
41//! REFERENCE resolved through the file/env vocabulary this module
42//! defines:
43//!
44//! - `${ENV_NAME}` — an environment variable (`[A-Za-z_][A-Za-z0-9_]*`;
45//!   the `file:` and `redacted` prefixes below are reserved and matched
46//!   first, so they cannot collide with a variable name). Unset or
47//!   empty fails closed.
48//! - `${file:/path/to/secret}` — a file read at resolution time (the
49//!   Docker/Kubernetes mounted-secret and systemd `LoadCredential`
50//!   shape). ONE trailing newline is trimmed (`\n` or `\r\n`) — the
51//!   convention of mounted secret files — and the remainder must be
52//!   non-empty. The read is BOUNDED at [`MAX_SECRET_FILE_BYTES`]
53//!   (1 MiB): a larger file fails closed naming the path and the limit.
54//! - `${redacted:sha256:<8hex>}` — the REDACTION PLACEHOLDER this
55//!   module's [`redact_inline_secret`] emits wherever the gateway echoes
56//!   configuration (admin `GET /config`, DW-045). It is not resolvable
57//!   BY DESIGN: a GET-then-PATCH round trip that carries a placeholder
58//!   back is rejected by validation with a precise issue instead of
59//!   silently installing placeholder bytes as a live key. A short
60//!   sha256 prefix lets operators compare WHICH key is deployed without
61//!   seeing it.
62//!
63//! Posture (pinned by tests): INLINE VALUES REMAIN ACCEPTED for
64//! backward compatibility — existing configs keep working — but they
65//! are redacted in every config echo; references are the recommended
66//! shape for new configs because the config file then never holds the
67//! secret bytes at all. A value that merely CONTAINS `${...}` mid-string
68//! is NOT a reference (no shell-style partial expansion; it stays a
69//! literal), while a value that STARTS with `${` but is not a
70//! well-formed reference — including one that is never closed, like
71//! `${unclosed` — is a validation error, never a silently installed
72//! literal key.
73//!
74//! Read-time model: references resolve at CONFIG-COMPILE time — cold
75//! start and every hot reload/re-publish re-read the env/file (the same
76//! generation-follows-config contract as TLS cert material; a rotation
77//! needs a reload to apply). Validation resolves every reference and
78//! fails closed with a `ValidationIssue` naming the field; the
79//! credential consumers (authn registry build, store seeding) resolve
80//! again immediately after publish, so a secret that breaks between
81//! validate and build is skipped loudly (the microsecond-race
82//! fail-closed backstop, the same pattern as `trusted_ca_file`). The
83//! request path NEVER touches a secret source: resolved values are
84//! hashed into selectors/stored-hashes at build time and the plaintext
85//! is dropped. The `SecretSource` extension impls (`EnvSecretSource`,
86//! `FileSecretSource`) share this module's reading rules so the seam
87//! and the config grammar cannot drift apart.
88
89use hmac::{Hmac, Mac};
90use sha2::{Digest, Sha256};
91
92/// HMAC-SHA256 keyed by the pepper (type alias for the new() call sites).
93type HmacSha256 = Hmac<Sha256>;
94
95/// Lowercase hex of a byte slice (no external hex dependency).
96fn hex(bytes: &[u8]) -> String {
97    const TABLE: &[u8; 16] = b"0123456789abcdef";
98    let mut out = String::with_capacity(bytes.len() * 2);
99    for b in bytes {
100        out.push(TABLE[usize::from(*b >> 4)] as char);
101        out.push(TABLE[usize::from(*b & 0x0f)] as char);
102    }
103    out
104}
105
106/// sha256 digest, hex-encoded (the API-key/Basic hashing path).
107pub fn sha256_hex(input: &[u8]) -> String {
108    let digest = Sha256::digest(input);
109    hex(&digest)
110}
111
112/// Lookup selector for API-key and Basic credentials: `hex(sha256(value))`.
113/// The selector is therefore a HASH, never plaintext — an indexed store of
114/// selectors leaks nothing about key material (this closes the DW-018
115/// finding that config-seeded selectors were the raw config values).
116pub fn credential_selector(value: &str) -> String {
117    sha256_hex(value.as_bytes())
118}
119
120/// Stored-hash format for the fast path: `sha256:<hex(sha256(secret))>`.
121pub fn sha256_stored_hash(secret: &str) -> String {
122    format!("sha256:{}", sha256_hex(secret.as_bytes()))
123}
124
125/// Peppered stored-hash format (#124):
126/// `hmac-sha256:<hex(HMAC-SHA256(pepper, secret))>`. Used for every NEW
127/// credential write (config seeding, store re-hash on legacy verify) when
128/// a pepper is configured; without one the legacy
129/// [`sha256_stored_hash`] format remains in force. An HMAC key shorter
130/// than the block size weakens the construction, so deployments should
131/// use at least 32 bytes of entropy (not enforced: a short pepper still
132/// strictly dominates no pepper).
133pub fn hmac_stored_hash(pepper: &[u8], secret: &str) -> String {
134    let mut mac = HmacSha256::new_from_slice(pepper).expect("HMAC accepts any key length");
135    mac.update(secret.as_bytes());
136    format!("hmac-sha256:{}", hex(&mac.finalize().into_bytes()))
137}
138
139/// A parsed `${...}` secret reference (DW-045). See the module docs for
140/// the grammar, the read-time model, and the redaction-placeholder
141/// round-trip contract.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum SecretRef {
144    /// `${ENV_NAME}`: an environment variable (any case; see
145    /// [`parse_secret_reference`] for the name grammar).
146    Env { name: String },
147    /// `${file:/path}`: a file read at resolution time.
148    File { path: String },
149    /// `${redacted:...}` (or bare `${redacted}`): the placeholder
150    /// [`redact_inline_secret`] writes. Never resolvable.
151    Redacted { fingerprint: String },
152}
153
154/// Whether `name` is a valid reference env-var name: non-empty, starts
155/// with an ASCII letter or `_`, then letters, digits, or `_`. Case is
156/// free (the `DWARA_*` convention is uppercase, but lowercase variables
157/// are legal); `file:` and `redacted` cannot collide because the
158/// reserved prefixes are matched BEFORE this check.
159fn valid_env_name(name: &str) -> bool {
160    let bytes = name.as_bytes();
161    match bytes.first() {
162        Some(b) if b.is_ascii_alphabetic() || *b == b'_' => {}
163        _ => return false,
164    }
165    bytes[1..]
166        .iter()
167        .all(|b| b.is_ascii_alphanumeric() || *b == b'_')
168}
169
170/// Classify a configured secret string (DW-045):
171///
172/// - `None` — the value is a plain literal (does not start with `${`),
173///   including values that merely start with `$` and values that only
174///   CONTAIN `${...}` mid-string (no partial expansion).
175/// - `Some(Ok(ref))` — a well-formed `${...}` reference.
176/// - `Some(Err(message))` — the value is reference-shaped (starts with
177///   `${`) but malformed, INCLUDING a reference that is never closed
178///   (`${unclosed`, `${file:/run/token`) or does not span the whole
179///   value (`${KEY}extra`): reference-shaped garbage is a validation
180///   error, never a silently installed literal key. The message says
181///   why and is safe to surface in a validation issue (it names the
182///   reference text, never a value).
183pub fn parse_secret_reference(value: &str) -> Option<Result<SecretRef, String>> {
184    if !value.starts_with("${") {
185        return None;
186    }
187    if !value.ends_with('}') {
188        return Some(Err(format!(
189            "not a valid secret reference: '{value}' (the reference is not \
190             closed — a well-formed reference is the ENTIRE value, ending in \
191             '}}': ${{ENV_NAME}}, ${{file:/path/to/secret}}, or \
192             ${{redacted:...}})"
193        )));
194    }
195    // Inner text between `${` and the trailing `}`; non-empty by shape.
196    let inner = &value[2..value.len() - 1];
197    if let Some(path) = inner.strip_prefix("file:") {
198        return Some(if path.is_empty() {
199            Err("not a valid secret reference: ${file:} names no file \
200                 (expected ${file:/path/to/secret})"
201                .to_string())
202        } else {
203            Ok(SecretRef::File {
204                path: path.to_string(),
205            })
206        });
207    }
208    if let Some(fingerprint) = inner.strip_prefix("redacted") {
209        return Some(match fingerprint.strip_prefix(':') {
210            Some(fp) => Ok(SecretRef::Redacted {
211                fingerprint: fp.to_string(),
212            }),
213            None if fingerprint.is_empty() => Ok(SecretRef::Redacted {
214                fingerprint: String::new(),
215            }),
216            None => Err(format!(
217                "not a valid secret reference: '{inner}' (did you mean \
218                 ${{redacted:...}}?)"
219            )),
220        });
221    }
222    if valid_env_name(inner) {
223        return Some(Ok(SecretRef::Env {
224            name: inner.to_string(),
225        }));
226    }
227    Some(Err(format!(
228        "not a valid secret reference: '{value}' (expected ${{ENV_NAME}}, \
229         ${{file:/path/to/secret}}, or ${{redacted:...}})"
230    )))
231}
232
233/// Upper bound on one secret file's size (DW-045, #46): secret files
234/// hold short key material, and the read must never be a
235/// memory-exhaustion vector — `${file:/dev/zero}` reads NUL bytes
236/// forever (NULs are valid UTF-8), so an unbounded read runs until the
237/// allocator gives up and takes the process with it, on every reload.
238/// Bounded reads have precedent: the SNI ClientHello reassembler caps
239/// at 64 KiB (`MAX_HELLO_BYTES` in the security/TLS domain). At or
240/// under the cap the file is read whole; anything larger fails closed
241/// naming the path and the limit. The bound is enforced BOTH on the
242/// file's metadata up front and on the bytes actually read
243/// (`Read::take`), so a file that grows between the two checks — or a
244/// non-regular file whose metadata size is 0, like `/dev/zero` — is
245/// still capped.
246pub const MAX_SECRET_FILE_BYTES: usize = 1024 * 1024;
247
248/// The fail-closed message for a secret file over
249/// [`MAX_SECRET_FILE_BYTES`]: names the path and the limit.
250fn secret_file_too_large(path: &str) -> String {
251    format!(
252        "secret file '{path}' is larger than the 1 MiB secret-file limit \
253         ({} bytes); secret files hold short key material and the read \
254         must stay bounded",
255        MAX_SECRET_FILE_BYTES
256    )
257}
258
259/// Read one secret file (DW-045): the whole file as UTF-8 with a SINGLE
260/// trailing newline trimmed (`\n` or `\r\n` — the mounted-secret-file
261/// convention; interior newlines are preserved), bounded at
262/// [`MAX_SECRET_FILE_BYTES`]. Shared by the config grammar and the
263/// `FileSecretSource` extension impl so the two cannot drift. The error
264/// names the path, never any content.
265pub fn read_secret_file(path: &str) -> Result<String, String> {
266    use std::io::Read as _;
267    let file = std::fs::File::open(path)
268        .map_err(|e| format!("secret file '{path}' cannot be read: {e}"))?;
269    // Up-front bound on the metadata: an oversized regular file is
270    // rejected without reading a byte of it.
271    if let Ok(meta) = file.metadata() {
272        if meta.len() > MAX_SECRET_FILE_BYTES as u64 {
273            return Err(secret_file_too_large(path));
274        }
275    }
276    // Read bound: take ONE byte past the cap so a file that grew since
277    // the metadata check (or whose metadata size is 0, like /dev/zero)
278    // still cannot read unbounded; landing past the cap is the same
279    // fail-closed error.
280    let mut raw = Vec::new();
281    file.take(MAX_SECRET_FILE_BYTES as u64 + 1)
282        .read_to_end(&mut raw)
283        .map_err(|e| format!("secret file '{path}' cannot be read: {e}"))?;
284    if raw.len() > MAX_SECRET_FILE_BYTES {
285        return Err(secret_file_too_large(path));
286    }
287    let raw =
288        String::from_utf8(raw).map_err(|_| format!("secret file '{path}' is not valid UTF-8"))?;
289    let trimmed = raw.strip_suffix('\n').unwrap_or(&raw);
290    let trimmed = trimmed.strip_suffix('\r').unwrap_or(trimmed);
291    if trimmed.is_empty() {
292        Err(format!(
293            "secret file '{path}' is empty (a secret reference must \
294             resolve to a non-empty value)"
295        ))
296    } else {
297        Ok(trimmed.to_string())
298    }
299}
300
301impl SecretRef {
302    /// Resolve this reference NOW (see the module docs for when callers
303    /// do that). Fails closed with a message safe for logs and
304    /// validation issues: it names the env var / file path and the
305    /// reason, never a resolved value.
306    pub fn resolve(&self) -> Result<String, String> {
307        match self {
308            SecretRef::Env { name } => match std::env::var(name) {
309                Ok(v) if !v.is_empty() => Ok(v),
310                Ok(_) => Err(format!(
311                    "environment variable '{name}' is set but empty (a \
312                     secret reference must resolve to a non-empty value)"
313                )),
314                Err(std::env::VarError::NotPresent) => Err(format!(
315                    "environment variable '{name}' is not set (secret \
316                     references are resolved at config-compile time; the \
317                     variable must exist in the gateway's environment)"
318                )),
319                Err(std::env::VarError::NotUnicode(_)) => Err(format!(
320                    "environment variable '{name}' is set but not valid Unicode"
321                )),
322            },
323            SecretRef::File { path } => read_secret_file(path),
324            SecretRef::Redacted { .. } => Err(
325                "redaction placeholder: the real value is withheld from every \
326                 config echo (DW-045); re-enter the secret or reference it as \
327                 ${ENV_NAME} or ${file:/path/to/secret}"
328                    .to_string(),
329            ),
330        }
331    }
332}
333
334/// Resolve a configured secret string (DW-045): literals pass through
335/// untouched; well-formed references resolve at CALL time; malformed
336/// reference-shaped values fail closed. Every consumer of secret-bearing
337/// config fields (snapshot validation, the authn registry build, store
338/// seeding) goes through this one entry point so no path can bypass
339/// resolution.
340pub fn resolve_configured_secret(value: &str) -> Result<String, String> {
341    match parse_secret_reference(value) {
342        None => Ok(value.to_string()),
343        Some(Ok(reference)) => reference.resolve(),
344        Some(Err(message)) => Err(message),
345    }
346}
347
348/// The redaction placeholder for an INLINE secret value (DW-045):
349/// `${redacted:sha256:<first 8 hex of sha256>}`. The short prefix lets
350/// an operator compare which key a generation carries without ever
351/// seeing the key; the placeholder itself is unresolvable by design, so
352/// echoing it back through a config-publishing surface is rejected
353/// fail-closed instead of installing placeholder bytes as a live key.
354/// Reference-shaped values are returned UNCHANGED (an env-var name or
355/// file path is not secret bytes — the config file already carries it).
356pub fn redact_inline_secret(value: &str) -> String {
357    if parse_secret_reference(value).is_some() {
358        return value.to_string();
359    }
360    format!(
361        "${{redacted:sha256:{}}}",
362        &sha256_hex(value.as_bytes())[..8]
363    )
364}