djogi 0.1.0-alpha.17

Model-first web framework for Rust — web-framework-agnostic core; Axum integration opt-in via the `axum` feature flag
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
//! HMAC-SHA256 sign + constant-time verify primitives for migration snapshots.
//! # Threat model
//! The signed object is `migrations/schema_snapshot.json` (and, in future
//! tasks, the `djogi_ddl_audit` row payloads). The signature deters two
//! attacker classes:
//! 1. **Filesystem tamper.** An operator (or compromised CI cache) edits the
//!    snapshot to suppress a drift warning or revert a schema change. The
//!    differ would otherwise believe the edited file represents the
//!    schema-of-record and silently mis-classify the next migration. With
//!    signing enabled, the signature on the persisted file no longer
//!    verifies and the differ refuses to trust the snapshot.
//! 2. **Timing side-channel.** A naive verify path that compares signature
//!    bytes with `==` short-circuits on the first mismatching byte, leaking
//!    one byte of secret per probe. We compute the full HMAC, then compare
//!    via [`subtle::ConstantTimeEq`], so any two 32-byte signatures take the
//!    same number of cycles to compare regardless of how many leading bytes
//!    happen to agree.
//! # No-op key sentinel
//! The framework supports unsigned snapshots — dev workflows, ephemeral CI,
//! and any deployment that does not set `DJOGI_SNAPSHOT_SIGNING_KEY` should
//! continue to work without ceremony. We encode "signing disabled" as the
//! all-zeros key `[0u8; 32]`:
//! - [`sign_snapshot`] short-circuits to `[0u8; 32]` — the signature is
//!   never computed, and every snapshot persisted under the sentinel key
//!   carries the same zero-byte signature.
//! - [`verify_snapshot`] returns `true` for the exact pair `(zero_sig,
//! zero_key)` and `false` for any other shape. Crucially, when an
//!   attacker submits a non-zero forged signature against the no-op key,
//!   the comparison still routes through the constant-time path so the
//!   bypass attempt does not leak timing information.
//!   Production deployments that care about integrity simply set
//!   `DJOGI_SNAPSHOT_SIGNING_KEY` to a random 32-byte hex value. The same
//!   key must round-trip to the verifier; key rotation is a future-task
//!   concern.
//!   See the parent module for the higher-level snapshot integrity story.

use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use std::env;
use subtle::ConstantTimeEq;

// The all-zero sentinel `[0u8; 32]` denotes "signing disabled".
// HMAC-SHA256 over real input has a ~2^-256 probability of producing
// this output — vanishingly small (lower than a hash collision in
// the lifetime of the universe). The sentinel is therefore safe
// even though it occupies a value in the legitimate output range.

/// Signing-disabled sentinel. Per the module-level "No-op key sentinel"
/// section, all-zeros means "do not compute or verify HMAC".
const NOOP_KEY: [u8; 32] = [0u8; 32];

/// Zero-byte signature emitted under the no-op key. Verifying any other
/// signature against the no-op key returns `false`.
const NOOP_SIG: [u8; 32] = [0u8; 32];

/// HMAC-SHA256 type alias used for snapshot signing.
type HmacSha256 = Hmac<Sha256>;

#[cfg(test)]
pub(crate) static SIGNING_KEY_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Sign `json_bytes` with `key` using HMAC-SHA256.
/// Returns the 32-byte MAC over the input bytes. The caller is responsible
/// for serialising the snapshot to a canonical byte form before calling
/// (typically `serde_json::to_vec_pretty(&snapshot)`).
/// # No-op key sentinel
/// When `key == [0u8; 32]` (the "signing disabled" default), the function
/// short-circuits to `[0u8; 32]` without invoking the HMAC primitive. This
/// makes `DJOGI_SNAPSHOT_SIGNING_KEY` opt-in: dev and CI runs that don't
/// set the env var produce zero-signature snapshots that round-trip
/// cleanly via [`verify_snapshot`].
pub fn sign_snapshot(json_bytes: &[u8], key: &[u8; 32]) -> [u8; 32] {
    if key == &NOOP_KEY {
        return NOOP_SIG;
    }

    // `Hmac::new_from_slice` only fails for KEY-length-mismatch errors on
    // ciphers that constrain key length; HMAC accepts any byte length so
    // this never errors in practice. `expect` documents the invariant
    // rather than papering over a real failure mode.
    let mut mac = HmacSha256::new_from_slice(key)
        .expect("HMAC accepts any key length; passing &[u8; 32] cannot fail");
    mac.update(json_bytes);
    let result = mac.finalize().into_bytes();

    let mut out = [0u8; 32];
    out.copy_from_slice(&result);
    out
}

