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