phantom_protocol/crypto/cid_chain.rs
1//! Rotating connection-ID chain (introduced in WIRE v5 / ε; current WIRE v6).
2//!
3//! After v4 header protection, the only per-connection cleartext left on the
4//! wire is the outer 8-byte routing `ConnId`. v5 collapsed the two connection
5//! identifiers (the redundant 32-byte inner `session_id` is dropped from the
6//! data-plane wire) into this single CID and **rotates** it on every migration,
7//! closing the stable-CID linkability residual (threat-model §12.5). The v6
8//! anti-fingerprint pass did not touch this construction — it still holds.
9//!
10//! ## Construction
11//!
12//! At session establishment each peer derives two per-direction chain secrets
13//! from the initial session secret (mirroring the AEAD / header-protection key
14//! layout):
15//!
16//! ```text
17//! cid_secret_c2s = derive_key_32("phantom-cid-c2s-v1", initial_secret) // client→server
18//! cid_secret_s2c = derive_key_32("phantom-cid-s2c-v1", initial_secret) // server→client
19//! ```
20//!
21//! The CID for migration index `i` truncates a per-index KDF output to 8 bytes:
22//!
23//! ```text
24//! CID_i = derive_key_32("phantom-cid-v1", cid_secret ‖ i.to_be_bytes())[0..8]
25//! ```
26//!
27//! The client **stamps** its outbound `ConnId` from the c2s chain (the chain the
28//! server routes on); the server stamps from the s2c chain. So `is_server` swaps
29//! the outbound/inbound secrets, exactly like `HeaderProtector::derive` and the
30//! AEAD send/recv keys — one peer's outbound chain is the other's inbound chain.
31//!
32//! ## Properties
33//!
34//! - **Session-stable, zeroized:** the chain secrets are derived once and held
35//! for the session (they do not rotate with the AEAD epoch); they are zeroized
36//! on drop. The *index* advances on `migrate()` (held by `Session`, not here).
37//! - **Unguessable / unlinkable:** `CID_i` is a KDF output truncation, so without
38//! `cid_secret` the CIDs are independent-random to an observer — pre- and
39//! post-migration flows cannot be linked.
40//! - **Not forward-secret (honest caveat):** like the HP keys, a session-key
41//! compromise lets an attacker recompute the whole CID chain and link a
42//! *recorded* flow retroactively. The payload stays forward-secret (AEAD
43//! ratchets). See `docs/plans/wire-v5-cid-collapse-design.md` §5.
44
45use crate::crypto::kdf::derive_key_32;
46use zeroize::Zeroize;
47
48/// Wire width of a routing CID (the outer UDP `ConnId`): 8 bytes.
49pub const CID_LEN: usize = 8;
50
51/// KDF label for an individual CID: `CID_i = derive_key_32(label, secret ‖ i_be)[0..8]`.
52const CID_LABEL: &str = "phantom-cid-v1";
53/// Per-direction chain-secret labels. Domain-separated from the AEAD / HP keys.
54const CID_C2S_LABEL: &str = "phantom-cid-c2s-v1";
55const CID_S2C_LABEL: &str = "phantom-cid-s2c-v1";
56
57/// Default demux window — trailing `T` (in-flight reordering across a migration
58/// boundary) and leading `K` (migration lookahead: the sender may have migrated
59/// ahead of delivery). `(T + K + 1) = 19` accepted CIDs per inbound direction.
60///
61/// `K = 16` (audit 2026-06-15, EPS-01): it is the **hard cap on consecutive
62/// migrations whose packets are ALL lost** before the sender's CID falls outside
63/// the window and the session strands (recoverable by reconnect via the liveness
64/// sweep). A delivered migration packet recenters the window on its index (the
65/// multi-step slide in `Session::note_migration_path`), so only an unbroken run of
66/// `> K` fully-lost migrations strands — far beyond any realistic rapid-migration
67/// regime. The window costs `T + K + 1` route-table entries per session per inbound
68/// direction (bounded by `MAX_ROUTES`, sized to preserve the session capacity).
69pub const CID_WINDOW_TRAILING: u64 = 2;
70/// See [`CID_WINDOW_TRAILING`].
71pub const CID_WINDOW_LEADING: u64 = 16;
72
73/// Per-direction, **session-stable** rotating-CID chain secrets. `outbound_secret`
74/// is the chain this peer stamps its outbound `ConnId` from (the peer routes on
75/// it); `inbound_secret` is the chain this peer routes inbound `ConnId`s on.
76/// Derived once from the initial session secret; zeroized on drop.
77pub struct CidChain {
78 outbound_secret: [u8; 32],
79 inbound_secret: [u8; 32],
80}
81
82impl CidChain {
83 /// Derive the per-direction CID-chain secrets from the initial session
84 /// secret. `is_server` swaps outbound/inbound so one peer's outbound chain
85 /// equals the other peer's inbound chain (mirrors `HeaderProtector::derive`).
86 pub fn derive(initial_secret: &[u8; 32], is_server: bool) -> Self {
87 let c2s = derive_key_32(CID_C2S_LABEL, initial_secret);
88 let s2c = derive_key_32(CID_S2C_LABEL, initial_secret);
89 // Client stamps c2s outbound (server routes c2s); server stamps s2c.
90 let (outbound_secret, inbound_secret) = if is_server { (s2c, c2s) } else { (c2s, s2c) };
91 Self {
92 outbound_secret,
93 inbound_secret,
94 }
95 }
96
97 /// The CID this peer stamps on its outbound envelope at migration index `i`.
98 #[inline]
99 pub fn outbound_cid(&self, i: u64) -> [u8; CID_LEN] {
100 cid_from_secret(&self.outbound_secret, i)
101 }
102
103 /// The CID this peer routes an inbound envelope on at migration index `i`.
104 #[inline]
105 pub fn inbound_cid(&self, i: u64) -> [u8; CID_LEN] {
106 cid_from_secret(&self.inbound_secret, i)
107 }
108
109 /// The inbound demux window `[highest_seen − trailing, highest_seen +
110 /// leading]` (saturating at index 0), each index paired with the CID it
111 /// routes on. Pure — the stateful slide lives in the demux (P4).
112 pub fn inbound_window(
113 &self,
114 highest_seen: u64,
115 trailing: u64,
116 leading: u64,
117 ) -> impl Iterator<Item = (u64, [u8; CID_LEN])> + '_ {
118 let lo = highest_seen.saturating_sub(trailing);
119 let hi = highest_seen.saturating_add(leading);
120 (lo..=hi).map(move |i| (i, self.inbound_cid(i)))
121 }
122}
123
124impl Drop for CidChain {
125 fn drop(&mut self) {
126 self.outbound_secret.zeroize();
127 self.inbound_secret.zeroize();
128 }
129}
130
131/// `CID_i = derive_key_32("phantom-cid-v1", secret ‖ i.to_be_bytes())[0..8]`.
132#[inline]
133fn cid_from_secret(secret: &[u8; 32], i: u64) -> [u8; CID_LEN] {
134 let mut ikm = [0u8; 40];
135 ikm[..32].copy_from_slice(secret);
136 ikm[32..].copy_from_slice(&i.to_be_bytes());
137 let full = derive_key_32(CID_LABEL, &ikm);
138 let mut cid = [0u8; CID_LEN];
139 cid.copy_from_slice(&full[..CID_LEN]);
140 cid
141}
142
143#[cfg(test)]
144#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
145mod tests {
146 use std::collections::HashSet;
147
148 use super::*;
149 use crate::crypto::kdf::derive_key_32;
150
151 /// Independent reference for the CID construction — the "known construction"
152 /// KAT. Locks the spec: label `phantom-cid-v1`, IKM = `secret ‖ index_be`,
153 /// truncated to the first 8 bytes. Backend-agnostic (same `derive_key_32`
154 /// dispatch), so it pins the construction on both the blake3 default and the
155 /// HKDF-SHA256 fips build.
156 fn reference_cid(secret: &[u8; 32], i: u64) -> [u8; 8] {
157 let mut ikm = [0u8; 40];
158 ikm[..32].copy_from_slice(secret);
159 ikm[32..].copy_from_slice(&i.to_be_bytes());
160 let full = derive_key_32("phantom-cid-v1", &ikm);
161 let mut cid = [0u8; 8];
162 cid.copy_from_slice(&full[..8]);
163 cid
164 }
165
166 /// The per-direction secret a peer derives from the initial session secret.
167 fn direction_secret(initial: &[u8; 32], c2s: bool) -> [u8; 32] {
168 derive_key_32(
169 if c2s {
170 "phantom-cid-c2s-v1"
171 } else {
172 "phantom-cid-s2c-v1"
173 },
174 initial,
175 )
176 }
177
178 /// KAT: each side's outbound chain matches the reference construction over
179 /// the secret it stamps from. Client stamps c2s; server stamps s2c.
180 #[test]
181 fn outbound_cid_matches_known_construction() {
182 let initial = [0x42u8; 32];
183 let client = CidChain::derive(&initial, false);
184 let server = CidChain::derive(&initial, true);
185 let c2s = direction_secret(&initial, true);
186 let s2c = direction_secret(&initial, false);
187 for i in [0u64, 1, 7, 1000] {
188 assert_eq!(client.outbound_cid(i), reference_cid(&c2s, i));
189 assert_eq!(server.outbound_cid(i), reference_cid(&s2c, i));
190 }
191 }
192
193 /// The routing contract: one peer's outbound chain is the other peer's
194 /// inbound chain. Client stamps c2s (the chain the server routes on); server
195 /// stamps s2c (the chain the client routes on). Mirrors the AEAD/HP swap.
196 #[test]
197 fn per_direction_swap_links_peers() {
198 let initial = [0x9cu8; 32];
199 let client = CidChain::derive(&initial, false);
200 let server = CidChain::derive(&initial, true);
201 for i in [0u64, 1, 5, 42, 9999] {
202 assert_eq!(
203 client.outbound_cid(i),
204 server.inbound_cid(i),
205 "client outbound (c2s) must equal server inbound (c2s)"
206 );
207 assert_eq!(
208 server.outbound_cid(i),
209 client.inbound_cid(i),
210 "server outbound (s2c) must equal client inbound (s2c)"
211 );
212 }
213 }
214
215 /// Within one peer the two directions use independent secrets, so the
216 /// outbound and inbound CIDs for the same index differ.
217 #[test]
218 fn directions_differ_within_peer() {
219 let chain = CidChain::derive(&[0x77u8; 32], false);
220 for i in [0u64, 1, 100] {
221 assert_ne!(chain.outbound_cid(i), chain.inbound_cid(i));
222 }
223 }
224
225 /// Distinctness across the index: rotation yields independent-looking CIDs
226 /// (no collisions across a long run — the unlinkability property).
227 #[test]
228 fn cids_are_distinct_across_index() {
229 let chain = CidChain::derive(&[0x11u8; 32], false);
230 let mut seen = HashSet::new();
231 for i in 0..256u64 {
232 assert!(seen.insert(chain.outbound_cid(i)), "collision at index {i}");
233 }
234 assert_eq!(seen.len(), 256);
235 }
236
237 /// Deterministic: two independent derivations from the same inputs produce
238 /// byte-identical CIDs (client and server must agree).
239 #[test]
240 fn derivation_is_deterministic() {
241 let initial = [0x5au8; 32];
242 let a = CidChain::derive(&initial, false);
243 let b = CidChain::derive(&initial, false);
244 for i in [0u64, 3, 64] {
245 assert_eq!(a.outbound_cid(i), b.outbound_cid(i));
246 assert_eq!(a.inbound_cid(i), b.inbound_cid(i));
247 }
248 }
249
250 /// The inbound demux window covers `[highest_seen − T, highest_seen + K]`,
251 /// each entry paired with the CID that index routes on.
252 #[test]
253 fn inbound_window_covers_trailing_and_leading() {
254 let chain = CidChain::derive(&[0x55u8; 32], true);
255 let win: Vec<(u64, [u8; 8])> = chain.inbound_window(10, 2, 4).collect();
256 let idxs: Vec<u64> = win.iter().map(|(i, _)| *i).collect();
257 assert_eq!(idxs, vec![8, 9, 10, 11, 12, 13, 14]);
258 for (i, cid) in win {
259 assert_eq!(cid, chain.inbound_cid(i));
260 }
261 }
262
263 /// The trailing edge saturates at index 0 (never underflows).
264 #[test]
265 fn inbound_window_saturates_at_zero() {
266 let chain = CidChain::derive(&[0x55u8; 32], true);
267 let idxs: Vec<u64> = chain.inbound_window(1, 2, 4).map(|(i, _)| i).collect();
268 assert_eq!(idxs, vec![0, 1, 2, 3, 4, 5]);
269 }
270
271 /// The canonical window size is `T + K + 1 = 19` entries (K widened 4→16 for
272 /// EPS-01 robustness; see [`CID_WINDOW_LEADING`]).
273 #[test]
274 fn canonical_window_size() {
275 let chain = CidChain::derive(&[0x55u8; 32], false);
276 let n = chain
277 .inbound_window(100, CID_WINDOW_TRAILING, CID_WINDOW_LEADING)
278 .count();
279 assert_eq!(n, (CID_WINDOW_TRAILING + CID_WINDOW_LEADING + 1) as usize);
280 assert_eq!(n, 19);
281 }
282
283 /// Frozen-byte KAT (default/blake3 build): pins the exact CID bytes so an
284 /// accidental construction change or an upstream blake3 behavior shift is
285 /// caught — mirroring the NIST/RFC vectors in `header_protection.rs`. The
286 /// fips/HKDF path is covered by the backend-agnostic construction KAT
287 /// (`outbound_cid_matches_known_construction`) plus `kdf::derive_key_32_fips_kat`.
288 #[cfg(not(feature = "fips"))]
289 #[test]
290 fn frozen_cid_bytes_default_build() {
291 let client = CidChain::derive(&[0x42u8; 32], false);
292 assert_eq!(
293 client.outbound_cid(0),
294 [102, 235, 212, 72, 93, 23, 250, 126]
295 );
296 assert_eq!(client.outbound_cid(1), [24, 107, 241, 72, 75, 55, 3, 167]);
297 }
298}