Skip to main content

studio_worker/
secrets.rs

1//! Shared entropy primitives for every locally-minted credential.
2//!
3//! The auto-register flow (install id + registration secret) and the
4//! local API (bearer token) all need unpredictable values: an attacker
5//! who can guess any of them can impersonate the worker or drive its
6//! GPU.  Centralising the OS-CSPRNG access here means there is exactly
7//! one audited path instead of per-module copies drifting apart.
8
9use sha2::{Digest, Sha256};
10
11/// Fill `N` bytes from the OS cryptographically-secure RNG via the
12/// `getrandom` crate (`getrandom(2)` / `/dev/urandom` on Linux,
13/// `getentropy` on macOS, `BCryptGenRandom` on Windows).
14///
15/// Panics if the OS entropy source is unavailable.  That only happens
16/// on a fundamentally broken platform, and failing loudly (the panic
17/// is captured by Sentry) is the right call — minting a guessable
18/// secret from a timestamp would be worse than a clean crash.
19pub fn rand_bytes<const N: usize>() -> [u8; N] {
20    let mut buf = [0u8; N];
21    getrandom::fill(&mut buf).expect("OS entropy source (getrandom) unavailable");
22    buf
23}
24
25/// 32 bytes of randomness = 64 hex chars (256 bits of entropy).
26pub fn new_secret_hex() -> String {
27    let bytes: [u8; 32] = rand_bytes::<32>();
28    bytes.iter().map(|b| format!("{b:02x}")).collect()
29}
30
31/// UUIDv4-ish without pulling in the `uuid` crate: 16 random bytes
32/// formatted as 8-4-4-4-12.
33pub fn new_uuid() -> String {
34    let bytes: [u8; 16] = rand_bytes::<16>();
35    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
36    format!(
37        "{}-{}-{}-{}-{}",
38        &hex[0..8],
39        &hex[8..12],
40        &hex[12..16],
41        &hex[16..20],
42        &hex[20..32]
43    )
44}
45
46/// Hex-encoded SHA-256 of `input`.  Used to send only the *hash* of a
47/// locally-held secret over the wire (auto-register).
48pub fn sha256_hex(input: &str) -> String {
49    let mut hasher = Sha256::new();
50    hasher.update(input.as_bytes());
51    let digest = hasher.finalize();
52    digest.iter().map(|b| format!("{b:02x}")).collect()
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn new_uuid_has_expected_shape() {
61        let id = new_uuid();
62        let parts: Vec<&str> = id.split('-').collect();
63        assert_eq!(parts.len(), 5);
64        assert_eq!(parts[0].len(), 8);
65        assert_eq!(parts[1].len(), 4);
66        assert_eq!(parts[2].len(), 4);
67        assert_eq!(parts[3].len(), 4);
68        assert_eq!(parts[4].len(), 12);
69        assert!(id.chars().all(|c| c.is_ascii_hexdigit() || c == '-'));
70    }
71
72    #[test]
73    fn new_uuid_is_unique() {
74        assert_ne!(new_uuid(), new_uuid());
75    }
76
77    #[test]
78    fn new_secret_hex_is_64_chars() {
79        let s = new_secret_hex();
80        assert_eq!(s.len(), 64);
81        assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
82    }
83
84    #[test]
85    fn sha256_hex_is_deterministic() {
86        assert_eq!(sha256_hex("abc"), sha256_hex("abc"));
87        assert_ne!(sha256_hex("abc"), sha256_hex("abd"));
88        assert_eq!(sha256_hex("").len(), 64);
89    }
90
91    // ---------------------------------------------------------------
92    // Entropy primitive.  `rand_bytes` is the single source for the
93    // install id, registration secret, and local API token on every
94    // platform, so these also cover the formerly-untested Windows path
95    // (which used to route through a predictable timestamp fallback).
96    // ---------------------------------------------------------------
97
98    #[test]
99    fn rand_bytes_are_distinct_across_many_calls() {
100        use std::collections::HashSet;
101        let mut seen = HashSet::new();
102        for _ in 0..2_000 {
103            assert!(
104                seen.insert(rand_bytes::<32>()),
105                "rand_bytes produced a duplicate 32-byte value"
106            );
107        }
108    }
109
110    #[test]
111    fn rand_bytes_cover_every_bit_position() {
112        // OR + AND across many samples: a stuck or constant source
113        // would leave a bit position never set (an OR-zero) or never
114        // cleared (an AND-one).  An OS CSPRNG flips every one of the
115        // 256 bits within a handful of samples.
116        let mut ever_set = [0u8; 32];
117        let mut ever_clear = [0xffu8; 32];
118        for _ in 0..256 {
119            let b = rand_bytes::<32>();
120            for i in 0..32 {
121                ever_set[i] |= b[i];
122                ever_clear[i] &= b[i];
123            }
124        }
125        assert_eq!(ever_set, [0xffu8; 32], "a bit position was never set");
126        assert_eq!(ever_clear, [0u8; 32], "a bit position was never cleared");
127    }
128}