Skip to main content

river_core/
key_derivation.rs

1//! Deterministic derivation of room secrets from the owner's signing key.
2
3use crate::room_state::privacy::SecretVersion;
4use ed25519_dalek::VerifyingKey;
5
6/// Hard-coded context string for the protocol-root key derivation step.
7/// Per blake3 KDF guidance this MUST be a compile-time constant. ANY change
8/// to the input set fed into the per-call keyed-hash phase below requires
9/// bumping this string (e.g. to `"river-rotate v2 ..."`) and the
10/// corresponding known-answer test vectors.
11const ROOT_CONTEXT: &str = "river-rotate v1 2026-04 room-secret-root";
12
13/// Derive a 32-byte room secret deterministically from the owner's signing
14/// key seed, the room owner's verifying key, and the secret version.
15///
16/// # Construction
17///
18/// Two-phase blake3 KDF:
19///
20/// 1. `root = blake3::derive_key(ROOT_CONTEXT, signing_key_seed)` — the
21///    canonical blake3 KDF mode, with a hard-coded context string that
22///    bakes in the protocol version and a date stamp. This separates the
23///    protocol-version commitment from the per-call inputs.
24/// 2. `secret = keyed_hash(root, owner_vk || version_le)` — keyed-hash with
25///    the per-call inputs. blake3 keyed-hash is a secure PRF.
26///
27/// Future input additions are limited to phase 2 and require bumping the
28/// `ROOT_CONTEXT` string (which forces a new known-answer test vector and
29/// makes the protocol break visible at code-review time). Future protocol
30/// version bumps just change the context string.
31///
32/// # Invariants
33///
34/// `signing_key_seed` MUST be the 32-byte ed25519 seed (the bytes returned
35/// by `SigningKey::to_bytes()`), not the expanded 64-byte secret, not random
36/// bytes, not the verifying key, not a stretched value. Passing any other
37/// 32-byte input produces a "valid" but undefined output and breaks the
38/// multi-replica determinism that is the entire point of this function.
39///
40/// `version: SecretVersion` is `u32`. Widening the type is a breaking
41/// protocol change requiring a new `ROOT_CONTEXT` (because the
42/// `to_le_bytes()` length changes).
43///
44/// # Determinism
45///
46/// Identical inputs always produce identical 32-byte outputs across all
47/// platforms. blake3 is endian-agnostic; `version.to_le_bytes()` is
48/// explicit; `VerifyingKey::as_bytes()` returns the canonical 32-byte
49/// compressed ed25519 point. Multiple replicas of the same delegate (e.g.
50/// a user running River on laptop + phone) compute byte-identical secrets
51/// without coordination.
52///
53/// # Security trade-off
54///
55/// Anyone with `signing_key_seed` can derive every past and every future
56/// secret for this room. This is acceptable for River's threat model: the
57/// signing key already authorises every room operation, so seed compromise
58/// is already terminal. The trade-off buys multi-device determinism without
59/// distributed coordination. Apps that need historical forward secrecy
60/// against signing-key compromise must not use this construction.
61///
62/// A removed member who held `secret_v_n` does not have the seed and so
63/// cannot derive `secret_v_{n+1}` — forward secrecy against a removed
64/// member holds, which is the property that matters for room rotation.
65pub fn derive_room_secret(
66    signing_key_seed: &[u8; 32],
67    owner_vk: &VerifyingKey,
68    version: SecretVersion,
69) -> [u8; 32] {
70    let root = blake3::derive_key(ROOT_CONTEXT, signing_key_seed);
71    let mut hasher = blake3::Hasher::new_keyed(&root);
72    hasher.update(owner_vk.as_bytes());
73    hasher.update(&version.to_le_bytes());
74    *hasher.finalize().as_bytes()
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use ed25519_dalek::SigningKey;
81
82    fn vk_from_seed(seed: [u8; 32]) -> VerifyingKey {
83        SigningKey::from_bytes(&seed).verifying_key()
84    }
85
86    #[test]
87    fn derive_is_deterministic() {
88        let seed = [7u8; 32];
89        let vk = vk_from_seed([1u8; 32]);
90        let a = derive_room_secret(&seed, &vk, 0);
91        let b = derive_room_secret(&seed, &vk, 0);
92        assert_eq!(a, b, "identical inputs must produce identical outputs");
93    }
94
95    #[test]
96    fn derive_separates_versions() {
97        let seed = [7u8; 32];
98        let vk = vk_from_seed([1u8; 32]);
99        let v0 = derive_room_secret(&seed, &vk, 0);
100        let v1 = derive_room_secret(&seed, &vk, 1);
101        assert_ne!(v0, v1, "different versions must produce different outputs");
102    }
103
104    #[test]
105    fn derive_separates_owners() {
106        let seed = [7u8; 32];
107        let vk_a = vk_from_seed([1u8; 32]);
108        let vk_b = vk_from_seed([2u8; 32]);
109        let a = derive_room_secret(&seed, &vk_a, 0);
110        let b = derive_room_secret(&seed, &vk_b, 0);
111        assert_ne!(a, b, "different owners must produce different outputs");
112    }
113
114    #[test]
115    fn derive_separates_keys() {
116        let seed_a = [7u8; 32];
117        let seed_b = [8u8; 32];
118        let vk = vk_from_seed([1u8; 32]);
119        let a = derive_room_secret(&seed_a, &vk, 0);
120        let b = derive_room_secret(&seed_b, &vk, 0);
121        assert_ne!(
122            a, b,
123            "different signing key seeds must produce different outputs"
124        );
125    }
126
127    #[test]
128    fn derive_locks_input_ordering() {
129        // If the implementation accidentally swapped the order of
130        // `update(owner_vk)` and `update(version_le)`, then
131        // `derive(seed, vk_a, 1)` would equal `derive(seed, vk_b, 0)`
132        // for some adversarially-chosen vk_b. This test ensures the
133        // ordering is locked: distinct (owner, version) pairs at the
134        // same axis-product position must not collide.
135        let seed = [7u8; 32];
136        let vk_a = vk_from_seed([1u8; 32]);
137        let vk_b = vk_from_seed([2u8; 32]);
138        assert_ne!(
139            derive_room_secret(&seed, &vk_a, 1),
140            derive_room_secret(&seed, &vk_b, 0),
141        );
142        assert_ne!(
143            derive_room_secret(&seed, &vk_a, 0),
144            derive_room_secret(&seed, &vk_b, 1),
145        );
146    }
147
148    /// Known-answer test that locks the construction in place. If this test
149    /// fails, the derivation algorithm has changed and any deployed clients
150    /// will compute incompatible secrets. Update the expected bytes ONLY when
151    /// intentionally changing the construction (and treat that as a breaking
152    /// protocol change requiring a new ROOT_CONTEXT string).
153    ///
154    /// To independently verify these vectors, run blake3 from outside Rust:
155    ///
156    ///   # Phase 1: derive the root key.
157    ///   #   blake3::derive_key(ROOT_CONTEXT, signing_key_seed)
158    ///   # Phase 2: keyed_hash(root, owner_vk_bytes || version_le_4).
159    ///
160    /// e.g. with python's `blake3` package:
161    ///   import blake3
162    ///   root = blake3.blake3(b'\x00'*32,
163    ///       derive_key_context='river-rotate v1 2026-04 room-secret-root'
164    ///   ).digest()
165    ///   # owner_vk for SigningKey::from_bytes([1u8;32]) — paste 32 bytes
166    ///   secret = blake3.blake3(owner_vk_bytes + (0).to_bytes(4,'little'),
167    ///       key=root).digest()
168    #[test]
169    fn derive_known_answer_v1_zero_seed_zero_version() {
170        let seed = [0u8; 32];
171        let vk = SigningKey::from_bytes(&[1u8; 32]).verifying_key();
172        let actual = derive_room_secret(&seed, &vk, 0);
173        let expected: [u8; 32] = [
174            0xdd, 0x18, 0x9c, 0xce, 0x07, 0x93, 0x74, 0x85, 0x6e, 0xb7, 0xa2, 0x01, 0x61, 0x8e,
175            0x58, 0x86, 0xa1, 0xe9, 0xe5, 0x59, 0x8b, 0x33, 0x34, 0x08, 0x43, 0x00, 0x2c, 0xbb,
176            0x90, 0x91, 0xe1, 0xa9,
177        ];
178        assert_eq!(
179            actual, expected,
180            "construction changed; KAT mismatch. Actual = {:02x?}",
181            actual
182        );
183    }
184
185    /// Multi-byte-significant version vector. Catches a future regression
186    /// that swapped `to_le_bytes` for `to_be_bytes`, which would produce
187    /// identical output for `version=0` or `version=1` but a different
188    /// output here.
189    #[test]
190    fn derive_known_answer_v1_multi_byte_version() {
191        let seed = [0u8; 32];
192        let vk = SigningKey::from_bytes(&[1u8; 32]).verifying_key();
193        let actual = derive_room_secret(&seed, &vk, 0x01020304);
194        let expected: [u8; 32] = [
195            0xaa, 0x8f, 0x7d, 0x5a, 0xb5, 0x15, 0x84, 0x66, 0x78, 0x72, 0x28, 0xd6, 0x88, 0x54,
196            0xf6, 0x5d, 0x39, 0xac, 0xe3, 0x13, 0x07, 0x8f, 0x29, 0xa9, 0xfb, 0xad, 0x88, 0x79,
197            0x70, 0xd3, 0xfe, 0x67,
198        ];
199        assert_eq!(
200            actual, expected,
201            "construction changed; KAT mismatch. Actual = {:02x?}",
202            actual
203        );
204    }
205
206    /// All-`0xFF` seed vector. Catches a buggy "if seed is zero, fall back
207    /// to unkeyed mode" regression and exercises non-zero key bytes through
208    /// blake3's keyed-hash internals.
209    #[test]
210    fn derive_known_answer_v1_all_ff_seed() {
211        let seed = [0xFFu8; 32];
212        let vk = SigningKey::from_bytes(&[1u8; 32]).verifying_key();
213        let actual = derive_room_secret(&seed, &vk, 0);
214        let expected: [u8; 32] = [
215            0x60, 0xb5, 0x60, 0x0b, 0x12, 0xfc, 0xaa, 0x0c, 0x52, 0xda, 0x76, 0x59, 0x95, 0xf6,
216            0x9c, 0xb3, 0xeb, 0x54, 0x37, 0xd5, 0x67, 0x53, 0xc0, 0x24, 0x97, 0x67, 0x19, 0xf1,
217            0xe4, 0x31, 0x7e, 0x87,
218        ];
219        assert_eq!(
220            actual, expected,
221            "construction changed; KAT mismatch. Actual = {:02x?}",
222            actual
223        );
224    }
225}