/// Verify `signature` against HMAC-SHA256 of `json_bytes` under `key`.
/// Returns `true` if the signature is authentic, `false` otherwise.
/// # Constant-time comparison
/// The actual byte comparison routes through [`subtle::ConstantTimeEq`]
/// the function never short-circuits on a leading-byte mismatch and never
/// uses `==` on signature bytes. This prevents timing-side-channel leaks
/// of the secret signature material.
/// # No-op key sentinel
/// - When `key == [0u8; 32]` AND `signature == [0u8; 32]`, returns `true`
///   (matches the [`sign_snapshot`] no-op path).
/// - When `key == [0u8; 32]` AND `signature != [0u8; 32]`, the constant-time
///   comparison still runs against the zero signature and returns `false`.
///   An attacker forging a non-zero signature against the no-op key cannot
///   short-circuit verification or leak timing information.
pub fn verify_snapshot(json_bytes: &[u8], signature: &[u8; 32], key: &[u8; 32]) -> bool {
    let expected = sign_snapshot(json_bytes, key);
    // `subtle::Choice` -> `bool` via `bool::from` keeps the comparison
    // explicitly constant-time. Do NOT replace with `expected == *signature`;
    // PartialEq for arrays short-circuits on first byte mismatch.
    bool::from(expected.ct_eq(signature))
}

/// Read `DJOGI_SNAPSHOT_SIGNING_KEY` from the environment and decode it as
/// a 32-byte HMAC key.
/// The env var must be **exactly 64 hex characters**, lowercase or uppercase
/// (`0`–`9`, `a`–`f`, `A`–`F`). Any other length or out-of-range byte yields
/// a [`SnapshotKeyError`]. When the variable is unset, returns `Ok(None)`
/// callers treat that as "use the no-op key sentinel".
/// # Implementation notes
/// Hex parsing is hand-rolled via a byte loop; per the djogi-wide rule
/// (`feedback_no_regex_in_djogi.md`) and to avoid a transitive dependency
/// on the `hex` crate for two dozen lines of decode logic. Match arms cover
/// the three ASCII ranges explicitly and reject everything else.
pub fn load_signing_key_from_env() -> Result<Option<[u8; 32]>, SnapshotKeyError> {
    let raw = match env::var("DJOGI_SNAPSHOT_SIGNING_KEY") {
        Ok(s) => s,
        // Unset → caller falls back to the no-op key sentinel.
        Err(env::VarError::NotPresent) => return Ok(None),
        // Set but non-UTF-8 → surface as a hard error rather than
        // silently degrading to the no-op sentinel. Collapsing this
        // into `Ok(None)` would let a malformed env var (typo,
        // accidental binary paste, injection) downgrade signing to a
        // no-op without warning, which violates the documented
        // signing contract. The operator must explicitly fix or
        // unset the variable.
        Err(env::VarError::NotUnicode(_)) => return Err(SnapshotKeyError::NonUnicodeEnvVar),
    };

    let bytes = raw.as_bytes();
    if bytes.len() != 64 {
        return Err(SnapshotKeyError::InvalidLength {
            actual: bytes.len(),
        });
    }

    let mut out = [0u8; 32];
    let mut i = 0;
    while i < 32 {
        let hi = decode_hex_nibble(bytes[i * 2], i * 2)?;
        let lo = decode_hex_nibble(bytes[i * 2 + 1], i * 2 + 1)?;
        out[i] = (hi << 4) | lo;
        i += 1;
    }
    Ok(Some(out))
}

/// Decode a single ASCII hex character into its 4-bit nibble. Accepts
/// lowercase `a`–`f`, uppercase `A`–`F`, and digits `0`–`9`. Anything else
/// returns [`SnapshotKeyError::InvalidHexByte`] tagged with the original
/// byte's index in the input string.
fn decode_hex_nibble(byte: u8, idx: usize) -> Result<u8, SnapshotKeyError> {
    match byte {
        b'0'..=b'9' => Ok(byte - b'0'),
        b'a'..=b'f' => Ok(byte - b'a' + 10),
        b'A'..=b'F' => Ok(byte - b'A' + 10),
        _ => Err(SnapshotKeyError::InvalidHexByte { idx, byte }),
    }
}

