Skip to main content

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}