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}