/// Errors surfaced by [`load_signing_key_from_env`].
/// The variants are deliberately narrow — the failure modes are wrong-length
/// input, a stray non-hex byte, and a non-UTF-8 env-var value. We hand-roll
/// `Display` and `Error` rather than pulling `thiserror` in for three
/// variants; the message format is stable and the boilerplate is trivial.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SnapshotKeyError {
    /// The env var was set but its byte length was not exactly 64. The
    /// `actual` field reports the observed length so log lines can help
    /// operators diagnose copy-paste truncation or extra whitespace.
    InvalidLength { actual: usize },
    /// A byte at index `idx` was not a valid hex digit. `byte` carries the
    /// offending byte for diagnostics; the framework does not log the
    /// surrounding bytes (which together with `idx` could leak partial
    /// secret material — see the verify-side timing-channel rationale at
    /// the top of this module).
    InvalidHexByte { idx: usize, byte: u8 },
    /// `DJOGI_SNAPSHOT_SIGNING_KEY` is set but contains non-UTF-8 bytes.
    /// Treating this as "no key set" would silently degrade signing to
    /// the no-op sentinel — a security regression. Surface as an error
    /// so the operator must explicitly fix the env var or unset it.
    NonUnicodeEnvVar,
}

impl std::fmt::Display for SnapshotKeyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SnapshotKeyError::InvalidLength { actual } => write!(
                f,
                "DJOGI_SNAPSHOT_SIGNING_KEY must be exactly 64 hex characters (got {actual})",
            ),
            SnapshotKeyError::InvalidHexByte { idx, byte } => write!(
                f,
                "DJOGI_SNAPSHOT_SIGNING_KEY contains a non-hex byte at index {idx} (byte 0x{byte:02x})",
            ),
            SnapshotKeyError::NonUnicodeEnvVar => write!(
                f,
                "DJOGI_SNAPSHOT_SIGNING_KEY is set but contains non-UTF-8 bytes; \
                 fix the value or unset the variable to disable signing",
            ),
        }
    }
}

impl std::error::Error for SnapshotKeyError {}

#[cfg(test)]
mod tests {
    use super::*;

    // The process-wide mutex lives outside this test module so
    // `migrate::runner` tests can share the same env-var guard. `cargo test`
    // runs unit tests in parallel by default; every test in this crate that
    // reads or mutates `DJOGI_SNAPSHOT_SIGNING_KEY` must hold the mutex.

    /// Pinned HMAC-SHA256 test vector. Not an RFC-4231 case (those use
    /// variable-length keys; our API is fixed `[u8; 32]`); this vector is
    /// self-consistent and reproducible via:
    /// ```sh
    /// printf 'Hi There' | openssl dgst -sha256 \
    ///     -mac HMAC -macopt hexkey:0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b
    /// ```
    /// The 32-byte key is twenty `0x0b` bytes (RFC-4231 vector 1's key
    /// material) zero-padded to 32 bytes. The data is `b"Hi There"`.
    /// Expected MAC pinned below.
    #[test]
    fn sign_known_input_known_key() {
        let mut key = [0u8; 32];
        for slot in key.iter_mut().take(20) {
            *slot = 0x0b;
        }
        let data = b"Hi There";
        // Generated via the openssl one-liner in the doc comment above
        // and pinned here. Any future libraries swap that breaks HMAC
        // semantics will fail this test loudly.
        // Note: the canonical RFC-4231 Test Case 1 uses a 20-byte key
        // (only the leading 0x0b bytes). This fixture zero-pads that
        // key to 32 bytes — but the resulting MAC is IDENTICAL to the
        // RFC vector. HMAC's key-derivation step pads any key shorter
        // than the block size (64 bytes for SHA-256) with zeros before
        // XOR'ing with the inner/outer pad constants, so a 20-byte key
        // padded to 32 bytes and then internally padded to 64 produces
        // the same `K XOR ipad`/`K XOR opad` as the 20-byte key padded
        // directly to 64. The pinned bytes below match RFC-4231 Test
        // Case 1's expected output and were cross-checked via the
        // openssl command in the doc comment.
        let expected: [u8; 32] = [
            0xb0, 0x34, 0x4c, 0x61, 0xd8, 0xdb, 0x38, 0x53, 0x5c, 0xa8, 0xaf, 0xce, 0xaf, 0x0b,
            0xf1, 0x2b, 0x88, 0x1d, 0xc2, 0x00, 0xc9, 0x83, 0x3d, 0xa7, 0x26, 0xe9, 0x37, 0x6c,
            0x2e, 0x32, 0xcf, 0xf7,
        ];
        let actual = sign_snapshot(data, &key);
        assert_eq!(
            actual, expected,
            "HMAC-SHA256 output drifted from pinned vector"
        );
    }

