Skip to main content

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}