    #[test]
    fn verify_round_trip_succeeds() {
        let key = [1u8; 32];
        let payload = b"{\"models\":[]}";
        let sig = sign_snapshot(payload, &key);
        assert!(
            verify_snapshot(payload, &sig, &key),
            "freshly-signed payload must verify under the same key",
        );
    }

    #[test]
    fn verify_rejects_tampered_payload() {
        let key = [1u8; 32];
        let payload: &[u8] = b"{\"models\":[]}";
        let sig = sign_snapshot(payload, &key);

        let mut tampered = payload.to_vec();
        tampered[0] ^= 0x01; // flip one bit
        assert!(
            !verify_snapshot(&tampered, &sig, &key),
            "tampered payload must not verify against the original signature",
        );
    }

    #[test]
    fn verify_rejects_tampered_signature() {
        let key = [1u8; 32];
        let payload = b"{\"models\":[]}";
        let mut sig = sign_snapshot(payload, &key);
        sig[0] ^= 0x01; // flip one bit
        assert!(
            !verify_snapshot(payload, &sig, &key),
            "tampered signature must not verify",
        );
    }

    #[test]
    fn verify_rejects_wrong_key() {
        let signing_key = [1u8; 32];
        let verifying_key = [2u8; 32];
        let payload = b"{\"models\":[]}";
        let sig = sign_snapshot(payload, &signing_key);
        assert!(
            !verify_snapshot(payload, &sig, &verifying_key),
            "signature must not verify under a different key",
        );
    }

    #[test]
    fn noop_key_signs_to_zero() {
        let payload = b"{\"models\":[]}";
        let sig = sign_snapshot(payload, &[0u8; 32]);
        assert_eq!(
            sig, [0u8; 32],
            "no-op key must short-circuit to zero signature",
        );
    }

    #[test]
    fn noop_key_zero_sig_verifies() {
        let payload = b"{\"models\":[]}";
        assert!(
            verify_snapshot(payload, &[0u8; 32], &[0u8; 32]),
            "(zero-key, zero-sig) must verify cleanly — round-trip of the no-op path",
        );
    }

    #[test]
    fn noop_key_nonzero_sig_does_not_verify() {
        let payload = b"{\"models\":[]}";
        let forged_sig = [1u8; 32];
        assert!(
            !verify_snapshot(payload, &forged_sig, &[0u8; 32]),
            "non-zero forged signature must not bypass the no-op path",
        );
    }

    #[test]
    fn load_key_from_env_unset() {
        let _g = SIGNING_KEY_ENV_MUTEX.lock().unwrap();
        // SAFETY: `remove_var` is `unsafe` on Rust 2024+ because mutating
        // the process environment from one thread while another reads it
        // is UB on some platforms. `SIGNING_KEY_ENV_MUTEX` serialises every
        // test in this crate that touches `DJOGI_SNAPSHOT_SIGNING_KEY`, so
        // within this test binary no concurrent reader exists.
        unsafe {
            env::remove_var("DJOGI_SNAPSHOT_SIGNING_KEY");
        }
        assert_eq!(load_signing_key_from_env(), Ok(None));
    }

    #[test]
    fn load_key_from_env_valid_hex() {
        let _g = SIGNING_KEY_ENV_MUTEX.lock().unwrap();
        // 64 hex chars = 32 bytes: 0x00, 0x11, 0x22, ..., 0xff (each byte
        // doubled). Mixes lower- and upper-case to exercise both arms of
        // the hex decoder.
        let hex = "00112233445566778899AABBCCDDEEFF00112233445566778899aabbccddeeff";
        // SAFETY: see `load_key_from_env_unset` for the threading argument;
        // `SIGNING_KEY_ENV_MUTEX` serialises all env-var readers and mutators
        // in this crate.
        unsafe {
            env::set_var("DJOGI_SNAPSHOT_SIGNING_KEY", hex);
        }
        let result = load_signing_key_from_env();
        // SAFETY: same as above — clear after the test so adjacent tests
        // see an unset variable.
        unsafe {
            env::remove_var("DJOGI_SNAPSHOT_SIGNING_KEY");
        }
        let expected: [u8; 32] = [
            0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
            0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb,
            0xcc, 0xdd, 0xee, 0xff,
        ];
        assert_eq!(result, Ok(Some(expected)));
    }

    #[test]
    fn load_key_from_env_short() {
        let _g = SIGNING_KEY_ENV_MUTEX.lock().unwrap();
        // 63 chars — one short of the required 64.
        let hex = "00112233445566778899AABBCCDDEEFF00112233445566778899aabbccddeef";
        // SAFETY: `SIGNING_KEY_ENV_MUTEX` serialises env-var access in this crate.
        unsafe {
            env::set_var("DJOGI_SNAPSHOT_SIGNING_KEY", hex);
        }
        let result = load_signing_key_from_env();
        // SAFETY: same.
        unsafe {
            env::remove_var("DJOGI_SNAPSHOT_SIGNING_KEY");
        }
        assert_eq!(result, Err(SnapshotKeyError::InvalidLength { actual: 63 }),);
    }

    #[test]
    fn load_key_from_env_non_hex() {
        let _g = SIGNING_KEY_ENV_MUTEX.lock().unwrap();
        // 64 chars but with a `g` at index 0 — outside hex range.
        let hex = "g0112233445566778899AABBCCDDEEFF00112233445566778899aabbccddeeff";
        // SAFETY: `SIGNING_KEY_ENV_MUTEX` serialises env-var access in this crate.
        unsafe {
            env::set_var("DJOGI_SNAPSHOT_SIGNING_KEY", hex);
        }
        let result = load_signing_key_from_env();
        // SAFETY: same.
        unsafe {
            env::remove_var("DJOGI_SNAPSHOT_SIGNING_KEY");
        }
        assert_eq!(
            result,
            Err(SnapshotKeyError::InvalidHexByte { idx: 0, byte: b'g' }),
        );
    }

    /// `env::var` distinguishes `NotPresent` (variable is unset) from
    /// `NotUnicode` (variable is set but the value is not valid UTF-8).
    /// The function under test must surface the latter as an error rather
    /// than silently degrading to the no-op key sentinel — otherwise a
    /// malformed env var (typo, accidental binary paste, injection)
    /// downgrades signing to a no-op without warning.
    /// Test is Unix-gated because constructing a non-UTF-8 `OsString`
    /// requires `os::unix::ffi::OsStringExt::from_vec`. On Windows the
    /// equivalent shape (ill-formed UTF-16 in the environment block) is
    /// possible but reaches the same code path; covering it on Unix is
    /// sufficient for the variant-routing assertion.
    #[cfg(unix)]
    #[test]
    fn load_key_from_env_non_unicode_returns_error() {
        use std::ffi::OsString;
        use std::os::unix::ffi::OsStringExt;
        let _g = SIGNING_KEY_ENV_MUTEX.lock().unwrap();
        // SAFETY: SIGNING_KEY_ENV_MUTEX serialises every env-var reader and
        // mutator in this crate; the runtime is single-threaded inside this
        // critical section.
        unsafe {
            env::set_var(
                "DJOGI_SNAPSHOT_SIGNING_KEY",
                OsString::from_vec(vec![0xFF, 0xFE, 0xFD]),
            );
        }
        let result = load_signing_key_from_env();
        // SAFETY: same — clear after the test so adjacent tests see an
        // unset variable.
        unsafe {
            env::remove_var("DJOGI_SNAPSHOT_SIGNING_KEY");
        }
        assert_eq!(result, Err(SnapshotKeyError::NonUnicodeEnvVar));
    }
}