ts_tka/lib.rs
1#![doc = include_str!("../README.md")]
2
3//! # Overview
4//!
5//! This crate implements the **client-side verification** path of Tailscale's Tailnet Lock (TKA),
6//! mirroring Go's `tka` package. The pieces:
7//!
8//! - [`cbor`]: a CTAP2-canonical CBOR encoder, so a value's signing digest is byte-identical to
9//! Go's `fxamacker/cbor` (CTAP2 mode) output.
10//! - [`AumHash`]: a BLAKE2s-256 hash with RFC4648 base32 (no padding) text encoding — the type
11//! `TkaInfo.head` carries on the wire.
12//! - [`AumHash`] / [`AumKind`] / [`Key`] / [`NodeKeySignature`]: the wire types, with canonical
13//! serialization + signing-digest helpers.
14//! - [`Authority`]: holds the current trusted-key [`State`] and exposes
15//! [`Authority::node_key_authorized`], the check that a peer's node key is signed by a key trusted
16//! under the current tailnet-lock state.
17//!
18//! ## Fail-closed + validation status
19//!
20//! Verification is fail-closed: any decode/shape/signature problem denies authorization. The
21//! CTAP2-CBOR byte-exactness is **cross-validated against Go-produced output**: the
22//! [`NodeKeySignature`] path against real `tka.NodeKeySignature.Serialize`/`SigHash` golden vectors
23//! (`tka_cbor_matches_go_golden`), and the [`Aum`] path against the literal `[]byte` vectors in Go's
24//! `tka/aum_test.go` `TestSerialization` (`aum_serialize_matches_go_test_serialization_vectors`).
25//! What remains for full Tailnet-Lock support is the *acquisition* side — the `/machine/tka/sync`
26//! RPC client and the [`Aum`]-chain replayer that folds a chain into a trusted-key [`State`] (the
27//! [`Authority`] is currently only constructible via [`Authority::from_state`]). See issue #7.
28
29extern crate alloc;
30
31use alloc::{string::String, vec::Vec};
32
33use blake2::{Blake2s256, Digest};
34
35pub mod cbor;
36
37use cbor::Value;
38
39/// Length in bytes of an [`AumHash`] (BLAKE2s-256 output).
40pub const AUM_HASH_LEN: usize = 32;
41
42/// Maximum nesting depth allowed when decoding/verifying a [`NodeKeySignature`] and its CBOR.
43///
44/// A peer-supplied signature CBOR is attacker-controlled and cheap to nest arbitrarily deep (a few
45/// bytes per level). Without a bound, the recursive decoder/verifier overflows the stack and aborts
46/// the process (DoS). Go bounds this. Real TKA rotation chains are short (a handful of links), so a
47/// cap of 16 sits comfortably above any legitimate chain while staying far below stack-overflow.
48/// Enforced at DECODE time (before any crypto), and also bounds generic CBOR container nesting.
49const MAX_SIG_NESTING_DEPTH: usize = 16;
50
51// ---------------------------------------------------------------------------------------------
52// Static-validation limits — mirror Go `tka/limits.go` (v1.100.0) byte-for-byte. These bound the
53// accept/reject boundary so a Rust node and a Go node agree on which AUMs/keys/checkpoints are
54// well-formed; a mismatch here is a tailnet-lock CONSENSUS SPLIT (the two derive different trusted
55// states), not merely a robustness nicety. Do not change without changing Go.
56// ---------------------------------------------------------------------------------------------
57
58/// Max trusted keys in a checkpoint state (Go `maxKeys`).
59const MAX_KEYS: usize = 512;
60/// Max disablement values in a checkpoint state (Go `maxDisablementValues`).
61const MAX_DISABLEMENT_VALUES: usize = 32;
62/// Required byte length of each disablement value — a BLAKE2s-256 digest (Go `disablementLength`).
63const DISABLEMENT_LENGTH: usize = 32;
64/// Max total bytes of a key's metadata map, summed over keys+values (Go `maxMetaBytes`).
65const MAX_META_BYTES: usize = 512;
66/// Max key voting weight (Go `Key.StaticValidate`: `Votes > 4096` is "excessive key weight").
67const MAX_KEY_VOTES: u32 = 4096;
68
69/// A BLAKE2s-256 hash of an AUM's canonical serialization. Identifies an AUM and links the chain
70/// (`PrevAUMHash`). Text form is RFC4648 standard base32, no padding (Go `AUMHash.MarshalText`).
71///
72/// `Ord`/`PartialOrd` order by the raw 32 bytes — used to key the sync store (a `BTreeMap`, since the
73/// crate is `no_std`) and already relied on by [`pick_next_aum`]'s lowest-hash fork tiebreak.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
75pub struct AumHash(pub [u8; AUM_HASH_LEN]);
76
77impl AumHash {
78 /// Decode an `AumHash` from its base32 (no-pad, RFC4648 standard alphabet) text form, as found
79 /// in `TkaInfo.head`. Returns `None` if the text is not exactly 32 decoded bytes.
80 pub fn from_base32(text: &str) -> Option<AumHash> {
81 let decoded = base32_decode_nopad(text)?;
82 if decoded.len() != AUM_HASH_LEN {
83 return None;
84 }
85 let mut h = [0u8; AUM_HASH_LEN];
86 h.copy_from_slice(&decoded);
87 Some(AumHash(h))
88 }
89
90 /// Encode this hash as base32 (no-pad, standard alphabet) — the wire/text form.
91 pub fn to_base32(&self) -> String {
92 base32_encode_nopad(&self.0)
93 }
94}
95
96/// The kind of an AUM (Authority Update Message) (Go `AUMKind`; integer values are wire-stable, do
97/// not reorder).
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99#[repr(u8)]
100pub enum AumKind {
101 /// Invalid / unset (0).
102 Invalid = 0,
103 /// Add a trusted key (1).
104 AddKey = 1,
105 /// Remove a trusted key (2).
106 RemoveKey = 2,
107 /// No-op (3).
108 NoOp = 3,
109 /// Update an existing key's votes/metadata (4).
110 UpdateKey = 4,
111 /// Checkpoint: a full state snapshot (5).
112 Checkpoint = 5,
113}
114
115impl AumKind {
116 /// Decode an [`AumKind`] from its wire integer, or `None` for an unknown kind. Provided for a
117 /// future AUM-chain replayer (the admin-side authority-derivation half), which is out of scope
118 /// for the current client-verify path.
119 pub fn from_u8(n: u8) -> Option<AumKind> {
120 Some(match n {
121 0 => AumKind::Invalid,
122 1 => AumKind::AddKey,
123 2 => AumKind::RemoveKey,
124 3 => AumKind::NoOp,
125 4 => AumKind::UpdateKey,
126 5 => AumKind::Checkpoint,
127 _ => return None,
128 })
129 }
130}
131
132/// The kind of a TKA [`Key`] (Go `KeyKind`).
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum KeyKind {
135 /// Ed25519 trusted key (Go `Key25519` = 1).
136 Ed25519,
137}
138
139/// A trusted TKA key (Go `tka.Key`). Its [`Key::id`] (the 32-byte public key for Ed25519) is what an
140/// [`AumHash`] / [`NodeKeySignature`] references via `KeyID`.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Key {
143 /// Key algorithm.
144 pub kind: KeyKind,
145 /// Voting weight (Go `Votes`, valid range 1..=4096).
146 pub votes: u32,
147 /// The raw public key bytes (32 for Ed25519).
148 pub public: Vec<u8>,
149}
150
151impl Key {
152 /// The key id: for Ed25519 this is the public key verbatim (Go `Key.ID`).
153 pub fn id(&self) -> &[u8] {
154 &self.public
155 }
156}
157
158/// The kind of a [`NodeKeySignature`] (Go `SigKind`).
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160#[repr(u8)]
161pub enum SigKind {
162 /// Invalid (0).
163 Invalid = 0,
164 /// Directly signs a node key with a trusted key (1).
165 Direct = 1,
166 /// Signs a rotated node key, nesting the prior signature (2).
167 Rotation = 2,
168 /// A credential signature; cannot authorize a node on its own (3).
169 Credential = 3,
170}
171
172impl SigKind {
173 fn from_u8(n: u8) -> Option<SigKind> {
174 Some(match n {
175 0 => SigKind::Invalid,
176 1 => SigKind::Direct,
177 2 => SigKind::Rotation,
178 3 => SigKind::Credential,
179 _ => return None,
180 })
181 }
182}
183
184/// A node-key signature (Go `tka.NodeKeySignature`): proof that a node's key is authorized under the
185/// tailnet-lock authority. Decoded from the CBOR blob a peer presents.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct NodeKeySignature {
188 /// Signature kind.
189 pub sig_kind: SigKind,
190 /// The node public key this signature authorizes (Go `Pubkey`).
191 pub pubkey: Vec<u8>,
192 /// The id of the trusted [`Key`] that signed this (Go `KeyID`).
193 pub key_id: Vec<u8>,
194 /// The Ed25519 signature bytes.
195 pub signature: Vec<u8>,
196 /// For [`SigKind::Rotation`], the nested (prior) signature.
197 pub nested: Option<alloc::boxed::Box<NodeKeySignature>>,
198 /// For rotation, the wrapping public key the nested signature authorized.
199 pub wrapping_pubkey: Vec<u8>,
200}
201
202/// Errors from TKA verification.
203#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
204pub enum TkaError {
205 /// The CBOR blob could not be decoded into the expected shape.
206 #[error("TKA decode error: {0}")]
207 Decode(&'static str),
208 /// A signature failed to verify cryptographically.
209 #[error("TKA signature verification failed")]
210 BadSignature,
211 /// The authorizing key is not trusted in the current authority state.
212 #[error("TKA authorizing key is not trusted")]
213 UntrustedKey,
214 /// A credential signature was presented where a node-authorizing signature was required.
215 #[error("a credential signature cannot authorize a node")]
216 CredentialCannotAuthorize,
217 /// The presented signature does not cover the given node key.
218 #[error("signature does not cover this node key")]
219 NodeKeyMismatch,
220 /// An AUM's `PrevAUMHash` does not match the hash of the state it was applied to (Go
221 /// "parent AUMHash mismatch") — the chain link is broken.
222 #[error("AUM parent hash does not match the current chain head")]
223 BadParent,
224 /// An `AUMAddKey` named a key id that is already trusted, or an `AUMRemoveKey`/`AUMUpdateKey`
225 /// named a key id that is not (Go "key already exists" / `ErrNoSuchKey`).
226 #[error("AUM key-state update is invalid (key already exists, or no such key)")]
227 BadKeyState,
228 /// An AUM chain was empty, did not begin at a genesis (`AUMCheckpoint`/`AUMAddKey` with no
229 /// parent), or otherwise could not be replayed into a state.
230 #[error("AUM chain is empty or has no valid genesis")]
231 BadChain,
232 /// An AUM carried no signatures (Go `aumVerify` "unsigned AUM"). Every AUM — including the
233 /// genesis — must be signed by at least one trusted key before it can advance the chain.
234 #[error("AUM is unsigned")]
235 UnsignedAum,
236}
237
238impl NodeKeySignature {
239 /// The canonical CBOR serialization of this signature with the `Signature` field nil'd, used as
240 /// the signing-digest preimage (Go `NodeKeySignature.SigHash` zeroes `Signature` then
241 /// serializes).
242 fn sig_hash(&self) -> [u8; AUM_HASH_LEN] {
243 let v = self.to_cbor(/* include_signature = */ false);
244 blake2s_256(&v.to_vec())
245 }
246
247 /// Build the CBOR value for this signature. When `include_signature` is false, the signature
248 /// field (key 4) is omitted (the SigHash preimage).
249 fn to_cbor(&self, include_signature: bool) -> Value {
250 cbor::int_map([
251 (1, Some(Value::Uint(self.sig_kind as u8 as u64))),
252 (2, nonempty_bytes(&self.pubkey)),
253 (3, nonempty_bytes(&self.key_id)),
254 (
255 4,
256 if include_signature {
257 nonempty_bytes(&self.signature)
258 } else {
259 None
260 },
261 ),
262 (5, self.nested.as_ref().map(|n| n.to_cbor(true))),
263 (6, nonempty_bytes(&self.wrapping_pubkey)),
264 ])
265 }
266
267 /// The key id that ultimately roots this signature in a trusted key (Go `authorizingKeyID`):
268 /// for a rotation, recurse into the nested signature; otherwise this signature's `key_id`.
269 fn authorizing_key_id(&self) -> Result<&[u8], TkaError> {
270 match self.sig_kind {
271 SigKind::Rotation => self
272 .nested
273 .as_ref()
274 .ok_or(TkaError::Decode("rotation signature missing nested"))?
275 .authorizing_key_id(),
276 SigKind::Direct | SigKind::Credential => Ok(&self.key_id),
277 SigKind::Invalid => Err(TkaError::Decode("invalid signature kind")),
278 }
279 }
280
281 /// Verify this signature authorizes `node_key`, rooted in the trusted `verification_key` (Go
282 /// `NodeKeySignature.verifySignature`).
283 fn verify_signature(&self, node_key: &[u8], verification_key: &Key) -> Result<(), TkaError> {
284 // For non-credential signatures the signed pubkey must equal the node key being authorized.
285 if self.sig_kind != SigKind::Credential && self.pubkey != node_key {
286 return Err(TkaError::NodeKeyMismatch);
287 }
288
289 let sig_hash = self.sig_hash();
290
291 match self.sig_kind {
292 SigKind::Rotation => {
293 let nested = self
294 .nested
295 .as_ref()
296 .ok_or(TkaError::Decode("rotation signature missing nested"))?;
297 // The outer rotation signature is verified with STANDARD ed25519 against the nested
298 // signature's wrapping public key.
299 let verify_pub = &nested.wrapping_pubkey;
300 if verify_pub.len() != 32 {
301 return Err(TkaError::Decode("wrapping pubkey wrong length"));
302 }
303 verify_ed25519_std(verify_pub, &sig_hash, &self.signature)?;
304 // The nested signature must cover the rotation pivot (`verify_pub`). For a nested
305 // Direct this is enforced inside its own `verify_signature` (the non-credential
306 // `pubkey != node_key` check). A nested Credential SKIPS that check, so bind it
307 // here: the credential must cover exactly the wrapping pubkey it is rotating, or an
308 // attacker could splice an unrelated valid credential into the chain.
309 if nested.sig_kind == SigKind::Credential && nested.pubkey != *verify_pub {
310 return Err(TkaError::NodeKeyMismatch);
311 }
312 // Then the nested signature must itself be valid, rooting in the trusted key.
313 nested.verify_signature(verify_pub, verification_key)
314 }
315 SigKind::Direct | SigKind::Credential => {
316 if self.nested.is_some() {
317 return Err(TkaError::Decode("direct/credential signature has nested"));
318 }
319 if verification_key.kind != KeyKind::Ed25519 || verification_key.public.len() != 32
320 {
321 return Err(TkaError::Decode("verification key not ed25519"));
322 }
323 // Direct/credential signatures verify with ZIP-215 (cofactored) ed25519, matching
324 // Go's `ed25519consensus.Verify`.
325 verify_ed25519_zip215(&verification_key.public, &sig_hash, &self.signature)
326 }
327 SigKind::Invalid => Err(TkaError::Decode("invalid signature kind")),
328 }
329 }
330}
331
332/// The current authority state (Go `tka.State`): the set of trusted keys at a given chain head.
333/// This is the minimal slice a client needs for [`Authority::node_key_authorized`].
334#[derive(Debug, Clone, Default, PartialEq, Eq)]
335pub struct State {
336 /// The trusted keys.
337 pub keys: Vec<Key>,
338}
339
340impl State {
341 /// Find a trusted key by its id (Go `State.GetKey`).
342 pub fn get_key(&self, key_id: &[u8]) -> Option<&Key> {
343 self.keys.iter().find(|k| k.id() == key_id)
344 }
345}
346
347/// A tailnet-lock authority as a client tracks it: the current trusted-key [`State`] and the chain
348/// `head`. Built by replaying the AUM chain (or from a control-provided checkpoint); the client
349/// then uses [`Authority::node_key_authorized`] to decide whether a peer is trusted.
350#[derive(Debug, Clone)]
351pub struct Authority {
352 head: AumHash,
353 state: State,
354}
355
356impl Authority {
357 /// Construct an authority directly from a known `head` and trusted-key `state` (e.g. a
358 /// control-provided checkpoint the client already trusts).
359 pub fn from_state(head: AumHash, state: State) -> Authority {
360 Authority { head, state }
361 }
362
363 /// The current chain head hash (Go `Authority.Head`).
364 pub fn head(&self) -> AumHash {
365 self.head
366 }
367
368 /// The trusted-key state.
369 pub fn state(&self) -> &State {
370 &self.state
371 }
372
373 /// Whether `head` (e.g. decoded from `TkaInfo.head`) matches this authority's head. A client
374 /// that finds a mismatch must resync before trusting verifications.
375 pub fn head_matches(&self, head: &AumHash) -> bool {
376 &self.head == head
377 }
378
379 /// Verify that `node_key` is authorized under the current authority state by the given
380 /// node-key-signature CBOR blob (Go `Authority.NodeKeyAuthorized`).
381 ///
382 /// Fail-closed: a credential-only signature, an untrusted authorizing key, a malformed blob, or
383 /// a bad signature all return `Err`.
384 ///
385 /// # Errors
386 ///
387 /// Returns [`TkaError::Decode`] if `signature_cbor` is malformed,
388 /// [`TkaError::CredentialCannotAuthorize`] for a credential-only signature,
389 /// [`TkaError::UntrustedKey`] if the authorizing key is not in the current state,
390 /// [`TkaError::NodeKeyMismatch`] if the signature does not cover `node_key`, or
391 /// [`TkaError::BadSignature`] if cryptographic verification fails.
392 pub fn node_key_authorized(
393 &self,
394 node_key: &[u8],
395 signature_cbor: &[u8],
396 ) -> Result<(), TkaError> {
397 let sig = decode_node_key_signature(signature_cbor)?;
398 // A credential signature can never authorize a node on its own.
399 if sig.sig_kind == SigKind::Credential {
400 return Err(TkaError::CredentialCannotAuthorize);
401 }
402 let key_id = sig.authorizing_key_id()?;
403 let key = self.state.get_key(key_id).ok_or(TkaError::UntrustedKey)?;
404 sig.verify_signature(node_key, key)
405 }
406}
407
408/// Compute the [`AumHash`] of an AUM given its canonical CBOR serialization. Exposed so a chain
409/// replayer can link AUMs (`PrevAUMHash`) without re-deriving the hash function.
410pub fn aum_hash(canonical_cbor: &[u8]) -> AumHash {
411 AumHash(blake2s_256(canonical_cbor))
412}
413
414/// A trusted-key payload as carried *inside* an [`Aum`] (`AUMAddKey`/`AUMUpdateKey`) or a
415/// checkpoint [`AumState`] — Go `tka.Key`, full wire shape (the verify-path [`Key`] is a leaner
416/// slice that omits `meta`, which the node-key-signature path never needs).
417///
418/// CBOR keymap (Go `cbor:"…,keyasint"`): `kind`=1, `votes`=2, `public`=3, `meta`=**12** (omitempty).
419#[derive(Debug, Clone, PartialEq, Eq)]
420pub struct AumKey {
421 /// Key algorithm (`Key25519` = 1).
422 pub kind: KeyKind,
423 /// Voting weight.
424 pub votes: u32,
425 /// Raw public key bytes (32 for Ed25519); the key id for `Key25519`.
426 pub public: Vec<u8>,
427 /// Optional metadata (Go `map[string]string`); omitted from CBOR when empty.
428 pub meta: Vec<(alloc::string::String, alloc::string::String)>,
429}
430
431impl AumKey {
432 /// The key id (for `Key25519`, the public key verbatim — Go `Key.ID`).
433 pub fn id(&self) -> &[u8] {
434 &self.public
435 }
436
437 /// Validate this key's well-formedness, mirroring Go `Key.StaticValidate` (`tka/key.go`,
438 /// v1.100.0). A trusted key folded into the state with an out-of-range `votes` would contribute
439 /// the wrong weight to [`pick_next_aum`] fork resolution, so a node that accepts it diverges from
440 /// one that rejects it — a consensus split. Checked at decode/fold time.
441 ///
442 /// Rules (exact Go parity): `votes` must be `1..=4096` (`0` → "key votes must be non-zero",
443 /// `>4096` → "excessive key weight"); the metadata byte total (Σ key+value lengths) must be
444 /// `≤ MAX_META_BYTES`; the kind must be a recognized key kind (`Key25519`).
445 pub fn static_validate(&self) -> Result<(), TkaError> {
446 if self.votes > MAX_KEY_VOTES {
447 return Err(TkaError::BadKeyState);
448 }
449 if self.votes == 0 {
450 return Err(TkaError::BadKeyState);
451 }
452 let meta_bytes: usize = self.meta.iter().map(|(k, v)| k.len() + v.len()).sum();
453 if meta_bytes > MAX_META_BYTES {
454 return Err(TkaError::BadKeyState);
455 }
456 // `kind` is the `KeyKind` enum (only `Ed25519`), so an unrecognized kind is unrepresentable
457 // here — Go's `default → "unrecognized key kind"` arm can't be hit by a decoded `AumKey`.
458 match self.kind {
459 KeyKind::Ed25519 => {}
460 }
461 Ok(())
462 }
463
464 /// The leaner verify-path [`Key`] view of this key (drops `meta`, which the node-key-signature
465 /// verification path never reads). Used by the replayer to populate the trusted-key [`State`].
466 pub fn to_key(&self) -> Key {
467 Key {
468 kind: self.kind,
469 votes: self.votes,
470 public: self.public.clone(),
471 }
472 }
473
474 fn kind_u8(&self) -> u8 {
475 match self.kind {
476 KeyKind::Ed25519 => 1,
477 }
478 }
479
480 fn to_cbor(&self) -> Value {
481 cbor::int_map([
482 (1, Some(Value::Uint(self.kind_u8() as u64))),
483 (2, Some(Value::Uint(self.votes as u64))),
484 (3, Some(Value::Bytes(self.public.clone()))),
485 (12, meta_to_cbor(&self.meta)),
486 ])
487 }
488}
489
490/// A full authority-state snapshot as carried in an `AUMCheckpoint` (Go `tka.State`).
491///
492/// CBOR keymap: `last_aum_hash`=1, `disablement_values`=2, `keys`=3, `state_id1`=4 (omitempty),
493/// `state_id2`=5 (omitempty). Keys 1/2/3 are **non-`omitempty`**, and Go's `fxamacker/cbor`
494/// distinguishes a **nil** slice/pointer from an **empty-but-present** one: a nil field encodes as
495/// CBOR **null** (`0xf6`), an empty-non-nil slice as an empty **array** (`0x80`). `last_aum_hash`,
496/// `disablement_values`, and `keys` are therefore `Option`: `None` ⇒ Go nil ⇒ `0xf6`; `Some(vec)`
497/// (incl. `Some(empty)`) ⇒ array. Getting this wrong changes the checkpoint's `Hash` — and thus the
498/// chain head — versus Go, so it is consensus-relevant (this was a recorded interop bug; fixed here).
499#[derive(Debug, Clone, PartialEq, Eq, Default)]
500pub struct AumState {
501 /// The hash of the AUM this state was produced by (Go `LastAUMHash`); `None` (Go nil) ⇒ null.
502 pub last_aum_hash: Option<AumHash>,
503 /// Disablement secret hashes (Go `DisablementValues`). `None` (Go nil) ⇒ CBOR null `0xf6`;
504 /// `Some(vec)` ⇒ array (empty array `0x80` when `Some(vec![])`).
505 pub disablement_values: Option<Vec<Vec<u8>>>,
506 /// The trusted keys at this state (Go `Keys`). `None` (Go nil) ⇒ CBOR null `0xf6`; `Some(vec)` ⇒
507 /// array.
508 pub keys: Option<Vec<AumKey>>,
509 /// Optional state identifier, high half (Go `StateID1`); omitted from CBOR when 0.
510 pub state_id1: u64,
511 /// Optional state identifier, low half (Go `StateID2`); omitted from CBOR when 0.
512 pub state_id2: u64,
513}
514
515impl AumState {
516 fn to_cbor(&self) -> Value {
517 cbor::int_map([
518 (
519 1,
520 Some(match &self.last_aum_hash {
521 Some(h) => Value::Bytes(h.0.to_vec()),
522 None => Value::Null,
523 }),
524 ),
525 (
526 2,
527 Some(match &self.disablement_values {
528 // Go nil ⇒ CBOR null; Some(vec) ⇒ array (empty array for Some(vec![])).
529 None => Value::Null,
530 Some(vals) => {
531 Value::Array(vals.iter().map(|d| Value::Bytes(d.clone())).collect())
532 }
533 }),
534 ),
535 (
536 3,
537 Some(match &self.keys {
538 None => Value::Null,
539 Some(keys) => Value::Array(keys.iter().map(AumKey::to_cbor).collect()),
540 }),
541 ),
542 (
543 4,
544 (self.state_id1 != 0).then_some(Value::Uint(self.state_id1)),
545 ),
546 (
547 5,
548 (self.state_id2 != 0).then_some(Value::Uint(self.state_id2)),
549 ),
550 ])
551 }
552
553 /// Validate this state for inclusion in a `Checkpoint` AUM, mirroring Go
554 /// `State.staticValidateCheckpoint` (`tka/state.go`, v1.100.0). A checkpoint replaces the entire
555 /// trusted-key set, so a malformed one a Rust node accepts but a Go node rejects (or vice versa)
556 /// is a consensus split; a zero-key checkpoint would silently disable the authority.
557 ///
558 /// Rules (exact Go parity):
559 /// - `last_aum_hash` must be `None` ("cannot specify a parent AUM" — a checkpoint roots a state).
560 /// - disablement values: **≥1**, **≤ MAX_DISABLEMENT_VALUES**, each exactly `DISABLEMENT_LENGTH`
561 /// bytes, **no duplicates**.
562 /// - keys: **≥1**, **≤ MAX_KEYS**, each [`AumKey::static_validate`]s, **no duplicate key ids**.
563 ///
564 /// Treats `None` (Go nil) the same as an empty slice for the count checks (a nil `keys`/
565 /// `disablement_values` fails the "≥1" requirement, exactly as Go's `len(nil) == 0`).
566 pub fn static_validate_checkpoint(&self) -> Result<(), TkaError> {
567 if self.last_aum_hash.is_some() {
568 return Err(TkaError::BadKeyState);
569 }
570
571 let disablements = self.disablement_values.as_deref().unwrap_or(&[]);
572 if disablements.is_empty() || disablements.len() > MAX_DISABLEMENT_VALUES {
573 return Err(TkaError::BadKeyState);
574 }
575 for (i, ds) in disablements.iter().enumerate() {
576 if ds.len() != DISABLEMENT_LENGTH {
577 return Err(TkaError::BadKeyState);
578 }
579 // O(n²) dedup — bounded by MAX_DISABLEMENT_VALUES (32), so trivially small. Mirrors Go's
580 // nested-loop `bytes.Equal` check.
581 if disablements[..i].iter().any(|other| other == ds) {
582 return Err(TkaError::BadKeyState);
583 }
584 }
585
586 let keys = self.keys.as_deref().unwrap_or(&[]);
587 if keys.is_empty() || keys.len() > MAX_KEYS {
588 return Err(TkaError::BadKeyState);
589 }
590 for (i, k) in keys.iter().enumerate() {
591 k.static_validate()?;
592 // Duplicate key-id check (Go compares `Key.ID()` pairwise). Bounded by MAX_KEYS (512).
593 if keys[..i].iter().any(|other| other.id() == k.id()) {
594 return Err(TkaError::BadKeyState);
595 }
596 }
597 Ok(())
598 }
599}
600
601/// A signature attached to an [`Aum`] (Go `tkatype.Signature`): which trusted key signed, and the
602/// signature bytes. CBOR keymap: `key_id`=1, `signature`=2 (both non-`omitempty`).
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub struct AumSignature {
605 /// The id of the trusted key that produced `signature`.
606 pub key_id: Vec<u8>,
607 /// The raw signature bytes.
608 pub signature: Vec<u8>,
609}
610
611impl AumSignature {
612 fn to_cbor(&self) -> Value {
613 // Both fields are non-`omitempty` in Go, so an empty/nil `[]byte` encodes as CBOR null
614 // (`0xf6`), not as an empty byte string (`0x40`) and not omitted — same rule as the AUM's
615 // genesis `prev_aum_hash`. (Go's `TestSerialization` Signature vector ends `02 f6`.)
616 cbor::int_map([
617 (1, Some(bytes_or_null(&self.key_id))),
618 (2, Some(bytes_or_null(&self.signature))),
619 ])
620 }
621}
622
623/// An Authority Update Message (Go `tka.AUM`): one link in the tailnet-lock chain. This is the
624/// acquisition-side type a client replays to derive the trusted-key [`State`] (the verify-only path
625/// in [`Authority`] doesn't need it). Serialization is byte-exact with Go's `fxamacker/cbor`
626/// (CTAP2) so [`Aum::hash`]/[`Aum::sig_hash`] match Go's `AUM.Hash`/`AUM.SigHash`.
627///
628/// CBOR keymap (Go `cbor:"…,keyasint"`): `message_kind`=1, `prev_aum_hash`=2 (both
629/// **non-`omitempty`**; a nil `prev` encodes as CBOR null, *not* omitted), `key`=3, `key_id`=4,
630/// `state`=5, `votes`=6, `meta`=7, `signatures`=**23** (all `omitempty`). Key 23 is the last key
631/// encodable in a single CBOR head byte, which is why Go put `Signatures` there.
632#[derive(Debug, Clone, PartialEq, Eq)]
633pub struct Aum {
634 /// The kind of update.
635 pub message_kind: AumKind,
636 /// The hash of the previous AUM in the chain (`None`/empty = genesis, encodes as CBOR null).
637 pub prev_aum_hash: Option<AumHash>,
638 /// `AUMAddKey`/`AUMUpdateKey`: the key being added.
639 pub key: Option<AumKey>,
640 /// `AUMRemoveKey`/`AUMUpdateKey`: the id of the key being removed/updated.
641 pub key_id: Vec<u8>,
642 /// `AUMCheckpoint`: the full state snapshot.
643 pub state: Option<AumState>,
644 /// `AUMUpdateKey`: the new vote weight (Go `*uint`; `None` = unchanged/omitted).
645 pub votes: Option<u32>,
646 /// `AUMUpdateKey`: new metadata.
647 pub meta: Vec<(alloc::string::String, alloc::string::String)>,
648 /// The signatures over this AUM's [`Aum::sig_hash`].
649 pub signatures: Vec<AumSignature>,
650}
651
652impl Aum {
653 fn message_kind_u8(&self) -> u8 {
654 self.message_kind as u8
655 }
656
657 /// Build the canonical CBOR value. When `include_signatures` is false, key 23 is omitted (the
658 /// [`Aum::sig_hash`] preimage — Go nils `Signatures`, which `omitempty`-drops it).
659 fn to_cbor(&self, include_signatures: bool) -> Value {
660 let signatures = if include_signatures && !self.signatures.is_empty() {
661 Some(Value::Array(
662 self.signatures.iter().map(AumSignature::to_cbor).collect(),
663 ))
664 } else {
665 None
666 };
667 cbor::int_map([
668 // key 1, NON-omitempty.
669 (1, Some(Value::Uint(self.message_kind_u8() as u64))),
670 // key 2, NON-omitempty: a nil prev hash is CBOR null, never omitted.
671 (
672 2,
673 Some(match &self.prev_aum_hash {
674 Some(h) => Value::Bytes(h.0.to_vec()),
675 None => Value::Null,
676 }),
677 ),
678 (3, self.key.as_ref().map(AumKey::to_cbor)),
679 (4, nonempty_bytes(&self.key_id)),
680 (5, self.state.as_ref().map(AumState::to_cbor)),
681 (6, self.votes.map(|v| Value::Uint(v as u64))),
682 (7, meta_to_cbor(&self.meta)),
683 (23, signatures),
684 ])
685 }
686
687 /// The canonical CBOR serialization including signatures (Go `AUM.Serialize`).
688 pub fn serialize(&self) -> Vec<u8> {
689 self.to_cbor(/* include_signatures = */ true).to_vec()
690 }
691
692 /// The chain-link hash: `BLAKE2s-256` of the full serialization (Go `AUM.Hash`).
693 pub fn hash(&self) -> AumHash {
694 AumHash(blake2s_256(&self.serialize()))
695 }
696
697 /// The signing digest: `BLAKE2s-256` of the serialization with `signatures` omitted (Go
698 /// `AUM.SigHash`).
699 pub fn sig_hash(&self) -> [u8; AUM_HASH_LEN] {
700 blake2s_256(&self.to_cbor(/* include_signatures = */ false).to_vec())
701 }
702
703 /// Validate this AUM's structural well-formedness, mirroring Go `AUM.StaticValidate`
704 /// (`tka/aum.go`, v1.100.0). Run **before** folding (and at decode time). Because the chain-link
705 /// [`Aum::hash`] covers *every present field*, an AUM carrying fields foreign to its kind hashes
706 /// differently than the canonical/stripped form — so if one node folds it (no static-validate)
707 /// while another rejects it, they derive different heads = a consensus split. This is the gate
708 /// that keeps both sides byte-identical on what counts as a well-formed AUM.
709 ///
710 /// Rules (exact Go parity):
711 /// - if `key` is present, it must [`AumKey::static_validate`];
712 /// - every signature must have `key_id` of length 32 **and** `signature` of length 64;
713 /// - if `state` is present, it must [`AumState::static_validate_checkpoint`];
714 /// - per-kind field allow-lists:
715 /// - `AddKey`: must have `key`; must NOT set `key_id`/`state`/`votes`/`meta`;
716 /// - `RemoveKey`: must have `key_id`; must NOT set `key`/`state`/`votes`/`meta`;
717 /// - `UpdateKey`: must have `key_id` **and** (`votes` or `meta`); must NOT set `key`/`state`;
718 /// - `Checkpoint`: must have `state`; must NOT set `key_id`/`key`/`votes`/`meta`;
719 /// - `NoOp`/`Invalid`/unknown: no field constraints (Go's forward-compat `default`).
720 ///
721 /// # `key_id`/`meta` nil-vs-empty (a decoder invariant, not a divergence today)
722 /// Go's allow-list tests are asymmetric: the *required*-field checks use `len(KeyID)==0`, but the
723 /// *forbidden*-field checks use `KeyID != nil` / `Meta != nil` — so in Go an empty-but-**non-nil**
724 /// `[]byte{}`/`map{}` counts as "present". This fork models `key_id` as `Vec<u8>` and `meta` as a
725 /// `Vec` (no nil-vs-empty distinction), so "present" == non-empty here. This is **not** an
726 /// accept/reject divergence in practice: Go's encoder `omitempty`-drops an empty `key_id`/`meta`,
727 /// so a well-formed AUM never carries an empty-non-nil one, and on the wire an absent field
728 /// decodes to the nil/empty representation either way. The invariant the future AUM wire decoder
729 /// (chunk 2) MUST uphold to stay consensus-identical with Go: decode an **absent OR empty** key 4
730 /// / key 7 to the empty `Vec` (i.e. collapse Go's empty-non-nil into "absent"), never surfacing a
731 /// distinct empty-but-present state that would flip one of these checks.
732 ///
733 /// The same invariant covers **`prev_aum_hash` (key 2)**: Go `AUM.StaticValidate` rejects a
734 /// present-but-zero-length `PrevAUMHash` ("absent parent must be a nil slice"), but this fork's
735 /// `Option<AumHash>` (`AumHash` wraps a fixed `[u8; 32]`) can only be `None` (nil) or a full
736 /// 32-byte hash — Go's rejected empty-non-nil state is structurally unrepresentable, so the check
737 /// is unnecessary here. The decoder MUST likewise map an absent OR empty key-2 byte string to
738 /// `None`, never an empty-present hash, or a future widening of the representation could
739 /// reintroduce that divergence.
740 pub fn static_validate(&self) -> Result<(), TkaError> {
741 if let Some(key) = &self.key {
742 key.static_validate()?;
743 }
744 for sig in &self.signatures {
745 if sig.key_id.len() != 32 || sig.signature.len() != 64 {
746 return Err(TkaError::Decode(
747 "AUM signature has missing keyID or malformed signature",
748 ));
749 }
750 }
751 if let Some(state) = &self.state {
752 state.static_validate_checkpoint()?;
753 }
754
755 // Field-presence shorthands for the per-kind allow-lists.
756 let has_key = self.key.is_some();
757 let has_key_id = !self.key_id.is_empty();
758 let has_state = self.state.is_some();
759 let has_votes = self.votes.is_some();
760 let has_meta = !self.meta.is_empty();
761
762 match self.message_kind {
763 AumKind::AddKey => {
764 if !has_key {
765 return Err(TkaError::Decode("AddKey AUMs must contain a key"));
766 }
767 if has_key_id || has_state || has_votes || has_meta {
768 return Err(TkaError::Decode("AddKey AUMs may only specify a Key"));
769 }
770 }
771 AumKind::RemoveKey => {
772 if !has_key_id {
773 return Err(TkaError::Decode("RemoveKey AUMs must specify a key ID"));
774 }
775 if has_key || has_state || has_votes || has_meta {
776 return Err(TkaError::Decode("RemoveKey AUMs may only specify a KeyID"));
777 }
778 }
779 AumKind::UpdateKey => {
780 if !has_key_id {
781 return Err(TkaError::Decode("UpdateKey AUMs must specify a key ID"));
782 }
783 if !has_votes && !has_meta {
784 return Err(TkaError::Decode(
785 "UpdateKey AUMs must contain an update to votes or key metadata",
786 ));
787 }
788 if has_key || has_state {
789 return Err(TkaError::Decode(
790 "UpdateKey AUMs may only specify KeyID, Votes, and Meta",
791 ));
792 }
793 }
794 AumKind::Checkpoint => {
795 if !has_state {
796 return Err(TkaError::Decode("Checkpoint AUMs must specify the state"));
797 }
798 if has_key_id || has_key || has_votes || has_meta {
799 return Err(TkaError::Decode("Checkpoint AUMs may only specify State"));
800 }
801 }
802 // NoOp + Invalid (and, once an AUM decoder exists, any unknown forward-compat kind):
803 // no field constraints, matching Go's `AUMNoOp` empty case + tolerant `default`.
804 AumKind::NoOp | AumKind::Invalid => {}
805 }
806 Ok(())
807 }
808}
809
810/// `Some(TextMap)` for a non-empty `map[string]string`, else `None` (the `omitempty` rule). Keys are
811/// UTF-8 text; CTAP2 canonical ordering is applied at encode time by [`cbor::Value::TextMap`].
812fn meta_to_cbor(meta: &[(alloc::string::String, alloc::string::String)]) -> Option<Value> {
813 if meta.is_empty() {
814 return None;
815 }
816 Some(Value::TextMap(
817 meta.iter()
818 .map(|(k, v)| (k.as_bytes().to_vec(), Value::Text(v.as_bytes().to_vec())))
819 .collect(),
820 ))
821}
822
823fn blake2s_256(data: &[u8]) -> [u8; AUM_HASH_LEN] {
824 let mut hasher = Blake2s256::new();
825 hasher.update(data);
826 let out = hasher.finalize();
827 let mut h = [0u8; AUM_HASH_LEN];
828 h.copy_from_slice(&out);
829 h
830}
831
832/// `Some(Bytes)` when `b` is non-empty, else `None` — the `omitempty` rule for byte fields.
833fn nonempty_bytes(b: &[u8]) -> Option<Value> {
834 if b.is_empty() {
835 None
836 } else {
837 Some(Value::Bytes(b.to_vec()))
838 }
839}
840
841/// A byte string, or CBOR null when empty — the encoding Go's `fxamacker/cbor` produces for a
842/// **non-`omitempty`** `[]byte` field that is nil/empty (e.g. `tkatype.Signature.{KeyID,Signature}`,
843/// or an AUM's genesis `PrevAUMHash`). Distinct from [`nonempty_bytes`], which *omits* the field.
844fn bytes_or_null(b: &[u8]) -> Value {
845 if b.is_empty() {
846 Value::Null
847 } else {
848 Value::Bytes(b.to_vec())
849 }
850}
851
852// ===========================================================================================
853// AUM-chain replay (issue #7, chunk 1B) — the acquisition-side derivation of a trusted-key
854// `State`/`Authority` from a chain of `Aum`s, mirroring Go `tka/state.go` + `tka/tka.go`.
855// ===========================================================================================
856
857/// The mutable trusted-key state a replay folds AUMs into. Carries the keys plus the hash of the
858/// last AUM applied (Go `State.LastAUMHash`), which the next AUM's `prev_aum_hash` must match.
859/// Distinct from the public [`State`] (which is the verify-only snapshot the [`Authority`] exposes);
860/// this one tracks the chain cursor needed during replay.
861#[derive(Debug, Clone, Default)]
862struct ReplayState {
863 keys: Vec<AumKey>,
864 last_aum_hash: Option<AumHash>,
865 /// The `(StateID1, StateID2)` seeded by the genesis checkpoint, if any. A *subsequent*
866 /// checkpoint must carry the same pair (Go state.go: "checkpointed state has an incorrect
867 /// stateID"); `None` until a checkpoint is applied.
868 state_id: Option<(u64, u64)>,
869}
870
871impl ReplayState {
872 fn get_key(&self, key_id: &[u8]) -> Option<&AumKey> {
873 self.keys.iter().find(|k| k.id() == key_id)
874 }
875
876 fn find_key_index(&self, key_id: &[u8]) -> Option<usize> {
877 self.keys.iter().position(|k| k.id() == key_id)
878 }
879
880 /// The total signing weight of `aum` under this state (Go `AUM.Weight`): the sum of `votes` over
881 /// the **distinct** keys (deduped by key id) that both signed the AUM *and* are trusted here.
882 /// An unknown signing key contributes 0; a key that signed twice counts once.
883 fn weight(&self, aum: &Aum) -> u64 {
884 let mut seen: Vec<&[u8]> = Vec::new();
885 let mut weight: u64 = 0;
886 for sig in &aum.signatures {
887 let id = sig.key_id.as_slice();
888 if seen.contains(&id) {
889 continue;
890 }
891 if let Some(key) = self.get_key(id) {
892 weight += key.votes as u64;
893 seen.push(id);
894 }
895 }
896 weight
897 }
898
899 /// Fold one already-signature-verified AUM into the state (Go `State.applyVerifiedAUM`).
900 ///
901 /// Checks the parent-hash chain link first (a brand-new state with no `last_aum_hash` matches any
902 /// parent — the genesis case), then applies the per-kind mutation. Advances `last_aum_hash` to
903 /// the applied AUM's own hash so the next link can be verified.
904 ///
905 /// When this is the genesis (the state has no `last_aum_hash` yet), Go restricts the kind to
906 /// `NoOp`/`AddKey`/`Checkpoint` (Go `computeStateAt` rejects anything else as
907 /// "invalid genesis update") and a genesis AUM must carry no parent — both are enforced here so a
908 /// non-genesis-rooted slice can't be silently accepted as if it were a genesis.
909 fn apply_verified_aum(&mut self, aum: &Aum) -> Result<(), TkaError> {
910 match &self.last_aum_hash {
911 // Once the chain is rolling, the AUM must name the current head as its parent.
912 Some(head) => match &aum.prev_aum_hash {
913 Some(prev) if prev == head => {}
914 _ => return Err(TkaError::BadParent),
915 },
916 // Genesis: must have no parent, and only certain kinds may start a chain.
917 None => {
918 if aum.prev_aum_hash.is_some() {
919 return Err(TkaError::BadParent);
920 }
921 if !matches!(
922 aum.message_kind,
923 AumKind::NoOp | AumKind::AddKey | AumKind::Checkpoint
924 ) {
925 return Err(TkaError::BadChain);
926 }
927 }
928 }
929
930 match aum.message_kind {
931 AumKind::NoOp | AumKind::Invalid => {
932 // No state change (unknown/forward-compat kinds are tolerated as no-ops, matching
933 // Go's `default` arm). The chain cursor still advances (below).
934 }
935 AumKind::Checkpoint => {
936 // A checkpoint replaces the whole key set with its embedded snapshot. A genesis
937 // checkpoint seeds the authority's StateID; a later checkpoint must match it (Go
938 // rejects "checkpointed state has an incorrect stateID") — otherwise it belongs to a
939 // different authority and replaying it would silently fork the trusted-key set.
940 let state = aum
941 .state
942 .as_ref()
943 .ok_or(TkaError::Decode("checkpoint AUM missing state"))?;
944 let incoming = (state.state_id1, state.state_id2);
945 match self.state_id {
946 Some(existing) if existing != incoming => {
947 return Err(TkaError::BadKeyState);
948 }
949 _ => self.state_id = Some(incoming),
950 }
951 // A checkpoint replaces the key set with its snapshot; a nil `keys` (Go nil) means
952 // "no trusted keys", i.e. the empty set.
953 self.keys = state.keys.clone().unwrap_or_default();
954 }
955 AumKind::AddKey => {
956 let key = aum
957 .key
958 .as_ref()
959 .ok_or(TkaError::Decode("AddKey AUM missing key"))?;
960 if self.get_key(key.id()).is_some() {
961 return Err(TkaError::BadKeyState);
962 }
963 self.keys.push(key.clone());
964 }
965 AumKind::UpdateKey => {
966 let idx = self
967 .find_key_index(&aum.key_id)
968 .ok_or(TkaError::BadKeyState)?;
969 // Mirror Go `applyVerifiedAUM` (AUMUpdateKey): each field is applied only when the
970 // update carries it — `if update.Votes != nil` / `if update.Meta != nil`. `votes`
971 // maps cleanly to `Option<u32>`. `meta` is a `Vec`, which cannot distinguish Go's
972 // nil map (leave unchanged) from an empty-but-present map (clear) — we treat empty
973 // as "not carried / leave unchanged", matching the nil case. The empty-but-present
974 // "clear meta" case is both unrepresentable here and verify-irrelevant (the
975 // verify-path `Key` drops `meta` entirely via `to_key`, so it never reaches a
976 // `node_key_authorized` decision); document the limitation rather than assign
977 // unconditionally, which would wrongly wipe `meta` on a votes-only update.
978 if let Some(votes) = aum.votes {
979 self.keys[idx].votes = votes;
980 }
981 if !aum.meta.is_empty() {
982 self.keys[idx].meta = aum.meta.clone();
983 }
984 // Go `applyVerifiedAUM` re-runs `k.StaticValidate()` after the mutation and errors
985 // "updated key fails validation" if the *result* is invalid (e.g. votes set to 0 or
986 // > 4096). Without this, an out-of-range UpdateKey one node accepts and another
987 // rejects flips fork weight → consensus split.
988 self.keys[idx].static_validate()?;
989 }
990 AumKind::RemoveKey => {
991 // Last-key guard (Go `aumVerify`): refuse to remove the final trusted key — that
992 // would leave the authority with an empty key set and effectively disable tailnet
993 // lock. Checked against the key id, before the removal.
994 if self.keys.len() == 1 && self.keys[0].id() == aum.key_id.as_slice() {
995 return Err(TkaError::BadKeyState);
996 }
997 let idx = self
998 .find_key_index(&aum.key_id)
999 .ok_or(TkaError::BadKeyState)?;
1000 self.keys.remove(idx);
1001 }
1002 }
1003
1004 self.last_aum_hash = Some(aum.hash());
1005 Ok(())
1006 }
1007
1008 /// The verify-path [`State`] snapshot (just the trusted keys).
1009 fn to_state(&self) -> State {
1010 State {
1011 keys: self.keys.iter().map(AumKey::to_key).collect(),
1012 }
1013 }
1014
1015 /// Verify an AUM's signatures against the trusted keys in **this** state — the authenticity
1016 /// gate Go runs in `aumVerify` (`tka/tka.go`) before `applyVerifiedAUM`. This is MUST-1: a
1017 /// control-supplied chain must not advance the trusted-key state on an AUM that isn't signed by
1018 /// keys already trusted at its parent.
1019 ///
1020 /// Mirrors Go exactly (verified against `tailscale/tailscale` v1.100.0):
1021 /// - **`len(signatures) == 0` ⇒ "unsigned AUM"** ([`TkaError::UnsignedAum`]). Every AUM,
1022 /// including the genesis, must be signed.
1023 /// - For **every** signature (not "at least one"): its `key_id` must resolve to a key trusted in
1024 /// this state ([`TkaError::UntrustedKey`] otherwise), and the signature must verify
1025 /// cryptographically over [`Aum::sig_hash`] with that key — cofactored Ed25519
1026 /// (`ed25519consensus.Verify` / ZIP-215), the same primitive the node-key-signature Direct
1027 /// path uses. Any failure rejects the whole AUM ([`TkaError::BadSignature`]).
1028 /// - **No weight/threshold gate here.** Go's `aumVerify` does *not* compare `Weight` against any
1029 /// quorum; weight is used only by [`pick_next_aum`] for fork resolution. "Authentic" means
1030 /// all signatures valid against trusted keys.
1031 /// - `is_genesis` only documents intent; unlike Go it changes nothing in *this* function (Go's
1032 /// `isGenesisAUM` flag gates `checkParent`, which the replayer performs separately in
1033 /// [`ReplayState::apply_verified_aum`]). The signature checks run identically for genesis and
1034 /// non-genesis AUMs.
1035 fn verify_aum_signatures(&self, aum: &Aum, _is_genesis: bool) -> Result<(), TkaError> {
1036 if aum.signatures.is_empty() {
1037 return Err(TkaError::UnsignedAum);
1038 }
1039 let sig_hash = aum.sig_hash();
1040 for sig in &aum.signatures {
1041 let key = self.get_key(&sig.key_id).ok_or(TkaError::UntrustedKey)?;
1042 if key.kind != KeyKind::Ed25519 || key.public.len() != 32 {
1043 return Err(TkaError::Decode("AUM signing key is not ed25519"));
1044 }
1045 // AUM `tkatype.Signature` verifies cofactored (ZIP-215), matching Go's
1046 // `signatureVerify` → `ed25519consensus.Verify`.
1047 verify_ed25519_zip215(&key.public, &sig_hash, &sig.signature)?;
1048 }
1049 Ok(())
1050 }
1051}
1052
1053/// A chain of AUMs whose signatures have been verified as it was folded from genesis to head — the
1054/// only input [`Authority::from_verified_chain`] accepts. This is MUST-1 made un-skippable in the
1055/// type system: a `VerifiedAumChain` can be obtained **only** via [`VerifiedAumChain::verify`],
1056/// which runs Go's `aumVerify` on every AUM against the trusted-key state as it stood at that AUM's
1057/// parent. A control-supplied `&[Aum]` therefore cannot reach a live `Authority`'s trusted-key set
1058/// without each link being signed by keys already trusted at the point it is applied.
1059///
1060/// Mirrors Go `tka.Authority.Inform` (`InformIdempotent` → `aumVerify(update, state, false)` →
1061/// `CommitVerifiedAUMs`), which verifies as it folds rather than trusting a precomputed chain.
1062#[derive(Debug, Clone)]
1063pub struct VerifiedAumChain {
1064 /// The replayed trusted-key state at the head (already folded + verified).
1065 state: ReplayState,
1066 /// The head AUM hash (the chain link the resulting authority advertises).
1067 head: AumHash,
1068}
1069
1070impl VerifiedAumChain {
1071 /// Verify and replay a **linear** chain of AUMs from genesis to head, checking each AUM's
1072 /// signatures against the trusted-key state at its parent (MUST-1). On success the returned
1073 /// value witnesses that every link is authentic and the chain folds cleanly.
1074 ///
1075 /// `aums` must be ordered parent→child (the first is the genesis). The genesis is verified
1076 /// against the trusted-key set it establishes: a genesis **`Checkpoint`** self-certifies (its
1077 /// signatures must verify against the keys it embeds, exactly Go's
1078 /// `aumVerify(bootstrap, *bootstrap.State, true)`); a genesis `AddKey`/`NoOp` is verified
1079 /// against the keys present *after* it seeds them, so a bootstrapping `AddKey` must be
1080 /// self-signed by the key it introduces. Each subsequent AUM is verified against the state at
1081 /// its parent, then folded.
1082 ///
1083 /// # Errors
1084 /// [`TkaError::UnsignedAum`] for an AUM with no signatures; [`TkaError::UntrustedKey`] if a
1085 /// signature names a key not trusted at that point; [`TkaError::BadSignature`] on a failed
1086 /// cryptographic check; plus every structural error of [`ReplayState::apply_verified_aum`]
1087 /// ([`TkaError::BadChain`]/[`BadParent`](TkaError::BadParent)/[`BadKeyState`](TkaError::BadKeyState)/[`Decode`](TkaError::Decode)).
1088 pub fn verify(aums: &[Aum]) -> Result<VerifiedAumChain, TkaError> {
1089 let last = aums.last().ok_or(TkaError::BadChain)?;
1090 let head = last.hash();
1091 let mut state = ReplayState::default();
1092 for (i, aum) in aums.iter().enumerate() {
1093 let is_genesis = i == 0;
1094 // Go `aumVerify` runs `aum.StaticValidate()` FIRST (before parent/signature checks). It
1095 // is state-independent (per-kind field allow-lists, per-sig 32/64-byte lengths, embedded
1096 // Key/Checkpoint validity), so it gates every AUM the same way regardless of position.
1097 aum.static_validate()?;
1098 // Then the structural fold + signature verify. Apply the fold FIRST for the genesis so a
1099 // genesis `Checkpoint`/`AddKey` seeds the trusted keys, then verify signatures against
1100 // the resulting state — this is what lets a genesis self-certify (Go verifies a bootstrap
1101 // Checkpoint against its own embedded `*State`). For a non-genesis AUM,
1102 // `apply_verified_aum` only mutates *after* its parent-link check passes; we verify
1103 // signatures against the state-at-parent by checking BEFORE the fold.
1104 if is_genesis {
1105 state.apply_verified_aum(aum)?;
1106 state.verify_aum_signatures(aum, true)?;
1107 } else {
1108 state.verify_aum_signatures(aum, false)?;
1109 state.apply_verified_aum(aum)?;
1110 }
1111 }
1112 Ok(VerifiedAumChain { state, head })
1113 }
1114}
1115
1116/// Choose the next AUM to apply when more than one child extends the current head (Go
1117/// `tka.pickNextAUM`). The three rules, in order:
1118///
1119/// 1. **Highest signature weight** wins (computed against `state`).
1120/// 2. If tied, prefer the **`RemoveKey`** AUM (a revocation should not be out-voted by a no-op fork).
1121/// 3. If still tied, the **lowest `AUM.Hash()`** (bytewise) wins — a deterministic, content-derived
1122/// tiebreak both peers compute identically.
1123///
1124/// `candidates` must be non-empty. The comparison is total and deterministic, so every node
1125/// replaying the same chain selects the same active branch (the property tailnet-lock relies on).
1126fn pick_next_aum<'a>(state: &ReplayState, candidates: &'a [Aum]) -> &'a Aum {
1127 debug_assert!(!candidates.is_empty(), "pick_next_aum needs candidates");
1128 let mut best = &candidates[0];
1129 let mut best_weight = state.weight(best);
1130 let mut best_hash = best.hash();
1131 for cand in &candidates[1..] {
1132 let w = state.weight(cand);
1133 let h = cand.hash();
1134 // Rule 1: strictly higher weight wins.
1135 let better = if w != best_weight {
1136 w > best_weight
1137 } else if (cand.message_kind == AumKind::RemoveKey)
1138 != (best.message_kind == AumKind::RemoveKey)
1139 {
1140 // Rule 2: exactly one is a RemoveKey → that one wins.
1141 cand.message_kind == AumKind::RemoveKey
1142 } else {
1143 // Rule 3: lowest hash wins.
1144 h.0 < best_hash.0
1145 };
1146 if better {
1147 best = cand;
1148 best_weight = w;
1149 best_hash = h;
1150 }
1151 }
1152 best
1153}
1154
1155impl Authority {
1156 /// Build an [`Authority`] from a [`VerifiedAumChain`] — the **trust-boundary** constructor.
1157 ///
1158 /// Because a `VerifiedAumChain` can only be produced by [`VerifiedAumChain::verify`] (which runs
1159 /// Go's `aumVerify` on every link), this is the constructor a live client must use when the chain
1160 /// originates from an untrusted source (the control plane / `/machine/tka/*` sync RPC). The type
1161 /// system makes the signature check un-skippable: there is no way to reach this function with an
1162 /// unverified chain. Mirrors Go `tka.Open`, which folds only AUMs already verified by `Inform`.
1163 pub fn from_verified_chain(chain: VerifiedAumChain) -> Authority {
1164 Authority {
1165 head: chain.head,
1166 state: chain.state.to_state(),
1167 }
1168 }
1169
1170 /// Build an [`Authority`] by replaying a **linear** chain of AUMs from genesis to head (Go
1171 /// `tka.Authority.Head` after `computeActiveChain` on a single confirmed branch), checking only
1172 /// the chain's **structure** (genesis kind, parent links, key-state transitions, checkpoint
1173 /// StateID) — **NOT** AUM signatures.
1174 ///
1175 /// # This is NOT a trust boundary
1176 /// `from_chain` does not verify that each AUM is signed by keys trusted at its parent. It is safe
1177 /// only for chains whose authenticity is already established by other means — the existing unit
1178 /// tests, and a chain the caller has *itself* fed through [`VerifiedAumChain::verify`]. For any
1179 /// chain that comes from an untrusted source (the control plane), use
1180 /// [`VerifiedAumChain::verify`] + [`Authority::from_verified_chain`], which the type system makes
1181 /// impossible to bypass. (A malicious control plane could otherwise forge an `AddKey`/`RemoveKey`
1182 /// here and silently defeat tailnet lock — the exact threat TKA exists to stop.)
1183 ///
1184 /// `aums` must be ordered parent→child: the first is the genesis — a `NoOp`, `AddKey`, or
1185 /// `Checkpoint` with **no** parent (Go `computeStateAt` rejects any other kind as an invalid
1186 /// genesis) — and each subsequent AUM's `prev_aum_hash` must equal the prior AUM's [`Aum::hash`].
1187 /// A slice that is actually a *suffix* of a chain (its first AUM names a parent not in the slice)
1188 /// is rejected rather than mis-rooted.
1189 ///
1190 /// For the **forked** case (competing children of one parent), use [`Authority::from_forked_chain`].
1191 ///
1192 /// # Errors
1193 /// [`TkaError::BadChain`] if `aums` is empty or its genesis is an invalid kind;
1194 /// [`TkaError::BadParent`] if a link doesn't chain (incl. a genesis that carries a parent);
1195 /// [`TkaError::BadKeyState`] for an invalid add/remove/update or a mismatched checkpoint StateID;
1196 /// [`TkaError::Decode`] for a malformed checkpoint/add.
1197 pub fn from_chain(aums: &[Aum]) -> Result<Authority, TkaError> {
1198 let last = aums.last().ok_or(TkaError::BadChain)?;
1199 let head = last.hash();
1200 let mut state = ReplayState::default();
1201 for aum in aums {
1202 state.apply_verified_aum(aum)?;
1203 }
1204 Ok(Authority {
1205 head,
1206 state: state.to_state(),
1207 })
1208 }
1209
1210 /// Resolve a **single fork point**: a shared linear `prefix` (genesis→fork point, parent-ordered)
1211 /// followed by `branches`, the competing children of the fork point. The active child is chosen by
1212 /// [`pick_next_aum`]'s deterministic rules (weight → `RemoveKey` preference → lowest hash),
1213 /// evaluated against the state at the fork point, and applied. This is the consensus-critical
1214 /// selection every node must make identically; the linear [`Authority::from_chain`] is the common
1215 /// (no-fork) case.
1216 ///
1217 /// **Each branch must be exactly one AUM.** In this single-AUM-per-branch shape the choice is
1218 /// provably identical to Go's `pickNextAUM` over the fork point's children. A *multi-step* branch
1219 /// is **rejected** ([`TkaError::BadChain`]) rather than mis-resolved: Go re-runs `pickNextAUM` at
1220 /// *every* link (`advanceByPrimary`), re-evaluating weight against the evolving state, so judging a
1221 /// whole multi-AUM branch by its first AUM alone could pick a different active head than Go and
1222 /// silently fork the trusted-key set. Implementing the per-step loop (and a general multi-fork DAG
1223 /// walk) is deferred to when the sync layer can actually surface such a chain; until then this
1224 /// guard keeps the model honest. (The common re-bootstrap case — competing single-AUM heads — is
1225 /// fully covered.)
1226 ///
1227 /// # Errors
1228 /// As [`Authority::from_chain`], plus [`TkaError::BadChain`] if `branches` is empty, or any branch
1229 /// is not exactly one AUM.
1230 pub fn from_forked_chain(prefix: &[Aum], branches: &[&[Aum]]) -> Result<Authority, TkaError> {
1231 // Each branch must be exactly one AUM — see the doc: a multi-step branch judged by its first
1232 // AUM could diverge from Go's per-step resolution. Reject rather than silently mis-resolve.
1233 if branches.is_empty() || branches.iter().any(|b| b.len() != 1) {
1234 return Err(TkaError::BadChain);
1235 }
1236 let mut state = ReplayState::default();
1237 for aum in prefix {
1238 state.apply_verified_aum(aum)?;
1239 }
1240 // Choose the winning child, judged against the state at the fork point — exactly Go's
1241 // `pickNextAUM` over the children.
1242 let heads: Vec<Aum> = branches.iter().map(|b| b[0].clone()).collect();
1243 let winner_head = pick_next_aum(&state, &heads).hash();
1244 let winner = branches
1245 .iter()
1246 .find(|b| b[0].hash() == winner_head)
1247 .ok_or(TkaError::BadChain)?;
1248 state.apply_verified_aum(&winner[0])?;
1249 Ok(Authority {
1250 head: winner[0].hash(),
1251 state: state.to_state(),
1252 })
1253 }
1254}
1255
1256// ===========================================================================================
1257// AUM-chain sync (issue #7, chunk 2 — `tsr-5po`): the acquisition-side machinery a client uses
1258// to catch its local chain up to the control server's, mirroring Go `tka/sync.go` + the
1259// `computeStateAt`/`fastForward` chain walkers in `tka/tka.go` (v1.100.0). This is the storage +
1260// offer/missing layer the `/machine/tka/sync` RPC (a later chunk) drives; it does NOT itself talk
1261// to the network.
1262//
1263// Verification posture: the walkers fold AUMs with `ReplayState::apply_verified_aum`
1264// (structural-only, exactly Go's `applyVerifiedAUM`), NOT `verify_aum_signatures`. Authenticity is
1265// enforced separately when a synced chain is turned into an `Authority` (via
1266// `VerifiedAumChain::verify` + `from_verified_chain`, the un-bypassable trust boundary). The store
1267// itself is untrusted scratch space — putting unverified AUMs in it is fine because nothing trusts
1268// them until that boundary runs.
1269// ===========================================================================================
1270
1271/// The starting number of AUMs to skip between ancestors in a [`SyncOffer`] (Go
1272/// `ancestorsSkipStart`). The gap grows exponentially (`<< ancestorsSkipShift` each step).
1273const ANCESTORS_SKIP_START: u64 = 4;
1274/// How many bits to advance the ancestor skip count each step (Go `ancestorsSkipShift`): `4 << 2 =
1275/// 16`, so after skipping 4 it skips 16, then 64…
1276const ANCESTORS_SKIP_SHIFT: u64 = 2;
1277/// Iteration cap for the backward head-intersection walk + offer ancestor walk (Go
1278/// `maxSyncHeadIntersectionIter`, `tka/limits.go`).
1279const MAX_SYNC_HEAD_INTERSECTION_ITER: u64 = 400;
1280/// Iteration cap for forward fast-forward / `computeStateAt` walks (Go `maxSyncIter` /
1281/// `maxScanIterations`, `tka/limits.go`).
1282const MAX_SYNC_ITER: usize = 2000;
1283
1284/// A read-only store of AUMs keyed by hash, plus the parent→children index the forward walk needs
1285/// (Go's `tka.Chonk`, reduced to the methods the sync/offer path actually calls). The client builds
1286/// one of these from the AUMs it has on hand; the sync RPC populates it with what control sends.
1287///
1288/// "Not found" is signalled by `None` from [`aum`](AumStore::aum) (Go's `os.ErrNotExist` sentinel),
1289/// which the walkers treat as a loop terminator, not an error.
1290pub trait AumStore {
1291 /// Fetch the AUM with this hash, or `None` if the store does not hold it.
1292 fn aum(&self, hash: &AumHash) -> Option<Aum>;
1293 /// The AUMs whose `prev_aum_hash` is `hash` — the forward links out of `hash` (Go
1294 /// `Chonk.ChildAUMs`). Order is unspecified; the caller resolves forks deterministically.
1295 fn child_aums(&self, hash: &AumHash) -> Vec<Aum>;
1296}
1297
1298/// An in-memory [`AumStore`]: a hash→AUM map plus a parent-hash→child-hashes index, both built as
1299/// AUMs are inserted. `no_std`-friendly (`BTreeMap`, not `HashMap`). This is the store a client uses
1300/// to stage the AUMs it knows about while computing a [`SyncOffer`] / the AUMs a peer is missing.
1301#[derive(Debug, Clone, Default)]
1302pub struct MemAumStore {
1303 by_hash: alloc::collections::BTreeMap<AumHash, Aum>,
1304 /// parent hash → child hashes (the forward index). A genesis AUM (no parent) contributes no
1305 /// entry here; it is found only via `by_hash`.
1306 children: alloc::collections::BTreeMap<AumHash, Vec<AumHash>>,
1307}
1308
1309impl MemAumStore {
1310 /// A new, empty store.
1311 pub fn new() -> MemAumStore {
1312 MemAumStore::default()
1313 }
1314
1315 /// Insert an AUM, indexing it by its own hash and (if it has a parent) under its parent's child
1316 /// list. Idempotent: re-inserting the same AUM hash replaces it and does not duplicate the child
1317 /// edge. Returns the inserted AUM's hash.
1318 pub fn insert(&mut self, aum: Aum) -> AumHash {
1319 let hash = aum.hash();
1320 if let Some(parent) = aum.prev_aum_hash {
1321 let kids = self.children.entry(parent).or_default();
1322 if !kids.contains(&hash) {
1323 kids.push(hash);
1324 }
1325 }
1326 self.by_hash.insert(hash, aum);
1327 hash
1328 }
1329
1330 /// Build a store from an iterator of AUMs (e.g. a chain or a sync batch).
1331 pub fn from_aums(aums: impl IntoIterator<Item = Aum>) -> MemAumStore {
1332 let mut store = MemAumStore::new();
1333 for aum in aums {
1334 store.insert(aum);
1335 }
1336 store
1337 }
1338
1339 /// Number of AUMs held.
1340 pub fn len(&self) -> usize {
1341 self.by_hash.len()
1342 }
1343
1344 /// Whether the store holds no AUMs.
1345 pub fn is_empty(&self) -> bool {
1346 self.by_hash.is_empty()
1347 }
1348}
1349
1350impl AumStore for MemAumStore {
1351 fn aum(&self, hash: &AumHash) -> Option<Aum> {
1352 self.by_hash.get(hash).cloned()
1353 }
1354
1355 fn child_aums(&self, hash: &AumHash) -> Vec<Aum> {
1356 self.children
1357 .get(hash)
1358 .map(|kids| {
1359 kids.iter()
1360 .filter_map(|h| self.by_hash.get(h).cloned())
1361 .collect()
1362 })
1363 .unwrap_or_default()
1364 }
1365}
1366
1367/// A node's view of where its chain is, offered to a peer so the peer can work out what to send (Go
1368/// `tka.SyncOffer`): the current `head` plus a sparse, exponentially-spaced sample of `ancestors`
1369/// back to the oldest AUM the node holds. The last entry is always the oldest-known AUM.
1370#[derive(Debug, Clone, PartialEq, Eq)]
1371pub struct SyncOffer {
1372 /// The node's current chain head.
1373 pub head: AumHash,
1374 /// A subset of the chain's ancestors, newest-first, ending with the oldest-known AUM. Used by a
1375 /// peer to find a "tail intersection" when it doesn't recognise the head.
1376 pub ancestors: Vec<AumHash>,
1377}
1378
1379/// The result of comparing two [`SyncOffer`]s (Go `tka.intersection`): where (if anywhere) the two
1380/// chains meet, which tells [`missing_aums`](sync_missing_aums) where to start gathering.
1381#[derive(Debug, Clone, PartialEq, Eq)]
1382struct Intersection {
1383 /// Both heads are equal — nothing to exchange.
1384 up_to_date: bool,
1385 /// The newest common AUM that is the *remote's* head and an ancestor of ours (we have updates
1386 /// building on it to send).
1387 head_intersection: Option<AumHash>,
1388 /// The oldest common AUM, where the chains diverge — a starting point to send from when we don't
1389 /// recognise the remote's head.
1390 tail_intersection: Option<AumHash>,
1391}
1392
1393/// Compute the state at `want_hash` by walking back to a checkpoint or genesis, then forward along
1394/// the taken path (Go `computeStateAt`, `tka/tka.go`). Structural fold only (no signature check) —
1395/// the verify boundary is elsewhere. Returns `None` (not an error) if `want_hash` is not in the
1396/// store, mirroring the `os.ErrNotExist` sentinel Go callers special-case.
1397fn compute_state_at(
1398 storage: &dyn AumStore,
1399 max_iter: usize,
1400 want_hash: AumHash,
1401) -> Result<Option<ReplayState>, TkaError> {
1402 let Some(top) = storage.aum(&want_hash) else {
1403 return Ok(None);
1404 };
1405
1406 // Walk backwards to a starting point: a checkpoint AUM (which carries full state) or a genesis
1407 // AUM (no parent — valid only for NoOp/AddKey/Checkpoint). `path` records every hash on the way
1408 // so the forward pass can follow exactly this branch (which, for a non-primary fork, may differ
1409 // from standard fork resolution).
1410 let mut curs = top;
1411 let mut state = ReplayState::default();
1412 let mut path: alloc::collections::BTreeSet<AumHash> = alloc::collections::BTreeSet::new();
1413 let mut started = false;
1414 for i in 0..=max_iter {
1415 if i == max_iter {
1416 return Err(TkaError::BadChain); // iteration limit exceeded
1417 }
1418 path.insert(curs.hash());
1419
1420 if curs.message_kind == AumKind::Checkpoint {
1421 // A checkpoint encapsulates the state at that point: fold it into an empty state.
1422 let mut s = ReplayState::default();
1423 s.apply_verified_aum(&curs)?;
1424 state = s;
1425 started = true;
1426 break;
1427 }
1428 match curs.prev_aum_hash {
1429 None => {
1430 // Genesis: applies to the empty state. Only NoOp/AddKey reach here (Checkpoint broke
1431 // above); anything else is an invalid genesis. `apply_verified_aum` enforces the
1432 // same kind restriction, so just fold and let it reject a bad genesis.
1433 let mut s = ReplayState::default();
1434 s.apply_verified_aum(&curs)?;
1435 state = s;
1436 started = true;
1437 break;
1438 }
1439 Some(parent) => {
1440 let Some(p) = storage.aum(&parent) else {
1441 return Err(TkaError::BadParent); // dangling parent link
1442 };
1443 curs = p;
1444 }
1445 }
1446 }
1447 debug_assert!(
1448 started,
1449 "compute_state_at must find a checkpoint or genesis"
1450 );
1451
1452 // Fast-forward from the starting point, following only AUMs on `path` (the custom advancer),
1453 // until we reach `want_hash`. No gather side-effect here — we only want the final state.
1454 let (_, end_state) = fast_forward(
1455 storage,
1456 max_iter,
1457 state,
1458 &mut |_: &Aum, _: &mut ReplayState| Ok(false),
1459 Some(&path),
1460 Some(want_hash),
1461 )?;
1462 Ok(Some(end_state))
1463}
1464
1465/// Fast-forward from `start_state` along the chain (Go `fastForwardWithAdvancer` +
1466/// `advanceByPrimary`). At each step it takes the children of the current AUM and advances by:
1467/// - the child on `path` when `path` is `Some` (the `computeStateAt` custom advancer), or
1468/// - [`pick_next_aum`]'s deterministic fork resolution otherwise (the primary advancer).
1469///
1470/// Stops when `stop_at` (when set) is reached — returning that AUM + the state *before* applying it
1471/// for a gather caller, matching Go's `done(curs, state)` check at the top of the loop — or when
1472/// there are no more children. `gather` is invoked for each visited AUM (Go's `done` callback side
1473/// effect) and its boolean return additionally stops the walk when true.
1474///
1475/// Returns the final AUM reached and the folded state at it. Structural fold only.
1476fn fast_forward(
1477 storage: &dyn AumStore,
1478 max_iter: usize,
1479 start_state: ReplayState,
1480 gather: &mut dyn FnMut(&Aum, &mut ReplayState) -> Result<bool, TkaError>,
1481 path: Option<&alloc::collections::BTreeSet<AumHash>>,
1482 stop_at: Option<AumHash>,
1483) -> Result<(Aum, ReplayState), TkaError> {
1484 let start_hash = start_state
1485 .last_aum_hash
1486 .ok_or(TkaError::Decode("fast_forward from a state with no head"))?;
1487 let mut curs = storage.aum(&start_hash).ok_or(TkaError::BadParent)?;
1488 let mut state = start_state;
1489
1490 for _ in 0..max_iter {
1491 // Done check runs BEFORE advancing (Go checks `done(curs, state)` at loop top): for a
1492 // `stop_at` caller this returns the stop AUM with the state *before* applying it.
1493 if Some(curs.hash()) == stop_at {
1494 return Ok((curs, state));
1495 }
1496 // Side-effect callback (the gather closure for `missing_aums`); a `true` return also stops.
1497 if gather(&curs, &mut state)? {
1498 return Ok((curs, state));
1499 }
1500
1501 let children = storage.child_aums(&curs.hash());
1502 let next = match path {
1503 // `computeStateAt` advancer: follow the unique child that is on the recorded path.
1504 Some(p) => children.into_iter().find(|c| p.contains(&c.hash())),
1505 // Primary advancer: deterministic fork resolution.
1506 None => {
1507 if children.is_empty() {
1508 None
1509 } else {
1510 Some(pick_next_aum(&state, &children).clone())
1511 }
1512 }
1513 };
1514 match next {
1515 None => return Ok((curs, state)), // no more children: we are at head
1516 Some(n) => {
1517 state.apply_verified_aum(&n)?;
1518 curs = n;
1519 }
1520 }
1521 }
1522 Err(TkaError::BadChain) // iteration limit exceeded
1523}
1524
1525impl Authority {
1526 /// Build the [`SyncOffer`] this authority would send a peer (Go `Authority.SyncOffer`): its
1527 /// `head` plus an exponentially-spaced sample of ancestors back to `oldest`, ending with
1528 /// `oldest`. `oldest` is the oldest AUM the caller holds (Go `a.oldestAncestor.Hash()`); our
1529 /// verify-only [`Authority`] does not track it, so it is passed in — typically the genesis hash
1530 /// of the chain the caller staged in `storage`.
1531 ///
1532 /// `storage` must contain the chain from `head` back to `oldest`; a gap simply truncates the
1533 /// ancestor list early (the walk breaks on the first missing parent, exactly like Go).
1534 pub fn sync_offer(
1535 &self,
1536 storage: &dyn AumStore,
1537 oldest: AumHash,
1538 ) -> Result<SyncOffer, TkaError> {
1539 let mut out = SyncOffer {
1540 head: self.head,
1541 ancestors: Vec::with_capacity(6),
1542 };
1543 let mut skip_amount = ANCESTORS_SKIP_START;
1544 let mut curs = self.head;
1545 for i in 0..MAX_SYNC_HEAD_INTERSECTION_ITER {
1546 if i > 0 && skip_amount != 0 && i % skip_amount == 0 {
1547 out.ancestors.push(curs);
1548 skip_amount <<= ANCESTORS_SKIP_SHIFT;
1549 }
1550 let Some(parent) = storage.aum(&curs) else {
1551 break; // os.ErrNotExist: stop, don't error
1552 };
1553 // We append `oldest` after the loop, so don't duplicate it.
1554 if parent.hash() == oldest {
1555 break;
1556 }
1557 match parent.prev_aum_hash {
1558 Some(prev) => curs = prev,
1559 None => break, // reached a genesis that isn't `oldest`; nothing earlier to walk
1560 }
1561 }
1562 out.ancestors.push(oldest);
1563 Ok(out)
1564 }
1565
1566 /// Given a peer's [`SyncOffer`], compute the AUMs **they** are missing — the ones to send them so
1567 /// their chain catches up to ours (Go `Authority.MissingAUMs`). `storage` must hold our chain.
1568 /// Returns an empty `Vec` when the peer is already up to date.
1569 ///
1570 /// Mirrors Go: compute our own offer, find the intersection of the two chains, then gather every
1571 /// AUM from the intersection forward to our head (excluding the intersection AUM itself).
1572 pub fn missing_aums(
1573 &self,
1574 storage: &dyn AumStore,
1575 remote_offer: &SyncOffer,
1576 oldest: AumHash,
1577 ) -> Result<Vec<Aum>, TkaError> {
1578 let local_offer = self.sync_offer(storage, oldest)?;
1579 let isect = compute_sync_intersection(storage, &local_offer, remote_offer)?;
1580 if isect.up_to_date {
1581 return Ok(Vec::new());
1582 }
1583 let from = isect
1584 .head_intersection
1585 .or(isect.tail_intersection)
1586 .ok_or(TkaError::BadChain)?; // Go panics "unreachable"; we fail closed instead.
1587
1588 let Some(state) = compute_state_at(storage, MAX_SYNC_ITER, from)? else {
1589 return Err(TkaError::BadParent);
1590 };
1591 let mut out: Vec<Aum> = Vec::with_capacity(12);
1592 fast_forward(
1593 storage,
1594 MAX_SYNC_ITER,
1595 state,
1596 &mut |curs: &Aum, _: &mut ReplayState| -> Result<bool, TkaError> {
1597 // Gather every AUM from the intersection forward, excluding the intersection itself.
1598 if curs.hash() != from {
1599 out.push(curs.clone());
1600 }
1601 Ok(false) // never stop early; walk to head (no more children)
1602 },
1603 None,
1604 None,
1605 )?;
1606 Ok(out)
1607 }
1608}
1609
1610/// Find where two chains meet (Go `computeSyncIntersection`). See [`Intersection`].
1611fn compute_sync_intersection(
1612 storage: &dyn AumStore,
1613 local_offer: &SyncOffer,
1614 remote_offer: &SyncOffer,
1615) -> Result<Intersection, TkaError> {
1616 // Simple case: identical heads → up to date.
1617 if remote_offer.head == local_offer.head {
1618 return Ok(Intersection {
1619 up_to_date: true,
1620 head_intersection: Some(local_offer.head),
1621 tail_intersection: None,
1622 });
1623 }
1624
1625 // Head intersection: if we hold the remote's head, walk back from our head looking for it. If
1626 // found, their head is an ancestor of ours and we have the AUMs that build on it.
1627 if storage.aum(&remote_offer.head).is_some() {
1628 let mut curs = local_offer.head;
1629 for _ in 0..MAX_SYNC_HEAD_INTERSECTION_ITER {
1630 let Some(parent) = storage.aum(&curs) else {
1631 break; // os.ErrNotExist
1632 };
1633 if parent.hash() == remote_offer.head {
1634 return Ok(Intersection {
1635 up_to_date: false,
1636 head_intersection: Some(parent.hash()),
1637 tail_intersection: None,
1638 });
1639 }
1640 match parent.prev_aum_hash {
1641 Some(prev) => curs = prev,
1642 None => break,
1643 }
1644 }
1645 }
1646
1647 // Tail intersection: we don't recognise their head, but if one of the ancestors they offered is
1648 // on our chain, that's a starting point. Iterate in their order (newest-first) so we pick the
1649 // most-recent shared ancestor and send the fewest AUMs.
1650 for ancestor in &remote_offer.ancestors {
1651 let state = match compute_state_at(storage, MAX_SYNC_ITER, *ancestor)? {
1652 Some(s) => s,
1653 None => continue, // os.ErrNotExist: we don't have this ancestor; try the next
1654 };
1655 let (end, _) = fast_forward(
1656 storage,
1657 MAX_SYNC_ITER,
1658 state,
1659 &mut |_: &Aum, _: &mut ReplayState| Ok(false),
1660 None,
1661 Some(local_offer.head),
1662 )?;
1663 // fast_forward can stop early (no more children) before reaching the target, so re-check.
1664 if end.hash() == local_offer.head {
1665 return Ok(Intersection {
1666 up_to_date: false,
1667 head_intersection: None,
1668 tail_intersection: Some(*ancestor),
1669 });
1670 }
1671 }
1672
1673 Err(TkaError::BadChain) // ErrNoIntersection
1674}
1675
1676/// Verify a standard (RFC 8032, non-cofactored) Ed25519 signature.
1677fn verify_ed25519_std(public: &[u8], msg: &[u8], sig: &[u8]) -> Result<(), TkaError> {
1678 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
1679 let pk: [u8; 32] = public
1680 .try_into()
1681 .map_err(|_| TkaError::Decode("bad pubkey len"))?;
1682 let vk = VerifyingKey::from_bytes(&pk).map_err(|_| TkaError::Decode("bad ed25519 pubkey"))?;
1683 let sig: [u8; 64] = sig
1684 .try_into()
1685 .map_err(|_| TkaError::Decode("bad sig len"))?;
1686 vk.verify(msg, &Signature::from_bytes(&sig))
1687 .map_err(|_| TkaError::BadSignature)
1688}
1689
1690/// Verify a ZIP-215 (cofactored) Ed25519 signature, matching Go `ed25519consensus.Verify`.
1691fn verify_ed25519_zip215(public: &[u8], msg: &[u8], sig: &[u8]) -> Result<(), TkaError> {
1692 let pk: [u8; 32] = public
1693 .try_into()
1694 .map_err(|_| TkaError::Decode("bad pubkey len"))?;
1695 let vk = ed25519_zebra::VerificationKey::try_from(pk)
1696 .map_err(|_| TkaError::Decode("bad ed25519 pubkey"))?;
1697 let sig_bytes: [u8; 64] = sig
1698 .try_into()
1699 .map_err(|_| TkaError::Decode("bad sig len"))?;
1700 let sig = ed25519_zebra::Signature::from(sig_bytes);
1701 vk.verify(&sig, msg).map_err(|_| TkaError::BadSignature)
1702}
1703
1704/// Decode a [`NodeKeySignature`] from canonical CBOR. This is a minimal decoder for the exact map
1705/// shape Go emits (integer keys 1..=6); anything else is rejected (fail-closed).
1706fn decode_node_key_signature(buf: &[u8]) -> Result<NodeKeySignature, TkaError> {
1707 let (val, rest) = decode_value(buf, 0)?;
1708 if !rest.is_empty() {
1709 return Err(TkaError::Decode("trailing bytes after signature"));
1710 }
1711 node_key_signature_from_value(val, 0)
1712}
1713
1714fn node_key_signature_from_value(val: Value, depth: usize) -> Result<NodeKeySignature, TkaError> {
1715 if depth > MAX_SIG_NESTING_DEPTH {
1716 return Err(TkaError::Decode("nested signature too deep"));
1717 }
1718 let Value::IntMap(entries) = val else {
1719 return Err(TkaError::Decode("signature is not an int-keyed map"));
1720 };
1721 let mut sig_kind = None;
1722 let mut pubkey = Vec::new();
1723 let mut key_id = Vec::new();
1724 let mut signature = Vec::new();
1725 let mut nested = None;
1726 let mut wrapping_pubkey = Vec::new();
1727
1728 for (k, v) in entries {
1729 match k {
1730 1 => {
1731 let Value::Uint(n) = v else {
1732 return Err(TkaError::Decode("sig kind not uint"));
1733 };
1734 sig_kind = Some(
1735 SigKind::from_u8(
1736 u8::try_from(n).map_err(|_| TkaError::Decode("sig kind range"))?,
1737 )
1738 .ok_or(TkaError::Decode("unknown sig kind"))?,
1739 );
1740 }
1741 2 => pubkey = expect_bytes(v)?,
1742 3 => key_id = expect_bytes(v)?,
1743 4 => signature = expect_bytes(v)?,
1744 5 => {
1745 nested = Some(alloc::boxed::Box::new(node_key_signature_from_value(
1746 v,
1747 depth + 1,
1748 )?))
1749 }
1750 6 => wrapping_pubkey = expect_bytes(v)?,
1751 _ => return Err(TkaError::Decode("unknown signature field")),
1752 }
1753 }
1754
1755 Ok(NodeKeySignature {
1756 sig_kind: sig_kind.ok_or(TkaError::Decode("signature missing kind"))?,
1757 pubkey,
1758 key_id,
1759 signature,
1760 nested,
1761 wrapping_pubkey,
1762 })
1763}
1764
1765fn expect_bytes(v: Value) -> Result<Vec<u8>, TkaError> {
1766 match v {
1767 Value::Bytes(b) => Ok(b),
1768 _ => Err(TkaError::Decode("expected byte string")),
1769 }
1770}
1771
1772/// A byte string, or an empty `Vec` for CBOR `null` — the decode inverse of [`bytes_or_null`]. Go's
1773/// `fxamacker/cbor` encodes a nil *non*-`omitempty` `[]byte` field as CBOR null (`0xf6`); on decode
1774/// that round-trips back to an empty `Vec` (the field's zero value). Any other CBOR type is rejected.
1775fn expect_bytes_or_null(v: Value) -> Result<Vec<u8>, TkaError> {
1776 match v {
1777 Value::Bytes(b) => Ok(b),
1778 Value::Null => Ok(Vec::new()),
1779 _ => Err(TkaError::Decode("expected byte string or null")),
1780 }
1781}
1782
1783fn expect_uint(v: Value) -> Result<u64, TkaError> {
1784 match v {
1785 Value::Uint(n) => Ok(n),
1786 _ => Err(TkaError::Decode("expected unsigned integer")),
1787 }
1788}
1789
1790/// Decode a `map[string]string` (`Meta`) from a [`Value::TextMap`]. Values must be text strings.
1791fn meta_from_value(
1792 v: Value,
1793) -> Result<Vec<(alloc::string::String, alloc::string::String)>, TkaError> {
1794 let Value::TextMap(entries) = v else {
1795 return Err(TkaError::Decode("meta is not a text-keyed map"));
1796 };
1797 let mut out = Vec::with_capacity(entries.len());
1798 for (k, val) in entries {
1799 let Value::Text(vbytes) = val else {
1800 return Err(TkaError::Decode("meta value not text"));
1801 };
1802 let key = alloc::string::String::from_utf8(k)
1803 .map_err(|_| TkaError::Decode("meta key not utf-8"))?;
1804 let value = alloc::string::String::from_utf8(vbytes)
1805 .map_err(|_| TkaError::Decode("meta value not utf-8"))?;
1806 out.push((key, value));
1807 }
1808 Ok(out)
1809}
1810
1811/// Decode a 32-byte [`AumHash`] from a CBOR byte string of exactly 32 bytes.
1812fn aum_hash_from_bytes(b: Vec<u8>) -> Result<AumHash, TkaError> {
1813 let arr: [u8; AUM_HASH_LEN] = b
1814 .try_into()
1815 .map_err(|_| TkaError::Decode("AUM hash not 32 bytes"))?;
1816 Ok(AumHash(arr))
1817}
1818
1819impl AumKey {
1820 /// Decode an [`AumKey`] from its CBOR value (Go `tka.Key`; keymap `kind`=1, `votes`=2,
1821 /// `public`=3, `meta`=12). The inverse of [`AumKey::to_cbor`]. Only `Key25519` (kind `1`) is
1822 /// supported (the sole [`KeyKind`] this fork models); any other kind is rejected (fail-closed).
1823 fn from_value(v: Value) -> Result<AumKey, TkaError> {
1824 let Value::IntMap(entries) = v else {
1825 return Err(TkaError::Decode("key is not an int-keyed map"));
1826 };
1827 let mut kind = None;
1828 let mut votes = None;
1829 let mut public = None;
1830 let mut meta = Vec::new();
1831 for (k, val) in entries {
1832 match k {
1833 1 => {
1834 kind = Some(match expect_uint(val)? {
1835 1 => KeyKind::Ed25519,
1836 _ => return Err(TkaError::Decode("unsupported key kind")),
1837 })
1838 }
1839 2 => {
1840 votes = Some(
1841 u32::try_from(expect_uint(val)?)
1842 .map_err(|_| TkaError::Decode("key votes out of range"))?,
1843 )
1844 }
1845 3 => public = Some(expect_bytes_or_null(val)?),
1846 12 => meta = meta_from_value(val)?,
1847 _ => return Err(TkaError::Decode("unknown key field")),
1848 }
1849 }
1850 Ok(AumKey {
1851 kind: kind.ok_or(TkaError::Decode("key missing kind"))?,
1852 votes: votes.ok_or(TkaError::Decode("key missing votes"))?,
1853 public: public.ok_or(TkaError::Decode("key missing public"))?,
1854 meta,
1855 })
1856 }
1857}
1858
1859impl AumState {
1860 /// Decode an [`AumState`] from its CBOR value (Go `tka.State`; keymap `last_aum_hash`=1,
1861 /// `disablement_values`=2, `keys`=3, `state_id1`=4, `state_id2`=5). The inverse of
1862 /// [`AumState::to_cbor`]: keys 1/2/3 are non-`omitempty`, so a nil one arrives as CBOR null
1863 /// (`None`); a present array arrives as `Some(vec)` (possibly empty). Keys 4/5 are `omitempty`,
1864 /// defaulting to 0 when absent.
1865 fn from_value(v: Value) -> Result<AumState, TkaError> {
1866 let Value::IntMap(entries) = v else {
1867 return Err(TkaError::Decode("state is not an int-keyed map"));
1868 };
1869 let mut state = AumState::default();
1870 for (k, val) in entries {
1871 match k {
1872 1 => {
1873 state.last_aum_hash = match val {
1874 Value::Null => None,
1875 Value::Bytes(b) => Some(aum_hash_from_bytes(b)?),
1876 _ => return Err(TkaError::Decode("last_aum_hash not bytes or null")),
1877 }
1878 }
1879 2 => {
1880 state.disablement_values = match val {
1881 Value::Null => None,
1882 Value::Array(items) => Some(
1883 items
1884 .into_iter()
1885 .map(expect_bytes)
1886 .collect::<Result<Vec<_>, _>>()?,
1887 ),
1888 _ => return Err(TkaError::Decode("disablement_values not array or null")),
1889 }
1890 }
1891 3 => {
1892 state.keys = match val {
1893 Value::Null => None,
1894 Value::Array(items) => Some(
1895 items
1896 .into_iter()
1897 .map(AumKey::from_value)
1898 .collect::<Result<Vec<_>, _>>()?,
1899 ),
1900 _ => return Err(TkaError::Decode("state keys not array or null")),
1901 }
1902 }
1903 4 => state.state_id1 = expect_uint(val)?,
1904 5 => state.state_id2 = expect_uint(val)?,
1905 _ => return Err(TkaError::Decode("unknown state field")),
1906 }
1907 }
1908 Ok(state)
1909 }
1910}
1911
1912impl AumSignature {
1913 /// Decode an [`AumSignature`] from its CBOR value (Go `tkatype.Signature`; keymap `key_id`=1,
1914 /// `signature`=2, both non-`omitempty` → a nil one is CBOR null → empty `Vec`).
1915 fn from_value(v: Value) -> Result<AumSignature, TkaError> {
1916 let Value::IntMap(entries) = v else {
1917 return Err(TkaError::Decode("signature is not an int-keyed map"));
1918 };
1919 let mut key_id = None;
1920 let mut signature = None;
1921 for (k, val) in entries {
1922 match k {
1923 1 => key_id = Some(expect_bytes_or_null(val)?),
1924 2 => signature = Some(expect_bytes_or_null(val)?),
1925 _ => return Err(TkaError::Decode("unknown AUM signature field")),
1926 }
1927 }
1928 Ok(AumSignature {
1929 key_id: key_id.ok_or(TkaError::Decode("AUM signature missing key_id"))?,
1930 signature: signature.ok_or(TkaError::Decode("AUM signature missing signature"))?,
1931 })
1932 }
1933}
1934
1935impl Aum {
1936 /// Decode an [`Aum`] from its canonical CBOR serialization (the inverse of [`Aum::serialize`] /
1937 /// [`Aum::to_cbor`]). This is the acquisition primitive a sync/bootstrap path uses to turn the
1938 /// raw `MarshaledAUM` bytes control sends into an [`Aum`] before it is verified and replayed
1939 /// (Go `tka.AUM` CBOR unmarshal).
1940 ///
1941 /// The keymap (Go `cbor:"…,keyasint"`): `message_kind`=1 and `prev_aum_hash`=2 are
1942 /// non-`omitempty` (a nil prev arrives as CBOR null → `None`); `key`=3, `key_id`=4, `state`=5,
1943 /// `votes`=6, `meta`=7, `signatures`=23 are `omitempty` (absent ⇒ the field's zero value).
1944 ///
1945 /// Fail-closed: a trailing byte after the AUM, an unknown field key, a wrong value type, an
1946 /// unknown `message_kind`, or any malformed CBOR head all return [`TkaError::Decode`]. The
1947 /// decoder does NOT re-canonicalize or validate chain structure — that is the verifier's job; it
1948 /// only reconstructs the struct the bytes describe.
1949 ///
1950 /// # Errors
1951 ///
1952 /// Returns [`TkaError::Decode`] if `buf` is not exactly one canonical-shaped AUM CBOR map.
1953 pub fn from_cbor(buf: &[u8]) -> Result<Aum, TkaError> {
1954 let (val, rest) = decode_value(buf, 0)?;
1955 if !rest.is_empty() {
1956 return Err(TkaError::Decode("trailing bytes after AUM"));
1957 }
1958 let Value::IntMap(entries) = val else {
1959 return Err(TkaError::Decode("AUM is not an int-keyed map"));
1960 };
1961 let mut message_kind = None;
1962 let mut prev_aum_hash = None;
1963 let mut have_prev = false;
1964 let mut key = None;
1965 let mut key_id = Vec::new();
1966 let mut state = None;
1967 let mut votes = None;
1968 let mut meta = Vec::new();
1969 let mut signatures = Vec::new();
1970 for (k, v) in entries {
1971 match k {
1972 1 => {
1973 message_kind = Some(
1974 AumKind::from_u8(
1975 u8::try_from(expect_uint(v)?)
1976 .map_err(|_| TkaError::Decode("message kind out of range"))?,
1977 )
1978 .ok_or(TkaError::Decode("unknown AUM message kind"))?,
1979 )
1980 }
1981 2 => {
1982 have_prev = true;
1983 prev_aum_hash = match v {
1984 Value::Null => None,
1985 Value::Bytes(b) => Some(aum_hash_from_bytes(b)?),
1986 _ => return Err(TkaError::Decode("prev_aum_hash not bytes or null")),
1987 }
1988 }
1989 3 => key = Some(AumKey::from_value(v)?),
1990 4 => key_id = expect_bytes_or_null(v)?,
1991 5 => state = Some(AumState::from_value(v)?),
1992 6 => {
1993 votes = Some(
1994 u32::try_from(expect_uint(v)?)
1995 .map_err(|_| TkaError::Decode("votes out of range"))?,
1996 )
1997 }
1998 7 => meta = meta_from_value(v)?,
1999 23 => {
2000 let Value::Array(items) = v else {
2001 return Err(TkaError::Decode("signatures not an array"));
2002 };
2003 signatures = items
2004 .into_iter()
2005 .map(AumSignature::from_value)
2006 .collect::<Result<Vec<_>, _>>()?;
2007 }
2008 _ => return Err(TkaError::Decode("unknown AUM field")),
2009 }
2010 }
2011 // `message_kind` (1) and `prev_aum_hash` (2) are non-`omitempty`: both keys must be present
2012 // on the wire (the prev *value* may be null, but the key itself is always emitted).
2013 if !have_prev {
2014 return Err(TkaError::Decode("AUM missing prev_aum_hash"));
2015 }
2016 Ok(Aum {
2017 message_kind: message_kind.ok_or(TkaError::Decode("AUM missing message kind"))?,
2018 prev_aum_hash,
2019 key,
2020 key_id,
2021 state,
2022 votes,
2023 meta,
2024 signatures,
2025 })
2026 }
2027}
2028
2029/// Decode one CBOR value (the subset the encoder produces) from `buf`, returning the value and the
2030/// remaining bytes. Minimal — only the major types TKA uses.
2031fn decode_value(buf: &[u8], depth: usize) -> Result<(Value, &[u8]), TkaError> {
2032 // Bound generic CBOR container nesting so a deeply-nested array/map (even a non-signature one,
2033 // e.g. an AUM with nested arrays) cannot overflow the recursive decoder before shape validation
2034 // runs. Shared by the AUM and node-key-signature paths, so the message is kept neutral (the
2035 // signature-specific depth guard with its own message lives in `node_key_signature_from_value`).
2036 if depth > MAX_SIG_NESTING_DEPTH {
2037 return Err(TkaError::Decode("CBOR nesting too deep"));
2038 }
2039 let (major, arg, rest) = decode_head(buf)?;
2040 match major {
2041 0 => Ok((Value::Uint(arg), rest)),
2042 2 => {
2043 // `usize::try_from` rather than `as usize`: on a 32-bit target a `u64` length above
2044 // `usize::MAX` must fail closed, not silently truncate to a smaller in-bounds length.
2045 let len = usize::try_from(arg).map_err(|_| TkaError::Decode("byte string too long"))?;
2046 if rest.len() < len {
2047 return Err(TkaError::Decode("byte string truncated"));
2048 }
2049 Ok((Value::Bytes(rest[..len].to_vec()), &rest[len..]))
2050 }
2051 3 => {
2052 let len = usize::try_from(arg).map_err(|_| TkaError::Decode("text string too long"))?;
2053 if rest.len() < len {
2054 return Err(TkaError::Decode("text string truncated"));
2055 }
2056 Ok((Value::Text(rest[..len].to_vec()), &rest[len..]))
2057 }
2058 4 => {
2059 let mut items = Vec::new();
2060 let mut cur = rest;
2061 for _ in 0..arg {
2062 let (v, next) = decode_value(cur, depth + 1)?;
2063 items.push(v);
2064 cur = next;
2065 }
2066 Ok((Value::Array(items), cur))
2067 }
2068 5 => {
2069 // A CBOR map decodes to either an `IntMap` (unsigned-integer keys — the `keyasint`
2070 // structs: AUM, Key, State, signatures) or a `TextMap` (text-string keys — Go
2071 // `map[string]string` `Meta` fields). The variant is chosen by the FIRST key's major
2072 // type and every key must match it: TKA never emits a mixed-key map, so a key whose
2073 // type differs from the first is rejected (fail-closed). An empty map decodes to an
2074 // empty `IntMap` (matching the prior behavior; an empty `map[string]string` is
2075 // `omitempty`-dropped by Go, so an empty map on the wire is always a struct).
2076 decode_map(rest, arg, depth)
2077 }
2078 // Major type 7: only the `null` simple value (`0xf6`, argument 22) is accepted. Go's
2079 // `fxamacker/cbor` emits CBOR null for a nil *non*-`omitempty` byte/slice/pointer field —
2080 // an AUM's genesis `prev_aum_hash`, `AumSignature.{key_id,signature}`, and an `AumState`'s
2081 // `last_aum_hash`/`disablement_values`/`keys` (see the encoder's `Value::Null` arm). Any
2082 // other major-7 simple value or float (booleans, undefined, `f16`/`f32`/`f64`) is rejected:
2083 // TKA never emits them, so accepting them would only widen the attack surface. The
2084 // `NodeKeySignature` path is unaffected — its `expect_bytes` rejects `Value::Null`, so a
2085 // null where bytes are required still fails closed there.
2086 7 if arg == 22 => Ok((Value::Null, rest)),
2087 // Major types 1 (negative int) and 6 (tag), and any other major-7 value, are unsupported.
2088 _ => Err(TkaError::Decode("unsupported CBOR major type")),
2089 }
2090}
2091
2092/// Decode the `count` key/value pairs of a CBOR map (major type 5) from `buf`, producing either a
2093/// [`Value::IntMap`] (unsigned-integer keys) or a [`Value::TextMap`] (text-string keys). The key
2094/// type is fixed by the first pair; every subsequent key must use the same major type (TKA emits no
2095/// mixed-key maps). An empty map decodes to an empty `IntMap`. Duplicate keys are rejected
2096/// (fail-closed), matching the CTAP2 / Go "no duplicate map keys" rule. Map *key ordering is not
2097/// enforced* on decode: the verify path re-serializes canonically before hashing, so a non-canonical
2098/// input simply produces a different (still self-consistent) struct, never a hash that silently
2099/// matches Go's for different bytes.
2100fn decode_map(buf: &[u8], count: u64, depth: usize) -> Result<(Value, &[u8]), TkaError> {
2101 if count == 0 {
2102 return Ok((Value::IntMap(Vec::new()), buf));
2103 }
2104 // Peek the first key's major type to pick the map variant.
2105 let (first_major, ..) = decode_head(buf)?;
2106 match first_major {
2107 0 => {
2108 let mut entries: Vec<(u64, Value)> = Vec::new();
2109 let mut cur = buf;
2110 for _ in 0..count {
2111 let (k, next) = decode_head(cur).and_then(|(m, a, r)| {
2112 if m == 0 {
2113 Ok((a, r))
2114 } else {
2115 Err(TkaError::Decode("mixed map key types"))
2116 }
2117 })?;
2118 let (v, next2) = decode_value(next, depth + 1)?;
2119 entries.push((k, v));
2120 cur = next2;
2121 }
2122 reject_duplicate_keys(entries.iter().map(|(k, _)| *k))?;
2123 Ok((Value::IntMap(entries), cur))
2124 }
2125 3 => {
2126 let mut entries: Vec<(Vec<u8>, Value)> = Vec::new();
2127 let mut cur = buf;
2128 for _ in 0..count {
2129 // Decode the text-string key via the shared value decoder so its length/truncation
2130 // checks apply uniformly, then require it to be `Value::Text`.
2131 let (key_val, next) = decode_value(cur, depth + 1)?;
2132 let Value::Text(k) = key_val else {
2133 return Err(TkaError::Decode("mixed map key types"));
2134 };
2135 let (v, next2) = decode_value(next, depth + 1)?;
2136 entries.push((k, v));
2137 cur = next2;
2138 }
2139 reject_duplicate_keys(entries.iter().map(|(k, _)| k.clone()))?;
2140 Ok((Value::TextMap(entries), cur))
2141 }
2142 _ => Err(TkaError::Decode("map key not uint or text string")),
2143 }
2144}
2145
2146/// Reject a CBOR map with duplicate keys (CTAP2 / Go forbid them) in `O(n log n)` via a sort, rather
2147/// than the `O(n²)` per-insert linear scan a naive decoder uses. The map element count is
2148/// attacker-controlled (a CBOR head can claim a large count), so the quadratic form is a latent
2149/// super-linear CPU-DoS on a hostile control-plane blob; the sort keeps it linear-ish. Insertion
2150/// order of the map itself is preserved by the caller (the verify path re-serializes canonically
2151/// before hashing, so wire order never reaches a hash).
2152fn reject_duplicate_keys<K: Ord>(keys: impl Iterator<Item = K>) -> Result<(), TkaError> {
2153 let mut ks: Vec<K> = keys.collect();
2154 ks.sort_unstable();
2155 if ks.windows(2).any(|w| w[0] == w[1]) {
2156 return Err(TkaError::Decode("duplicate map key"));
2157 }
2158 Ok(())
2159}
2160
2161/// Decode a CBOR head: returns `(major, argument, rest)`.
2162fn decode_head(buf: &[u8]) -> Result<(u8, u64, &[u8]), TkaError> {
2163 let first = *buf.first().ok_or(TkaError::Decode("empty CBOR"))?;
2164 let major = first >> 5;
2165 let info = first & 0x1f;
2166 let rest = &buf[1..];
2167 let (arg, rest) = match info {
2168 n @ 0..=23 => (n as u64, rest),
2169 24 => {
2170 let b = *rest.first().ok_or(TkaError::Decode("truncated u8"))?;
2171 (b as u64, &rest[1..])
2172 }
2173 25 => {
2174 if rest.len() < 2 {
2175 return Err(TkaError::Decode("truncated u16"));
2176 }
2177 (u16::from_be_bytes([rest[0], rest[1]]) as u64, &rest[2..])
2178 }
2179 26 => {
2180 if rest.len() < 4 {
2181 return Err(TkaError::Decode("truncated u32"));
2182 }
2183 (
2184 u32::from_be_bytes([rest[0], rest[1], rest[2], rest[3]]) as u64,
2185 &rest[4..],
2186 )
2187 }
2188 27 => {
2189 if rest.len() < 8 {
2190 return Err(TkaError::Decode("truncated u64"));
2191 }
2192 let mut b = [0u8; 8];
2193 b.copy_from_slice(&rest[..8]);
2194 (u64::from_be_bytes(b), &rest[8..])
2195 }
2196 _ => return Err(TkaError::Decode("indefinite/reserved CBOR length")),
2197 };
2198 Ok((major, arg, rest))
2199}
2200
2201// ----- RFC 4648 base32 (standard alphabet, no padding) -----
2202
2203const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
2204
2205fn base32_encode_nopad(data: &[u8]) -> String {
2206 let mut out = String::new();
2207 let mut buffer: u32 = 0;
2208 let mut bits: u32 = 0;
2209 for &b in data {
2210 buffer = (buffer << 8) | b as u32;
2211 bits += 8;
2212 while bits >= 5 {
2213 bits -= 5;
2214 let idx = ((buffer >> bits) & 0x1f) as usize;
2215 out.push(BASE32_ALPHABET[idx] as char);
2216 }
2217 }
2218 if bits > 0 {
2219 let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
2220 out.push(BASE32_ALPHABET[idx] as char);
2221 }
2222 out
2223}
2224
2225fn base32_decode_nopad(text: &str) -> Option<Vec<u8>> {
2226 let mut buffer: u32 = 0;
2227 let mut bits: u32 = 0;
2228 let mut out = Vec::new();
2229 for c in text.chars() {
2230 let val = match c {
2231 'A'..='Z' => c as u32 - 'A' as u32,
2232 '2'..='7' => c as u32 - '2' as u32 + 26,
2233 _ => return None,
2234 };
2235 buffer = (buffer << 5) | val;
2236 bits += 5;
2237 if bits >= 8 {
2238 bits -= 8;
2239 out.push((buffer >> bits) as u8);
2240 }
2241 }
2242 Some(out)
2243}
2244
2245#[cfg(test)]
2246mod tests {
2247 use super::*;
2248
2249 #[test]
2250 fn base32_roundtrip_32_bytes() {
2251 let h = AumHash([0xABu8; 32]);
2252 let text = h.to_base32();
2253 let back = AumHash::from_base32(&text).unwrap();
2254 assert_eq!(h, back);
2255 }
2256
2257 #[test]
2258 fn base32_rejects_wrong_length() {
2259 // "AAAA" decodes to fewer than 32 bytes.
2260 assert!(AumHash::from_base32("AAAA").is_none());
2261 // Lowercase / invalid alphabet rejected.
2262 assert!(AumHash::from_base32("aaaa").is_none());
2263 }
2264
2265 #[test]
2266 fn base32_matches_known_vector() {
2267 // RFC 4648 base32 of "foobar" is "MZXW6YTBOI" (with padding "======"); no-pad drops the pad.
2268 assert_eq!(base32_encode_nopad(b"foobar"), "MZXW6YTBOI");
2269 assert_eq!(base32_decode_nopad("MZXW6YTBOI").unwrap(), b"foobar");
2270 }
2271
2272 #[test]
2273 fn credential_signature_cannot_authorize() {
2274 let auth = Authority::from_state(AumHash([0; 32]), State::default());
2275 let sig = NodeKeySignature {
2276 sig_kind: SigKind::Credential,
2277 pubkey: alloc::vec![1, 2, 3],
2278 key_id: alloc::vec![4, 5, 6],
2279 signature: alloc::vec![0; 64],
2280 nested: None,
2281 wrapping_pubkey: Vec::new(),
2282 };
2283 let cbor = sig.to_cbor(true).to_vec();
2284 let err = auth.node_key_authorized(&[1, 2, 3], &cbor).unwrap_err();
2285 assert_eq!(err, TkaError::CredentialCannotAuthorize);
2286 }
2287
2288 #[test]
2289 fn untrusted_key_denied() {
2290 // A direct signature whose key id is not in the (empty) trusted state.
2291 let auth = Authority::from_state(AumHash([0; 32]), State::default());
2292 let sig = NodeKeySignature {
2293 sig_kind: SigKind::Direct,
2294 pubkey: alloc::vec![9; 32],
2295 key_id: alloc::vec![7; 32],
2296 signature: alloc::vec![0; 64],
2297 nested: None,
2298 wrapping_pubkey: Vec::new(),
2299 };
2300 let cbor = sig.to_cbor(true).to_vec();
2301 let err = auth.node_key_authorized(&[9; 32], &cbor).unwrap_err();
2302 assert_eq!(err, TkaError::UntrustedKey);
2303 }
2304
2305 #[test]
2306 fn direct_signature_verifies_end_to_end() {
2307 use ed25519_dalek::{Signer, SigningKey};
2308
2309 // A trusted Ed25519 key signs a node key directly.
2310 let signing = SigningKey::from_bytes(&[42u8; 32]);
2311 let trusted_pub = signing.verifying_key().to_bytes().to_vec();
2312 let node_key = alloc::vec![7u8; 32];
2313
2314 let trusted = Key {
2315 kind: KeyKind::Ed25519,
2316 votes: 1,
2317 public: trusted_pub.clone(),
2318 };
2319 let auth = Authority::from_state(
2320 AumHash([0; 32]),
2321 State {
2322 keys: alloc::vec![trusted],
2323 },
2324 );
2325
2326 // Build the signature, compute its sig-hash preimage, sign, then fill in the signature.
2327 let mut sig = NodeKeySignature {
2328 sig_kind: SigKind::Direct,
2329 pubkey: node_key.clone(),
2330 key_id: trusted_pub.clone(),
2331 signature: Vec::new(),
2332 nested: None,
2333 wrapping_pubkey: Vec::new(),
2334 };
2335 let sig_hash = sig.sig_hash();
2336 // NOTE: Go verifies Direct with ZIP-215; a standard ed25519-dalek signature is accepted by
2337 // ZIP-215 verification (ZIP-215 is a superset), so signing with dalek here is valid.
2338 sig.signature = signing.sign(&sig_hash).to_bytes().to_vec();
2339
2340 let cbor = sig.to_cbor(true).to_vec();
2341 assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
2342
2343 // A different node key must NOT be authorized by this signature.
2344 let other = alloc::vec![8u8; 32];
2345 assert_eq!(
2346 auth.node_key_authorized(&other, &cbor).unwrap_err(),
2347 TkaError::NodeKeyMismatch
2348 );
2349 }
2350
2351 #[test]
2352 fn tampered_signature_denied() {
2353 use ed25519_dalek::{Signer, SigningKey};
2354
2355 let signing = SigningKey::from_bytes(&[42u8; 32]);
2356 let trusted_pub = signing.verifying_key().to_bytes().to_vec();
2357 let node_key = alloc::vec![7u8; 32];
2358 let auth = Authority::from_state(
2359 AumHash([0; 32]),
2360 State {
2361 keys: alloc::vec![Key {
2362 kind: KeyKind::Ed25519,
2363 votes: 1,
2364 public: trusted_pub.clone(),
2365 }],
2366 },
2367 );
2368 let mut sig = NodeKeySignature {
2369 sig_kind: SigKind::Direct,
2370 pubkey: node_key.clone(),
2371 key_id: trusted_pub,
2372 signature: Vec::new(),
2373 nested: None,
2374 wrapping_pubkey: Vec::new(),
2375 };
2376 let sig_hash = sig.sig_hash();
2377 let mut sigbytes = signing.sign(&sig_hash).to_bytes();
2378 sigbytes[0] ^= 0xff; // tamper
2379 sig.signature = sigbytes.to_vec();
2380
2381 let cbor = sig.to_cbor(true).to_vec();
2382 assert_eq!(
2383 auth.node_key_authorized(&node_key, &cbor).unwrap_err(),
2384 TkaError::BadSignature
2385 );
2386 }
2387
2388 #[test]
2389 fn head_matches_check() {
2390 let h = AumHash([5u8; 32]);
2391 let auth = Authority::from_state(h, State::default());
2392 assert!(auth.head_matches(&h));
2393 assert!(!auth.head_matches(&AumHash([6u8; 32])));
2394 }
2395
2396 // ----- Fix 1: depth cap on attacker-controlled nesting -----
2397
2398 #[test]
2399 fn deeply_nested_signature_rejected_without_overflow() {
2400 // Wrap a NodeKeySignature inside `nested` far past MAX_SIG_NESTING_DEPTH. This is cheap to
2401 // construct (a few bytes per level) and would overflow an unbounded recursive decoder. The
2402 // decoder must reject it as a Decode error — never panic / stack-overflow.
2403 let mut sig = NodeKeySignature {
2404 sig_kind: SigKind::Direct,
2405 pubkey: alloc::vec![1u8; 32],
2406 key_id: alloc::vec![2u8; 32],
2407 signature: alloc::vec![3u8; 64],
2408 nested: None,
2409 wrapping_pubkey: Vec::new(),
2410 };
2411 for _ in 0..(MAX_SIG_NESTING_DEPTH + 8) {
2412 sig = NodeKeySignature {
2413 sig_kind: SigKind::Rotation,
2414 pubkey: alloc::vec![1u8; 32],
2415 key_id: Vec::new(),
2416 signature: alloc::vec![3u8; 64],
2417 nested: Some(alloc::boxed::Box::new(sig)),
2418 wrapping_pubkey: alloc::vec![1u8; 32],
2419 };
2420 }
2421 let cbor = sig.to_cbor(true).to_vec();
2422 let err = decode_node_key_signature(&cbor).unwrap_err();
2423 // The shared generic-container depth guard in `decode_value` trips first (the CBOR is
2424 // nested past the cap before the signature-shape walk runs), so the neutral message.
2425 assert_eq!(err, TkaError::Decode("CBOR nesting too deep"));
2426 }
2427
2428 // ----- Fix 5: duplicate CBOR map keys rejected -----
2429
2430 #[test]
2431 fn duplicate_map_key_rejected() {
2432 // Hand-craft a CBOR map with key 1 repeated: map(2) { 1:0, 1:1 } => 0xa2 01 00 01 01.
2433 let blob = [0xa2u8, 0x01, 0x00, 0x01, 0x01];
2434 let err = decode_node_key_signature(&blob).unwrap_err();
2435 assert_eq!(err, TkaError::Decode("duplicate map key"));
2436 }
2437
2438 // ----- Fix 3: rotation-chain happy path + ZIP-215/std split -----
2439
2440 // ZIP-215 vs standard ed25519 in TKA, and why this crate carries BOTH verifiers:
2441 //
2442 // * Direct/Credential signatures are verified with `verify_ed25519_zip215` (ed25519-zebra),
2443 // matching Go `ed25519consensus.Verify` — the *cofactored* ZIP-215 rule the TKA leaf
2444 // signatures are produced under. ZIP-215 is a strict superset of RFC 8032: any standard
2445 // (dalek) signature is accepted by it, which is why the tests below can sign leaves with
2446 // dalek and still verify under zebra.
2447 // * The outer rotation WRAP signature is verified with `verify_ed25519_std` (ed25519-dalek),
2448 // matching Go's plain `ed25519.Verify` for the rotation wrap. Collapsing these two
2449 // verifiers into one would silently diverge from Go on the wire — hence both deps are kept
2450 // (see Cargo.toml comment).
2451 #[test]
2452 fn rotation_chain_verifies_end_to_end() {
2453 use ed25519_dalek::{Signer, SigningKey};
2454
2455 // Trusted key signs the inner Direct over the wrapping (pivot) pubkey.
2456 let trusted = SigningKey::from_bytes(&[7u8; 32]);
2457 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
2458
2459 // The rotation pivot: a fresh keypair whose public key the inner Direct authorizes and
2460 // whose private key signs the outer rotation wrap.
2461 let wrapping = SigningKey::from_bytes(&[9u8; 32]);
2462 let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
2463
2464 let node_key = alloc::vec![5u8; 32];
2465
2466 let auth = Authority::from_state(
2467 AumHash([0; 32]),
2468 State {
2469 keys: alloc::vec![Key {
2470 kind: KeyKind::Ed25519,
2471 votes: 1,
2472 public: trusted_pub.clone(),
2473 }],
2474 },
2475 );
2476
2477 // Inner Direct: trusted key authorizes the wrapping pubkey. Verified ZIP-215, so a dalek
2478 // signature is accepted.
2479 let mut inner = NodeKeySignature {
2480 sig_kind: SigKind::Direct,
2481 pubkey: wrapping_pub.clone(),
2482 key_id: trusted_pub.clone(),
2483 signature: Vec::new(),
2484 nested: None,
2485 wrapping_pubkey: wrapping_pub.clone(),
2486 };
2487 let inner_hash = inner.sig_hash();
2488 inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
2489
2490 // Outer Rotation: signs the node key with the wrapping key (verified STANDARD ed25519).
2491 let mut outer = NodeKeySignature {
2492 sig_kind: SigKind::Rotation,
2493 pubkey: node_key.clone(),
2494 key_id: Vec::new(),
2495 signature: Vec::new(),
2496 nested: Some(alloc::boxed::Box::new(inner)),
2497 wrapping_pubkey: Vec::new(),
2498 };
2499 let outer_hash = outer.sig_hash();
2500 outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
2501
2502 let cbor = outer.to_cbor(true).to_vec();
2503 assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
2504
2505 // A tampered rotation-wrap signature must be rejected by the STANDARD ed25519 verifier.
2506 let mut tampered = outer.clone();
2507 let mut sb = tampered.signature.clone();
2508 sb[0] ^= 0xff;
2509 tampered.signature = sb;
2510 let cbor_bad = tampered.to_cbor(true).to_vec();
2511 assert_eq!(
2512 auth.node_key_authorized(&node_key, &cbor_bad).unwrap_err(),
2513 TkaError::BadSignature
2514 );
2515 }
2516
2517 // ----- Fix 2: nested-Credential pubkey must bind to the rotation pivot -----
2518
2519 #[test]
2520 fn rotation_nested_credential_pubkey_bind() {
2521 use ed25519_dalek::{Signer, SigningKey};
2522
2523 let trusted = SigningKey::from_bytes(&[11u8; 32]);
2524 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
2525 let wrapping = SigningKey::from_bytes(&[13u8; 32]);
2526 let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
2527 let node_key = alloc::vec![6u8; 32];
2528
2529 let auth = Authority::from_state(
2530 AumHash([0; 32]),
2531 State {
2532 keys: alloc::vec![Key {
2533 kind: KeyKind::Ed25519,
2534 votes: 1,
2535 public: trusted_pub.clone(),
2536 }],
2537 },
2538 );
2539
2540 // Helper: build a rotation wrapping a nested Credential whose `pubkey` is `cred_pubkey`.
2541 let build = |cred_pubkey: Vec<u8>| -> Vec<u8> {
2542 let mut inner = NodeKeySignature {
2543 sig_kind: SigKind::Credential,
2544 pubkey: cred_pubkey,
2545 key_id: trusted_pub.clone(),
2546 signature: Vec::new(),
2547 nested: None,
2548 wrapping_pubkey: wrapping_pub.clone(),
2549 };
2550 let inner_hash = inner.sig_hash();
2551 inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
2552
2553 let mut outer = NodeKeySignature {
2554 sig_kind: SigKind::Rotation,
2555 pubkey: node_key.clone(),
2556 key_id: Vec::new(),
2557 signature: Vec::new(),
2558 nested: Some(alloc::boxed::Box::new(inner)),
2559 wrapping_pubkey: Vec::new(),
2560 };
2561 let outer_hash = outer.sig_hash();
2562 outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
2563 outer.to_cbor(true).to_vec()
2564 };
2565
2566 // Matching: credential covers exactly the wrapping pivot pubkey -> accepted.
2567 let cbor_ok = build(wrapping_pub.clone());
2568 assert!(auth.node_key_authorized(&node_key, &cbor_ok).is_ok());
2569
2570 // Mismatch: credential covers an unrelated pubkey -> rejected (NodeKeyMismatch), even though
2571 // the credential is otherwise signed by the trusted key.
2572 let cbor_bad = build(alloc::vec![0xaau8; 32]);
2573 assert_eq!(
2574 auth.node_key_authorized(&node_key, &cbor_bad).unwrap_err(),
2575 TkaError::NodeKeyMismatch
2576 );
2577 }
2578
2579 // ----- CTAP2-CBOR byte-exactness FROZEN regression vector -----
2580
2581 /// A small hex helper for embedding captured bytes in a failure message.
2582 fn hex(bytes: &[u8]) -> String {
2583 let mut s = String::new();
2584 for b in bytes {
2585 s.push_str(&alloc::format!("{b:02x}"));
2586 }
2587 s
2588 }
2589
2590 /// FROZEN CTAP2-CBOR byte-exactness vector for the wire/signing serialization.
2591 ///
2592 /// The crate docs (and the `cbor` module) state the CTAP2-canonical CBOR encoding is asserted by
2593 /// construction but NOT cross-validated against Go's `fxamacker/cbor` (CTAP2 mode) in this fork.
2594 /// The existing TKA tests build a signature, sign it, and verify round-trip — so they would all
2595 /// still pass if the canonical encoding silently changed (int-map key ordering, smallest-int
2596 /// rule, omitempty), because both sides of the round-trip use the same encoder. That class of
2597 /// change would, however, break wire-compat with a live Go TKA.
2598 ///
2599 /// This pins the EXACT bytes for a fixed `NodeKeySignature` (Direct, deterministic key material):
2600 /// the full `to_cbor(true)` serialization, the `to_cbor(false)` SigHash preimage, the resulting
2601 /// `sig_hash` (BLAKE2s-256 of the preimage), and the `aum_hash` over the full serialization. ANY
2602 /// accidental change to canonical-CBOR encoding or the BLAKE2s digest breaks this test.
2603 ///
2604 /// NOTE: this is a regression-FREEZE vector captured from the current encoder, NOT a Go-sourced
2605 /// cross-vector. It should be replaced with a real `fxamacker/cbor` CTAP2 vector (the same
2606 /// `NodeKeySignature` encoded by a live Go `tka`) once one can be captured.
2607 #[test]
2608 fn node_key_signature_cbor_frozen_vector() {
2609 // Deterministic, fixed key material — NOT random. byte i = i, so the bytes are obvious.
2610 let pubkey: Vec<u8> = (0u8..32).collect();
2611 let key_id: Vec<u8> = (32u8..64).collect();
2612 let signature: Vec<u8> = (64u8..128).collect();
2613
2614 let sig = NodeKeySignature {
2615 sig_kind: SigKind::Direct,
2616 pubkey,
2617 key_id,
2618 signature,
2619 nested: None,
2620 wrapping_pubkey: Vec::new(), // empty -> omitted (omitempty), key 6 must NOT appear
2621 };
2622
2623 // 1. Full serialization (include_signature = true): keys 1,2,3,4 present, 5/6 omitted.
2624 let full = sig.to_cbor(true).to_vec();
2625 const EXPECTED_FULL: &[u8] = &[
2626 0xa4, 0x01, 0x01, 0x02, 0x58, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
2627 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
2628 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x03, 0x58, 0x20, 0x20,
2629 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e,
2630 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
2631 0x3d, 0x3e, 0x3f, 0x04, 0x58, 0x40, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47,
2632 0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55,
2633 0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, 0x61, 0x62, 0x63,
2634 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71,
2635 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f,
2636 ];
2637 assert_eq!(
2638 full,
2639 EXPECTED_FULL,
2640 "full CBOR serialization changed (canonical-CBOR encoding drift). actual: {}",
2641 hex(&full)
2642 );
2643
2644 // 2. SigHash preimage (include_signature = false): key 4 (signature) omitted.
2645 let preimage = sig.to_cbor(false).to_vec();
2646 const EXPECTED_PREIMAGE: &[u8] = &[
2647 0xa3, 0x01, 0x01, 0x02, 0x58, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
2648 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
2649 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x03, 0x58, 0x20, 0x20,
2650 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e,
2651 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
2652 0x3d, 0x3e, 0x3f,
2653 ];
2654 assert_eq!(
2655 preimage,
2656 EXPECTED_PREIMAGE,
2657 "SigHash preimage CBOR changed. actual: {}",
2658 hex(&preimage)
2659 );
2660
2661 // 3. sig_hash = BLAKE2s-256(preimage) — pinned.
2662 let sig_hash = sig.sig_hash();
2663 const EXPECTED_SIG_HASH: [u8; AUM_HASH_LEN] = [
2664 0x22, 0x6f, 0x9c, 0xbc, 0x63, 0x73, 0x92, 0x75, 0x2e, 0x0e, 0xb1, 0x32, 0x9c, 0xc4,
2665 0x99, 0x07, 0x01, 0x4a, 0xb6, 0x4f, 0x8e, 0x5d, 0x82, 0x85, 0xc2, 0x91, 0x42, 0x62,
2666 0xf6, 0xa6, 0xa8, 0x33,
2667 ];
2668 assert_eq!(
2669 sig_hash,
2670 EXPECTED_SIG_HASH,
2671 "sig_hash (BLAKE2s-256 of preimage) changed. actual: {}",
2672 hex(&sig_hash)
2673 );
2674
2675 // 4. aum_hash over the full serialization — pinned (exercises the public `aum_hash` helper
2676 // + BLAKE2s digest over a frozen input).
2677 let aum = aum_hash(&full);
2678 const EXPECTED_AUM_HASH: [u8; AUM_HASH_LEN] = [
2679 0xa4, 0x40, 0x71, 0xa3, 0x7a, 0xbf, 0x80, 0x92, 0xd6, 0xff, 0x23, 0x84, 0xb2, 0xb0,
2680 0xa3, 0x50, 0xc7, 0xcb, 0x48, 0x41, 0xed, 0x68, 0x99, 0x62, 0x41, 0x7c, 0xd4, 0x23,
2681 0x68, 0xdc, 0x72, 0x49,
2682 ];
2683 assert_eq!(
2684 aum.0,
2685 EXPECTED_AUM_HASH,
2686 "aum_hash over full serialization changed. actual: {}",
2687 hex(&aum.0)
2688 );
2689 }
2690
2691 // ----- ed25519-speccheck KAT: dual-verifier (dalek std vs zebra ZIP-215) -----
2692
2693 /// Decode an ASCII hex string to bytes. Panics on malformed input (test-only).
2694 fn unhex(s: &str) -> Vec<u8> {
2695 assert!(s.len().is_multiple_of(2), "odd hex length");
2696 let nib = |c: u8| -> u8 {
2697 match c {
2698 b'0'..=b'9' => c - b'0',
2699 b'a'..=b'f' => c - b'a' + 10,
2700 b'A'..=b'F' => c - b'A' + 10,
2701 _ => panic!("bad hex nibble"),
2702 }
2703 };
2704 let b = s.as_bytes();
2705 let mut out = Vec::with_capacity(s.len() / 2);
2706 let mut i = 0;
2707 while i < b.len() {
2708 out.push((nib(b[i]) << 4) | nib(b[i + 1]));
2709 i += 2;
2710 }
2711 out
2712 }
2713
2714 /// The 12 adversarial Ed25519 vectors from `novifinancial/ed25519-speccheck`.
2715 ///
2716 /// Provenance: `cases.json` at commit `65519336fda78a3d016e947df6d82848aca0c9da`
2717 /// (<https://github.com/novifinancial/ed25519-speccheck/blob/main/cases.json>), the canonical
2718 /// generated vectors backing the "Taming the many EdDSAs" paper (IACR 2020/1244, Table 6c).
2719 /// The hex below is copied byte-for-byte from that file; the `message` field is itself hex
2720 /// (the speccheck driver hex-decodes it before verifying), so we decode it the same way.
2721 ///
2722 /// Tuple layout: `(message_hex, pubkey_hex, signature_hex)`.
2723 const SPECCHECK_VECTORS: [(&str, &str, &str); 12] = [
2724 // 0: S = 0; both A and R small-order.
2725 (
2726 "8c93255d71dcab10e8f379c26200f3c7bd5f09d9bc3068d3ef4edeb4853022b6",
2727 "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
2728 "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a0000000000000000000000000000000000000000000000000000000000000000",
2729 ),
2730 // 1: 0 < S < L; small A only.
2731 (
2732 "9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79",
2733 "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
2734 "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43a5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
2735 ),
2736 // 2: 0 < S < L; small R only.
2737 (
2738 "aebf3f2601a0c8c5d39cc7d8911642f740b78168218da8471772b35f9d35b9ab",
2739 "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
2740 "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa8c4bd45aecaca5b24fb97bc10ac27ac8751a7dfe1baff8b953ec9f5833ca260e",
2741 ),
2742 // 3: A and R mixed-order; passes both (unless full-order checked).
2743 (
2744 "9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79",
2745 "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
2746 "9046a64750444938de19f227bb80485e92b83fdb4b6506c160484c016cc1852f87909e14428a7a1d62e9f22f3d3ad7802db02eb2e688b6c52fcd6648a98bd009",
2747 ),
2748 // 4: A and R mixed; passes cofactored, FAILS cofactorless — the cofactored discriminator.
2749 (
2750 "e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c",
2751 "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
2752 "160a1cb0dc9c0258cd0a7d23e94d8fa878bcb1925f2c64246b2dee1796bed5125ec6bc982a269b723e0668e540911a9a6a58921d6925e434ab10aa7940551a09",
2753 ),
2754 // 5: A mixed, R order L; "fails cofactored iff (8h) prereduced".
2755 (
2756 "e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c",
2757 "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
2758 "21122a84e0b5fca4052f5b1235c80a537878b38f3142356b2c2384ebad4668b7e40bc836dac0f71076f9abe3a53f9c03c1ceeeddb658d0030494ace586687405",
2759 ),
2760 // 6: S > L (out of bounds) — malleability vector; std verifier MUST reject.
2761 (
2762 "85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40",
2763 "442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623",
2764 "e96f66be976d82e60150baecff9906684aebb1ef181f67a7189ac78ea23b6c0e547f7690a0e2ddcd04d87dbc3490dc19b3b3052f7ff0538cb68afb369ba3a514",
2765 ),
2766 // 7: S >> L (no canonical serialization with null high bit) — std verifier MUST reject.
2767 (
2768 "85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40",
2769 "442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623",
2770 "8ce5b96c8f26d0ab6c47958c9e68b937104cd36e13c33566acd2fe8d38aa19427e71f98a473474f2f13f06f97c20d58cc3f54b8bd0d272f42b695dd7e89a8c22",
2771 ),
2772 // 8: 0 < S < L; non-canonical R, reduced for hash.
2773 (
2774 "9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41",
2775 "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
2776 "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff03be9678ac102edcd92b0210bb34d7428d12ffc5df5f37e359941266a4e35f0f",
2777 ),
2778 // 9: 0 < S < L; non-canonical R, NOT reduced for hash.
2779 (
2780 "9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41",
2781 "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
2782 "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffca8c5b64cd208982aa38d4936621a4775aa233aa0505711d8fdcfdaa943d4908",
2783 ),
2784 // 10: 0 < S < L; non-canonical A, reduced for hash.
2785 (
2786 "e96b7021eb39c1a163b6da4e3093dcd3f21387da4cc4572be588fafae23c155b",
2787 "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
2788 "a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
2789 ),
2790 // 11: 0 < S < L; non-canonical A, NOT reduced for hash.
2791 (
2792 "39a591f5321bbe07fd5a23dc2f39d025d74526615746727ceefd6e82ae65c06f",
2793 "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
2794 "a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
2795 ),
2796 ];
2797
2798 /// Known-answer test guarding the dual-verifier split that backs TKA consensus correctness.
2799 ///
2800 /// `verify_ed25519_std` wraps `ed25519-dalek 2.x` (standard RFC-8032-ish, cofactorless) and is
2801 /// used for SigRotation WRAPPING signatures. `verify_ed25519_zip215` wraps `ed25519-zebra 4.x`
2802 /// (ZIP-215 cofactored) and is used for Direct/Credential signatures to match Go
2803 /// `ed25519consensus`. If these two ever collapse to identical behavior, Go wire-compat for
2804 /// Tailnet-Lock silently breaks — this test proves they remain distinct on the adversarial set.
2805 ///
2806 /// The accept/reject matrix is asserted **as actually observed** from the pinned crate versions
2807 /// (`ed25519-dalek 2.2.0`, `ed25519-zebra 4.2.0`). These are newer than the versions tabulated
2808 /// in the "Taming the many EdDSAs" paper (Table 5: dalek 1.0.0-pre.4, zebra 2.1.1), so the
2809 /// non-canonical cases (8–11) may differ from the paper; we lock in current behavior as a
2810 /// regression guard. The SECURITY-CRITICAL invariants are NOT version-tunable: the standard
2811 /// verifier MUST reject the S >= L malleability vectors (6, 7), and the two verifiers MUST
2812 /// disagree on the cofactored discriminator (vector 4). Those are hard, separate assertions.
2813 #[test]
2814 fn ed25519_speccheck_dual_verifier_kat() {
2815 // Observed accept(true)/reject(false) matrix for the pinned crates, vectors 0..=11.
2816 // Anchored to the speccheck paper rows then corrected to what the crates actually do
2817 // (see the per-vector run below — any divergence makes the test fail loudly).
2818 //
2819 // 0 1 2 3 4 5 6 7 8 9 10 11
2820 const STD_EXPECT: [bool; 12] = [
2821 true, true, true, true, false, false, false, false, false, false, false, true,
2822 ];
2823 const ZIP215_EXPECT: [bool; 12] = [
2824 true, true, true, true, true, true, false, false, false, true, true, true,
2825 ];
2826
2827 for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
2828 let msg = unhex(msg_hex);
2829 let pk = unhex(pk_hex);
2830 let sig = unhex(sig_hex);
2831 assert_eq!(pk.len(), 32, "vector {i}: pubkey not 32 bytes");
2832 assert_eq!(sig.len(), 64, "vector {i}: signature not 64 bytes");
2833
2834 let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
2835 let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
2836
2837 assert_eq!(
2838 std_ok, STD_EXPECT[i],
2839 "speccheck vector {i}: verify_ed25519_std accept={std_ok}, expected {}",
2840 STD_EXPECT[i]
2841 );
2842 assert_eq!(
2843 zip_ok, ZIP215_EXPECT[i],
2844 "speccheck vector {i}: verify_ed25519_zip215 accept={zip_ok}, expected {}",
2845 ZIP215_EXPECT[i]
2846 );
2847 }
2848
2849 // SECURITY-CRITICAL invariant (NOT version-tunable): the standard verifier must reject
2850 // signatures whose scalar S is out of range (S >= L). These are vectors 6 and 7 — the
2851 // EdDSA malleability guard. If either ACCEPTS, that is a real security finding.
2852 for &i in &[6usize, 7usize] {
2853 let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[i];
2854 let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
2855 assert!(
2856 verify_ed25519_std(&pk, &msg, &sig).is_err(),
2857 "SECURITY: verify_ed25519_std ACCEPTED S>=L malleability vector {i}"
2858 );
2859 }
2860
2861 // KEY DISCRIMINATOR (vector 4): cofactored (ZIP-215/zebra) accepts, cofactorless
2862 // (standard/dalek) rejects, on the SAME (pk, msg, sig). This proves the dual-verifier
2863 // split is real and not accidentally identical.
2864 {
2865 let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[4];
2866 let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
2867 assert!(
2868 verify_ed25519_zip215(&pk, &msg, &sig).is_ok(),
2869 "vector 4: ZIP-215 (zebra) should ACCEPT the cofactored discriminator"
2870 );
2871 assert!(
2872 verify_ed25519_std(&pk, &msg, &sig).is_err(),
2873 "vector 4: standard (dalek) should REJECT the cofactored discriminator"
2874 );
2875 }
2876 }
2877
2878 // ----- Cross-implementation KATs against real Go `tailscale.com/tka` v1.100.0 -----
2879
2880 /// Cross-implementation Known-Answer-Test: the CTAP2-CBOR serialization and BLAKE2s-256
2881 /// `SigHash` of three `NodeKeySignature` shapes must byte-match the REAL Go
2882 /// `tailscale.com/tka` package, version **v1.100.0** (toolchain **go1.26.4**).
2883 ///
2884 /// Provenance: the golden bytes below were produced by a Go generator that imports the real
2885 /// upstream `tailscale.com/tka` and calls `NodeKeySignature.Serialize()` (full CBOR including
2886 /// the signature field) and `NodeKeySignature.SigHash()` (BLAKE2s-256 of the CBOR with the
2887 /// `Signature` field nil'd). They are authoritative upstream output, NOT this fork's own
2888 /// encoder echoed back — this is the cross-validation the `node_key_signature_cbor_frozen_vector`
2889 /// freeze-test could not provide. The generator lives alongside the speccheck generator under
2890 /// `tests/vectors/gen` (Go module pinned to `tailscale.com v1.100.0`).
2891 ///
2892 /// Three shapes are covered: a `Direct` leaf, a `Credential` leaf (same fields, different
2893 /// `sigKind`), and a `Rotation` wrapping a nested `Direct` (the rotation-chain wire form). The
2894 /// int-map keys are 1=sigKind, 2=pubkey, 3=keyID, 4=signature, 5=nested, 6=wrappingPubkey;
2895 /// empty byte fields are omitted (`omitempty`).
2896 #[test]
2897 fn tka_cbor_matches_go_golden() {
2898 // Common fixed field material (real Go generator inputs).
2899 let pubkey32 = unhex("a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf");
2900 let key_id32 = unhex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f");
2901 let sig64 = unhex(
2902 "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
2903 );
2904 let wrap32 = unhex("101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f");
2905 let rot_sig64 = unhex(
2906 "55565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f9091929394",
2907 );
2908
2909 // GOLDEN 1 — Direct.
2910 {
2911 let sig = NodeKeySignature {
2912 sig_kind: SigKind::Direct,
2913 pubkey: pubkey32.clone(),
2914 key_id: key_id32.clone(),
2915 signature: sig64.clone(),
2916 nested: None,
2917 wrapping_pubkey: Vec::new(),
2918 };
2919 let full = sig.to_cbor(true).to_vec();
2920 let expected_full = unhex(
2921 "a40101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
2922 );
2923 assert_eq!(
2924 full,
2925 expected_full,
2926 "GOLDEN 1 (Direct) full CBOR diverged from Go tka v1.100.0. actual: {}",
2927 hex(&full)
2928 );
2929 let expected_hash =
2930 unhex("7e9653c97d35485b37b9bf942b1861cd2f3cb0663b5bb154f1178cca72101e74");
2931 assert_eq!(
2932 sig.sig_hash().as_slice(),
2933 expected_hash.as_slice(),
2934 "GOLDEN 1 (Direct) sig_hash diverged from Go tka v1.100.0. actual: {}",
2935 hex(&sig.sig_hash())
2936 );
2937 }
2938
2939 // GOLDEN 2 — Credential (same fields as Direct, sigKind=3).
2940 {
2941 let sig = NodeKeySignature {
2942 sig_kind: SigKind::Credential,
2943 pubkey: pubkey32.clone(),
2944 key_id: key_id32.clone(),
2945 signature: sig64.clone(),
2946 nested: None,
2947 wrapping_pubkey: Vec::new(),
2948 };
2949 let full = sig.to_cbor(true).to_vec();
2950 let expected_full = unhex(
2951 "a40103025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
2952 );
2953 assert_eq!(
2954 full,
2955 expected_full,
2956 "GOLDEN 2 (Credential) full CBOR diverged from Go tka v1.100.0. actual: {}",
2957 hex(&full)
2958 );
2959 let expected_hash =
2960 unhex("b6070ea8bc7ae8989ef4293f5031bedaa4a499803ade99f9e2f34dc2898ac03f");
2961 assert_eq!(
2962 sig.sig_hash().as_slice(),
2963 expected_hash.as_slice(),
2964 "GOLDEN 2 (Credential) sig_hash diverged from Go tka v1.100.0. actual: {}",
2965 hex(&sig.sig_hash())
2966 );
2967 }
2968
2969 // GOLDEN 3 — Rotation wrapping a nested Direct.
2970 //
2971 // Decoded from the authoritative Go bytes, the OUTER map is `a4` = 4 entries: keys
2972 // 1=sigKind(Rotation), 2=pubkey(wrap32), 4=signature(rotSig64), 5=nested. The outer has NO
2973 // key 6 (its `wrapping_pubkey` is EMPTY → omitted) and NO key 3 (its `key_id` is EMPTY →
2974 // omitted). The trailing `065820<wrap32>` in the hex belongs to the NESTED Direct map
2975 // (`a5` = 5 entries: keys 1,2,3,4,6), whose `wrapping_pubkey` IS set to wrap32. Constructing
2976 // the structs this way (outer wrapping_pubkey empty, nested wrapping_pubkey=wrap32)
2977 // reproduces the Go bytes exactly.
2978 {
2979 let nested = NodeKeySignature {
2980 sig_kind: SigKind::Direct,
2981 pubkey: pubkey32.clone(),
2982 key_id: key_id32.clone(),
2983 signature: sig64.clone(),
2984 nested: None,
2985 wrapping_pubkey: wrap32.clone(),
2986 };
2987 let sig = NodeKeySignature {
2988 sig_kind: SigKind::Rotation,
2989 pubkey: wrap32.clone(),
2990 key_id: Vec::new(),
2991 signature: rot_sig64.clone(),
2992 nested: Some(alloc::boxed::Box::new(nested)),
2993 wrapping_pubkey: Vec::new(),
2994 };
2995 let full = sig.to_cbor(true).to_vec();
2996 let expected_full = unhex(
2997 "a40102025820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f04584055565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939405a50101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf065820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f",
2998 );
2999 assert_eq!(
3000 full,
3001 expected_full,
3002 "GOLDEN 3 (Rotation) full CBOR diverged from Go tka v1.100.0. actual: {}",
3003 hex(&full)
3004 );
3005 let expected_hash =
3006 unhex("fac0a5a6781bb945369c28a0b3d3eea04e1648b60ec1a990a1ff68a9a566e6a7");
3007 assert_eq!(
3008 sig.sig_hash().as_slice(),
3009 expected_hash.as_slice(),
3010 "GOLDEN 3 (Rotation) sig_hash diverged from Go tka v1.100.0. actual: {}",
3011 hex(&sig.sig_hash())
3012 );
3013 }
3014 }
3015
3016 /// Cross-bind the dual Ed25519 verifier accept/reject matrix to the verdicts produced by the
3017 /// REAL Go implementations on the adversarial speccheck set (see [`SPECCHECK_VECTORS`]).
3018 ///
3019 /// Provenance of the Go verdicts: Go `crypto/ed25519.Verify` (standard, cofactorless) and
3020 /// `github.com/hdevalence/ed25519consensus v0.2.0` (ZIP-215, cofactored), toolchain
3021 /// **go1.26.4**, driven by the generator under `tests/vectors/gen/zip215`. These are the SAME
3022 /// verdicts the pinned Rust crates produce — proving `ed25519-dalek 2.x` == Go-std and
3023 /// `ed25519-zebra 4.x` == Go-`ed25519consensus` on the adversarial set. The arrays below MUST
3024 /// therefore equal `STD_EXPECT` / `ZIP215_EXPECT` asserted in
3025 /// `ed25519_speccheck_dual_verifier_kat`; this test additionally pins them to Go's behavior.
3026 ///
3027 /// NOTE: [`SPECCHECK_VECTORS`] is duplicated (byte-for-byte) in the Go generator at
3028 /// `tests/vectors/gen/zip215/main.go`. Both copies derive from the same upstream
3029 /// `cases.json` commit; if you edit one you MUST edit the other, or this proof would compare
3030 /// inputs the Go verdicts were never computed over.
3031 #[test]
3032 fn ed25519_dual_verifier_matches_go_verdicts() {
3033 // 0 1 2 3 4 5 6 7 8 9 10 11
3034 const GO_STD_ACCEPT: [bool; 12] = [
3035 true, true, true, true, false, false, false, false, false, false, false, true,
3036 ];
3037 const GO_ZIP215_ACCEPT: [bool; 12] = [
3038 true, true, true, true, true, true, false, false, false, true, true, true,
3039 ];
3040
3041 for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
3042 let msg = unhex(msg_hex);
3043 let pk = unhex(pk_hex);
3044 let sig = unhex(sig_hex);
3045
3046 let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
3047 let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
3048
3049 assert_eq!(
3050 std_ok, GO_STD_ACCEPT[i],
3051 "vector {i}: Rust verify_ed25519_std accept={std_ok} disagrees with Go \
3052 crypto/ed25519.Verify={}",
3053 GO_STD_ACCEPT[i]
3054 );
3055 assert_eq!(
3056 zip_ok, GO_ZIP215_ACCEPT[i],
3057 "vector {i}: Rust verify_ed25519_zip215 accept={zip_ok} disagrees with Go \
3058 ed25519consensus.Verify={}",
3059 GO_ZIP215_ACCEPT[i]
3060 );
3061 }
3062 }
3063
3064 /// Byte-exact cross-validation of [`Aum::serialize`] against the literal `[]byte` vectors in Go
3065 /// `tka/aum_test.go` `TestSerialization` (tailscale v1.100.0, fxamacker/cbor v2.9.2 CTAP2 mode).
3066 /// These are the authoritative oracle: if our CTAP2 CBOR diverges from Go by a single byte, the
3067 /// `AUM.Hash` chain links and every signature digest break. Each case reproduces the exact Go
3068 /// `AUM{…}` literal and asserts identical canonical bytes.
3069 #[test]
3070 fn aum_serialize_matches_go_test_serialization_vectors() {
3071 // AddKey: AUM{MessageKind: AUMAddKey, Key: &Key{}}. Go's *zero* Key{} has Kind=0
3072 // (KeyInvalid) and Public=nil, which our `AumKey` (always a valid KeyKind + Vec) cannot
3073 // model — that zero-Key encoding (`03 a3 01 00 02 00 03 f6`) is asserted directly at the
3074 // CBOR layer here, while the AUM keymap around it (map3, kind=AddKey, null prev, Key at
3075 // key 3) is covered by the structural assertions plus the three full vectors below.
3076 let add_key_inner_zero_key = cbor::Value::IntMap(alloc::vec![
3077 (1, cbor::Value::Uint(0)), // Kind = KeyInvalid(0)
3078 (2, cbor::Value::Uint(0)), // Votes = 0
3079 (3, cbor::Value::Null), // Public = nil -> null
3080 ]);
3081 assert_eq!(
3082 add_key_inner_zero_key.to_vec(),
3083 alloc::vec![0xa3, 0x01, 0x00, 0x02, 0x00, 0x03, 0xf6],
3084 "Go's zero Key{{}} encodes as map(3){{kind=0, votes=0, public=null}}"
3085 );
3086
3087 // RemoveKey: AUM{MessageKind: AUMRemoveKey, KeyID: []byte{1, 2}}
3088 let remove_key = Aum {
3089 message_kind: AumKind::RemoveKey,
3090 prev_aum_hash: None,
3091 key: None,
3092 key_id: alloc::vec![1, 2],
3093 state: None,
3094 votes: None,
3095 meta: Vec::new(),
3096 signatures: Vec::new(),
3097 };
3098 assert_eq!(
3099 remove_key.serialize(),
3100 // a3 (map3) 01 02 (kind=RemoveKey) 02 f6 (prev=null) 04 42 01 02 (KeyID=bytes{1,2})
3101 alloc::vec![0xa3, 0x01, 0x02, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02],
3102 "RemoveKey AUM serialization must match Go TestSerialization byte-for-byte"
3103 );
3104
3105 // UpdateKey: AUM{MessageKind: AUMUpdateKey, Votes: &uint(2), KeyID: []byte{1,2},
3106 // Meta: map[string]string{"a":"b"}}
3107 let update_key = Aum {
3108 message_kind: AumKind::UpdateKey,
3109 prev_aum_hash: None,
3110 key: None,
3111 key_id: alloc::vec![1, 2],
3112 state: None,
3113 votes: Some(2),
3114 meta: alloc::vec![("a".into(), "b".into())],
3115 signatures: Vec::new(),
3116 };
3117 assert_eq!(
3118 update_key.serialize(),
3119 // a5 (map5) 01 04 (UpdateKey) 02 f6 (prev null) 04 42 01 02 (KeyID) 06 02 (Votes=2)
3120 // 07 a1 61 61 61 62 (Meta = {"a":"b"}) — keys ascending 1,2,4,6,7
3121 alloc::vec![
3122 0xa5, 0x01, 0x04, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02, 0x06, 0x02, 0x07, 0xa1, 0x61,
3123 0x61, 0x61, 0x62
3124 ],
3125 "UpdateKey AUM serialization must match Go TestSerialization byte-for-byte"
3126 );
3127
3128 // Signature: AUM{MessageKind: AUMAddKey, Signatures: []tkatype.Signature{{KeyID: []byte{1}}}}
3129 let with_sig = Aum {
3130 message_kind: AumKind::AddKey,
3131 prev_aum_hash: None,
3132 key: None,
3133 key_id: Vec::new(),
3134 state: None,
3135 votes: None,
3136 meta: Vec::new(),
3137 signatures: alloc::vec![AumSignature {
3138 key_id: alloc::vec![1],
3139 signature: Vec::new(),
3140 }],
3141 };
3142 assert_eq!(
3143 with_sig.serialize(),
3144 // a3 (map3) 01 01 (AddKey) 02 f6 (prev null) 17 (key 23 = Signatures) 81 (array1)
3145 // a2 (map2) 01 41 01 (Signature.KeyID = bytes{1}) 02 f6 (Signature.Signature = null)
3146 alloc::vec![
3147 0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6
3148 ],
3149 "Signature AUM serialization must match Go TestSerialization (key 23 + nil sig = null)"
3150 );
3151
3152 // sig_hash must drop key 23 (Go SigHash nils Signatures → omitempty): the with_sig AUM's
3153 // sig_hash equals the BLAKE2s of the same AUM with no signatures.
3154 let no_sig = Aum {
3155 signatures: Vec::new(),
3156 ..with_sig.clone()
3157 };
3158 assert_eq!(
3159 with_sig.sig_hash(),
3160 blake2s_256(&no_sig.serialize()),
3161 "SigHash preimage must omit key 23 (Signatures), matching Go AUM.SigHash"
3162 );
3163 // And the full Hash differs from the SigHash (signatures are in the chain-link hash).
3164 assert_ne!(
3165 with_sig.hash().0,
3166 with_sig.sig_hash(),
3167 "Hash (incl. signatures) must differ from SigHash (excl.) when signatures are present"
3168 );
3169 }
3170
3171 /// Checkpoint AUM with an embedded `State`: exercises [`AumState`]/[`AumKey`] CBOR (the 32-byte
3172 /// `LastAUMHash` as a definite-length byte string, the `DisablementValues`/`Keys` arrays, and the
3173 /// `Key.Public` at key 3). Mirrors the structure of Go's `TestSerialization` Checkpoint case.
3174 #[test]
3175 fn aum_checkpoint_state_serialization() {
3176 let checkpoint = Aum {
3177 message_kind: AumKind::Checkpoint,
3178 prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
3179 key: None,
3180 key_id: Vec::new(),
3181 state: Some(AumState {
3182 last_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
3183 disablement_values: Some(Vec::new()),
3184 keys: Some(alloc::vec![AumKey {
3185 kind: KeyKind::Ed25519,
3186 votes: 1,
3187 public: alloc::vec![5, 6],
3188 meta: Vec::new(),
3189 }]),
3190 state_id1: 0,
3191 state_id2: 0,
3192 }),
3193 votes: None,
3194 meta: Vec::new(),
3195 signatures: Vec::new(),
3196 };
3197 let bytes = checkpoint.serialize();
3198 // Spot-check the structurally-load-bearing pieces (full-vector parity is covered by the
3199 // three exact vectors above; here we pin the State/Key encoding shape):
3200 // map3: key1=Checkpoint(5), key2=prev(32-byte bytestring 0x58 0x20 …), key5=State.
3201 assert_eq!(
3202 &bytes[0..3],
3203 &[0xa3, 0x01, 0x05],
3204 "map(3), MessageKind=Checkpoint(5)"
3205 );
3206 assert_eq!(
3207 &bytes[3..6],
3208 &[0x02, 0x58, 0x20],
3209 "key2 prev = 32-byte byte string head"
3210 );
3211 // The embedded State map (key 5) must contain: LastAUMHash (1) as 32-byte bytes, an empty
3212 // DisablementValues array (2 → 0x80), and a Keys array (3 → 0x81 with one Key map).
3213 // Locate the State map head (key 5) after the 32-byte prev hash: 3 + 3 + 32 = offset 38.
3214 assert_eq!(bytes[38], 0x05, "key 5 = State");
3215 // State is a map; its first entry is key1 (LastAUMHash) = 32-byte byte string.
3216 assert_eq!(
3217 &bytes[39..42],
3218 &[0xa3, 0x01, 0x58],
3219 "State map(3), key1 LastAUMHash bytes"
3220 );
3221 // The Key inside Keys carries Public={5,6} at key 3 (…03 42 05 06) and Votes=1 at key 2.
3222 let tail = &bytes[bytes.len() - 4..];
3223 assert_eq!(
3224 tail,
3225 &[0x03, 0x42, 0x05, 0x06],
3226 "Key.Public (key 3) = bytes{{5,6}}"
3227 );
3228 // Round-trips deterministically (hash is stable).
3229 assert_eq!(checkpoint.hash(), checkpoint.hash());
3230 }
3231
3232 // ---- AUM-chain replay (chunk 1B) -----------------------------------------------------------
3233
3234 /// A test trusted key from a seed byte (deterministic public key + given votes).
3235 fn test_aum_key(seed: u8, votes: u32) -> AumKey {
3236 use ed25519_dalek::SigningKey;
3237 let pubk = SigningKey::from_bytes(&[seed; 32])
3238 .verifying_key()
3239 .to_bytes()
3240 .to_vec();
3241 AumKey {
3242 kind: KeyKind::Ed25519,
3243 votes,
3244 public: pubk,
3245 meta: Vec::new(),
3246 }
3247 }
3248
3249 /// A genesis `AUMAddKey` (no parent) adding `key`.
3250 fn genesis_add(key: AumKey) -> Aum {
3251 Aum {
3252 message_kind: AumKind::AddKey,
3253 prev_aum_hash: None,
3254 key: Some(key),
3255 key_id: Vec::new(),
3256 state: None,
3257 votes: None,
3258 meta: Vec::new(),
3259 signatures: Vec::new(),
3260 }
3261 }
3262
3263 /// A child AUM of `parent` of the given kind, optionally carrying a key / key_id.
3264 fn child(parent: &Aum, kind: AumKind, key: Option<AumKey>, key_id: Vec<u8>) -> Aum {
3265 Aum {
3266 message_kind: kind,
3267 prev_aum_hash: Some(parent.hash()),
3268 key,
3269 key_id,
3270 state: None,
3271 votes: None,
3272 meta: Vec::new(),
3273 signatures: Vec::new(),
3274 }
3275 }
3276
3277 /// Linear replay applies each kind: genesis AddKey(k0), AddKey(k1), UpdateKey(k1 votes), then
3278 /// RemoveKey(k0). The final state has only k1 with its updated votes, and head = last AUM hash.
3279 #[test]
3280 fn replay_linear_chain_folds_all_kinds() {
3281 let k0 = test_aum_key(1, 1);
3282 let k1 = test_aum_key(2, 1);
3283
3284 let a0 = genesis_add(k0.clone());
3285 let a1 = child(&a0, AumKind::AddKey, Some(k1.clone()), Vec::new());
3286 let mut a2 = child(&a1, AumKind::UpdateKey, None, k1.public.clone());
3287 a2.votes = Some(5);
3288 let a3 = child(&a2, AumKind::RemoveKey, None, k0.public.clone());
3289
3290 let auth = Authority::from_chain(&[a0, a1, a2, a3.clone()]).unwrap();
3291
3292 // Only k1 remains, with the updated vote weight.
3293 assert_eq!(auth.state().keys.len(), 1, "k0 removed, k1 remains");
3294 let remaining = &auth.state().keys[0];
3295 assert_eq!(remaining.public, k1.public, "k1 is the surviving key");
3296 assert_eq!(remaining.votes, 5, "UpdateKey raised k1's votes to 5");
3297 // Head is the hash of the last applied AUM.
3298 assert_eq!(auth.head(), a3.hash(), "head = last AUM hash");
3299 }
3300
3301 /// A broken chain link (wrong `prev_aum_hash`) is rejected with `BadParent`.
3302 #[test]
3303 fn replay_rejects_broken_parent_link() {
3304 let k0 = test_aum_key(1, 1);
3305 let k1 = test_aum_key(2, 1);
3306 let a0 = genesis_add(k0);
3307 // a1 claims a bogus parent, not a0's hash.
3308 let mut a1 = child(&a0, AumKind::AddKey, Some(k1), Vec::new());
3309 a1.prev_aum_hash = Some(AumHash([0xab; 32]));
3310 assert_eq!(
3311 Authority::from_chain(&[a0, a1]).unwrap_err(),
3312 TkaError::BadParent
3313 );
3314 }
3315
3316 /// AddKey of an already-trusted key, and Remove/Update of an absent key, are rejected.
3317 #[test]
3318 fn replay_rejects_bad_key_state() {
3319 let k0 = test_aum_key(1, 1);
3320 let a0 = genesis_add(k0.clone());
3321 // Duplicate add of k0.
3322 let dup = child(&a0, AumKind::AddKey, Some(k0.clone()), Vec::new());
3323 assert_eq!(
3324 Authority::from_chain(&[a0.clone(), dup]).unwrap_err(),
3325 TkaError::BadKeyState
3326 );
3327 // Remove of a key that was never added.
3328 let absent = test_aum_key(9, 1);
3329 let rm = child(&a0, AumKind::RemoveKey, None, absent.public.clone());
3330 assert_eq!(
3331 Authority::from_chain(&[a0, rm]).unwrap_err(),
3332 TkaError::BadKeyState
3333 );
3334 }
3335
3336 /// An empty chain is rejected.
3337 #[test]
3338 fn replay_empty_chain_is_bad_chain() {
3339 assert_eq!(Authority::from_chain(&[]).unwrap_err(), TkaError::BadChain);
3340 }
3341
3342 /// `weight` sums the votes of distinct trusted signing keys: an unknown signer contributes 0, and
3343 /// a key that signs twice counts once (Go `TestAUMWeight` "Double use" → its votes, not double).
3344 #[test]
3345 fn replay_weight_dedups_and_ignores_unknown() {
3346 let k0 = test_aum_key(1, 2);
3347 let k1 = test_aum_key(2, 3);
3348 let state = ReplayState {
3349 keys: alloc::vec![k0.clone(), k1.clone()],
3350 last_aum_hash: None,
3351 state_id: None,
3352 };
3353
3354 // Empty signatures → 0.
3355 let mut aum = genesis_add(test_aum_key(5, 1));
3356 assert_eq!(state.weight(&aum), 0);
3357
3358 // One known signer (k0, votes 2).
3359 aum.signatures = alloc::vec![AumSignature {
3360 key_id: k0.public.clone(),
3361 signature: Vec::new()
3362 }];
3363 assert_eq!(state.weight(&aum), 2);
3364
3365 // Two distinct known signers → 2 + 3 = 5.
3366 aum.signatures = alloc::vec![
3367 AumSignature {
3368 key_id: k0.public.clone(),
3369 signature: Vec::new()
3370 },
3371 AumSignature {
3372 key_id: k1.public.clone(),
3373 signature: Vec::new()
3374 },
3375 ];
3376 assert_eq!(state.weight(&aum), 5);
3377
3378 // Double-use of k0 → counted once (2), not 4.
3379 aum.signatures = alloc::vec![
3380 AumSignature {
3381 key_id: k0.public.clone(),
3382 signature: Vec::new()
3383 },
3384 AumSignature {
3385 key_id: k0.public.clone(),
3386 signature: Vec::new()
3387 },
3388 ];
3389 assert_eq!(state.weight(&aum), 2, "a key signing twice counts once");
3390
3391 // Unknown signer → 0.
3392 aum.signatures = alloc::vec![AumSignature {
3393 key_id: alloc::vec![0xff; 32],
3394 signature: Vec::new()
3395 }];
3396 assert_eq!(
3397 state.weight(&aum),
3398 0,
3399 "an untrusted signing key contributes no weight"
3400 );
3401 }
3402
3403 /// `pick_next_aum` rule 3 (the deterministic tiebreak): with equal weight (0, no signatures) and
3404 /// neither a RemoveKey, the candidate with the lexicographically-lowest `Hash()` wins —
3405 /// regardless of input order, so two nodes select the same branch.
3406 #[test]
3407 fn pick_next_aum_lowest_hash_tiebreak_is_order_independent() {
3408 let k = test_aum_key(1, 1);
3409 let a0 = genesis_add(k);
3410 // Two distinct NoOp children of a0 (differ by key_id so their hashes differ).
3411 let c1 = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
3412 let c2 = child(&a0, AumKind::NoOp, None, alloc::vec![2]);
3413 let state = ReplayState::default();
3414
3415 let lower = if c1.hash().0 < c2.hash().0 {
3416 c1.hash()
3417 } else {
3418 c2.hash()
3419 };
3420 let ab = [c1.clone(), c2.clone()];
3421 let ba = [c2, c1];
3422 let pick_ab = pick_next_aum(&state, &ab).hash();
3423 let pick_ba = pick_next_aum(&state, &ba).hash();
3424 assert_eq!(pick_ab, lower, "lowest hash wins");
3425 assert_eq!(
3426 pick_ab, pick_ba,
3427 "selection is independent of candidate order"
3428 );
3429 }
3430
3431 /// `pick_next_aum` rule 1 (weight) dominates rule 3 (hash): a signed child with real weight beats
3432 /// an unsigned child even if the unsigned one has a lower hash.
3433 #[test]
3434 fn pick_next_aum_weight_beats_hash() {
3435 use ed25519_dalek::SigningKey;
3436 let signer_seed = 3u8;
3437 let signer_pub = SigningKey::from_bytes(&[signer_seed; 32])
3438 .verifying_key()
3439 .to_bytes()
3440 .to_vec();
3441 let state = ReplayState {
3442 keys: alloc::vec![AumKey {
3443 kind: KeyKind::Ed25519,
3444 votes: 4,
3445 public: signer_pub.clone(),
3446 meta: Vec::new(),
3447 }],
3448 last_aum_hash: None,
3449 state_id: None,
3450 };
3451
3452 let a0 = genesis_add(test_aum_key(1, 1));
3453 let unsigned = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
3454 let mut signed = child(&a0, AumKind::NoOp, None, alloc::vec![2]);
3455 signed.signatures = alloc::vec![AumSignature {
3456 key_id: signer_pub,
3457 signature: Vec::new(),
3458 }];
3459
3460 // The signed child wins on weight (4 > 0) no matter the hash order.
3461 let candidates = [unsigned.clone(), signed.clone()];
3462 let winner = pick_next_aum(&state, &candidates);
3463 assert_eq!(
3464 winner.hash(),
3465 signed.hash(),
3466 "higher weight wins over lower hash"
3467 );
3468 }
3469
3470 /// `from_forked_chain`: a shared genesis, then two competing RemoveKey vs NoOp branches at equal
3471 /// weight — rule 2 prefers the RemoveKey branch. The resulting state reflects the chosen branch.
3472 #[test]
3473 fn forked_chain_prefers_removekey_branch() {
3474 let k0 = test_aum_key(1, 1);
3475 let k1 = test_aum_key(2, 1);
3476 // Genesis adds both keys (two AUMs).
3477 let a0 = genesis_add(k0.clone());
3478 let a1 = child(&a0, AumKind::AddKey, Some(k1.clone()), Vec::new());
3479 // Fork at a1: branch A removes k0; branch B is a NoOp. Equal weight (0 sigs).
3480 let branch_remove = child(&a1, AumKind::RemoveKey, None, k0.public.clone());
3481 let branch_noop = child(&a1, AumKind::NoOp, None, alloc::vec![9]);
3482
3483 let noop_branch = [branch_noop.clone()];
3484 let remove_branch = [branch_remove.clone()];
3485 let auth = Authority::from_forked_chain(&[a0, a1], &[&noop_branch[..], &remove_branch[..]])
3486 .unwrap();
3487
3488 // RemoveKey branch wins → k0 gone, only k1 remains; head = the RemoveKey AUM.
3489 assert_eq!(auth.state().keys.len(), 1);
3490 assert_eq!(auth.state().keys[0].public, k1.public);
3491 assert_eq!(
3492 auth.head(),
3493 branch_remove.hash(),
3494 "active head = RemoveKey branch"
3495 );
3496 }
3497
3498 /// End-to-end: replay a chain to an `Authority`, then verify it authorizes a node key signed by a
3499 /// trusted key — proving the replayed state drives `node_key_authorized` identically to
3500 /// `from_state`. A key removed by the chain no longer authorizes.
3501 #[test]
3502 fn replayed_authority_authorizes_node_end_to_end() {
3503 use ed25519_dalek::{Signer, SigningKey};
3504
3505 let signing = SigningKey::from_bytes(&[77u8; 32]);
3506 let trusted_pub = signing.verifying_key().to_bytes().to_vec();
3507 let trusted = AumKey {
3508 kind: KeyKind::Ed25519,
3509 votes: 1,
3510 public: trusted_pub.clone(),
3511 meta: Vec::new(),
3512 };
3513 // A second key we'll add then remove, to show a removed key can't authorize.
3514 let revoked_signing = SigningKey::from_bytes(&[88u8; 32]);
3515 let revoked_pub = revoked_signing.verifying_key().to_bytes().to_vec();
3516 let revoked = AumKey {
3517 kind: KeyKind::Ed25519,
3518 votes: 1,
3519 public: revoked_pub.clone(),
3520 meta: Vec::new(),
3521 };
3522
3523 let a0 = genesis_add(trusted);
3524 let a1 = child(&a0, AumKind::AddKey, Some(revoked), Vec::new());
3525 let a2 = child(&a1, AumKind::RemoveKey, None, revoked_pub.clone());
3526 let auth = Authority::from_chain(&[a0, a1, a2]).unwrap();
3527
3528 let node_key = alloc::vec![7u8; 32];
3529 // Signature from the still-trusted key authorizes.
3530 let mut sig = NodeKeySignature {
3531 sig_kind: SigKind::Direct,
3532 pubkey: node_key.clone(),
3533 key_id: trusted_pub.clone(),
3534 signature: Vec::new(),
3535 nested: None,
3536 wrapping_pubkey: Vec::new(),
3537 };
3538 sig.signature = signing.sign(&sig.sig_hash()).to_bytes().to_vec();
3539 assert!(
3540 auth.node_key_authorized(&node_key, &sig.to_cbor(true).to_vec())
3541 .is_ok(),
3542 "the replayed authority must authorize a node signed by a still-trusted key"
3543 );
3544
3545 // The same node key signed by the REVOKED key must be rejected (key no longer in state).
3546 let mut bad = NodeKeySignature {
3547 sig_kind: SigKind::Direct,
3548 pubkey: node_key.clone(),
3549 key_id: revoked_pub.clone(),
3550 signature: Vec::new(),
3551 nested: None,
3552 wrapping_pubkey: Vec::new(),
3553 };
3554 bad.signature = revoked_signing.sign(&bad.sig_hash()).to_bytes().to_vec();
3555 assert_eq!(
3556 auth.node_key_authorized(&node_key, &bad.to_cbor(true).to_vec())
3557 .unwrap_err(),
3558 TkaError::UntrustedKey,
3559 "a key the chain removed must not authorize"
3560 );
3561 }
3562
3563 /// Genesis-kind guard (Go `computeStateAt` "invalid genesis update"): a chain whose first AUM is
3564 /// a `RemoveKey`/`UpdateKey` is rejected. (A genesis `NoOp`/`AddKey`/`Checkpoint` is allowed.)
3565 #[test]
3566 fn replay_rejects_invalid_genesis_kind() {
3567 // A bare RemoveKey as genesis: no key to remove → today this is BadKeyState, but the genesis
3568 // guard catches an UpdateKey before the key lookup. Use UpdateKey to exercise the guard arm.
3569 let mut g = genesis_add(test_aum_key(1, 1));
3570 g.message_kind = AumKind::UpdateKey;
3571 g.key = None;
3572 g.key_id = test_aum_key(1, 1).public.clone();
3573 assert_eq!(
3574 Authority::from_chain(&[g]).unwrap_err(),
3575 TkaError::BadChain,
3576 "an UpdateKey cannot be a genesis AUM"
3577 );
3578 }
3579
3580 /// Genesis must carry no parent: a first AUM with a non-None `prev_aum_hash` (i.e. a chain
3581 /// *suffix* mis-supplied as a whole chain) is rejected as `BadParent`, not silently re-rooted.
3582 #[test]
3583 fn replay_rejects_genesis_with_parent() {
3584 let mut g = genesis_add(test_aum_key(1, 1));
3585 g.prev_aum_hash = Some(AumHash([0x11; 32])); // names a parent not in the slice
3586 assert_eq!(
3587 Authority::from_chain(&[g]).unwrap_err(),
3588 TkaError::BadParent,
3589 "a genesis AUM that names a parent must be rejected (not treated as genesis)"
3590 );
3591 }
3592
3593 /// Checkpoint StateID guard (Go "checkpointed state has an incorrect stateID"): a genesis
3594 /// checkpoint seeds the StateID; a later checkpoint with a different StateID is rejected.
3595 #[test]
3596 fn replay_rejects_checkpoint_stateid_mismatch() {
3597 let k = test_aum_key(1, 1);
3598 // Genesis checkpoint seeds StateID (7, 0).
3599 let genesis = Aum {
3600 message_kind: AumKind::Checkpoint,
3601 prev_aum_hash: None,
3602 key: None,
3603 key_id: Vec::new(),
3604 state: Some(AumState {
3605 last_aum_hash: None,
3606 disablement_values: Some(Vec::new()),
3607 keys: Some(alloc::vec![k.clone()]),
3608 state_id1: 7,
3609 state_id2: 0,
3610 }),
3611 votes: None,
3612 meta: Vec::new(),
3613 signatures: Vec::new(),
3614 };
3615 // A second checkpoint, correctly chained, but with a FOREIGN StateID (8, 0).
3616 let bad = Aum {
3617 message_kind: AumKind::Checkpoint,
3618 prev_aum_hash: Some(genesis.hash()),
3619 key: None,
3620 key_id: Vec::new(),
3621 state: Some(AumState {
3622 last_aum_hash: Some(genesis.hash()),
3623 disablement_values: Some(Vec::new()),
3624 keys: Some(alloc::vec![k.clone()]),
3625 state_id1: 8, // ← mismatch
3626 state_id2: 0,
3627 }),
3628 votes: None,
3629 meta: Vec::new(),
3630 signatures: Vec::new(),
3631 };
3632 assert_eq!(
3633 Authority::from_chain(&[genesis.clone(), bad]).unwrap_err(),
3634 TkaError::BadKeyState,
3635 "a checkpoint with a foreign StateID belongs to another authority and must be rejected"
3636 );
3637 // A matching-StateID second checkpoint is accepted.
3638 let ok = Aum {
3639 message_kind: AumKind::Checkpoint,
3640 prev_aum_hash: Some(genesis.hash()),
3641 key: None,
3642 key_id: Vec::new(),
3643 state: Some(AumState {
3644 last_aum_hash: Some(genesis.hash()),
3645 disablement_values: Some(Vec::new()),
3646 keys: Some(alloc::vec![k]),
3647 state_id1: 7,
3648 state_id2: 0,
3649 }),
3650 votes: None,
3651 meta: Vec::new(),
3652 signatures: Vec::new(),
3653 };
3654 assert!(Authority::from_chain(&[genesis, ok]).is_ok());
3655 }
3656
3657 /// `from_forked_chain` rejects a multi-step branch rather than mis-resolving it (Go re-runs
3658 /// pickNextAUM per link; judging a whole branch by its first AUM could diverge).
3659 #[test]
3660 fn forked_chain_rejects_multistep_branch() {
3661 let k0 = test_aum_key(1, 1);
3662 let a0 = genesis_add(k0.clone());
3663 let b1 = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
3664 // A two-AUM branch (b1 → b2): must be rejected as BadChain.
3665 let b2 = child(&b1, AumKind::NoOp, None, alloc::vec![2]);
3666 let single = [child(&a0, AumKind::NoOp, None, alloc::vec![3])];
3667 let multi = [b1, b2];
3668 assert_eq!(
3669 Authority::from_forked_chain(&[a0], &[&single[..], &multi[..]]).unwrap_err(),
3670 TkaError::BadChain,
3671 "a multi-step branch must be rejected, not judged by its first AUM"
3672 );
3673 }
3674
3675 /// Cross-implementation Known-Answer-Test for the **AUM** type: [`Aum::serialize`],
3676 /// [`Aum::hash`] (Go `AUM.Hash`), and [`Aum::sig_hash`] (Go `AUM.SigHash`) must byte-match the
3677 /// REAL `tailscale.com/tka` package, version **v1.100.0** (toolchain **go1.26.3+**).
3678 ///
3679 /// Provenance: every golden below is authoritative upstream output produced by the Go generator
3680 /// at `tests/vectors/gen/tka/main.go` (which imports the real `tailscale.com/tka`, builds one
3681 /// `tka.AUM` per `MessageKind`, and dumps `AUM.Serialize()`/`AUM.Hash()`/`AUM.SigHash()` hex).
3682 /// The same values are committed for provenance at `tests/vectors/tka_aum_hash_golden.json`.
3683 /// This is the missing half of axis-B for AUM: the sibling
3684 /// [`aum_serialize_matches_go_test_serialization_vectors`] test pins Go's *Serialize()* literals
3685 /// from `tka/aum_test.go`, but no Go-produced *AUM.Hash()* digest was pinned until now — so an
3686 /// error in the BLAKE2s-over-canonical-CBOR digest (the value that links the whole chain and is
3687 /// signed) would have gone undetected. Here `hash`/`sig_hash` are pinned to Go directly.
3688 ///
3689 /// Covered kinds: AddKey (genesis, with a real Key25519 + meta), RemoveKey, UpdateKey
3690 /// (votes+meta), a signed AddKey (Signatures at CBOR key 23), and a Checkpoint with a populated
3691 /// `State`. The signed AUM additionally proves `hash() != sig_hash()` — i.e. `Hash()` covers the
3692 /// signatures and `SigHash()` excludes them, exactly as Go's `AUM.SigHash` nils `Signatures`
3693 /// before serializing.
3694 #[test]
3695 fn aum_hash_sighash_matches_go_golden() {
3696 // Deterministic field material — identical to the Go generator's inputs.
3697 let prev = AumHash({
3698 let mut a = [0u8; AUM_HASH_LEN];
3699 let mut i = 0;
3700 while i < AUM_HASH_LEN {
3701 a[i] = 0x20u8.wrapping_add(i as u8);
3702 i += 1;
3703 }
3704 a
3705 });
3706 let key_pub: Vec<u8> = (0..32u16).map(|i| 0x40u8.wrapping_add(i as u8)).collect();
3707 let key_pub2: Vec<u8> = (0..32u16).map(|i| 0x60u8.wrapping_add(i as u8)).collect();
3708 let sig_bytes: Vec<u8> = (0..64u16).map(|i| 0x80u8.wrapping_add(i as u8)).collect();
3709
3710 // Assert one AUM's serialize/hash/sig_hash against the authoritative Go hex.
3711 let check = |label: &str, aum: &Aum, ser_hex: &str, hash_hex: &str, sig_hash_hex: &str| {
3712 assert_eq!(
3713 hex(&aum.serialize()),
3714 ser_hex,
3715 "{label}: Aum::serialize diverged from Go tka v1.100.0"
3716 );
3717 assert_eq!(
3718 hex(&aum.hash().0),
3719 hash_hex,
3720 "{label}: Aum::hash (Go AUM.Hash) diverged from Go tka v1.100.0"
3721 );
3722 assert_eq!(
3723 hex(&aum.sig_hash()),
3724 sig_hash_hex,
3725 "{label}: Aum::sig_hash (Go AUM.SigHash) diverged from Go tka v1.100.0"
3726 );
3727 };
3728
3729 // (a) AddKey genesis (nil prev) with a real Key25519 + meta {"name":"alpha"}.
3730 let add_key = Aum {
3731 message_kind: AumKind::AddKey,
3732 prev_aum_hash: None,
3733 key: Some(AumKey {
3734 kind: KeyKind::Ed25519,
3735 votes: 7,
3736 public: key_pub.clone(),
3737 meta: alloc::vec![("name".into(), "alpha".into())],
3738 }),
3739 key_id: Vec::new(),
3740 state: None,
3741 votes: None,
3742 meta: Vec::new(),
3743 signatures: Vec::new(),
3744 };
3745 check(
3746 "AddKey",
3747 &add_key,
3748 "a3010102f603a401010207035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f0ca1646e616d6565616c706861",
3749 "921ca301077ae2b892ca8c40b3315e5f2a9ccc9ac99eec784e93b323577e1e14",
3750 "921ca301077ae2b892ca8c40b3315e5f2a9ccc9ac99eec784e93b323577e1e14",
3751 );
3752
3753 // (b) RemoveKey with a non-nil prev.
3754 let remove_key = Aum {
3755 message_kind: AumKind::RemoveKey,
3756 prev_aum_hash: Some(prev),
3757 key: None,
3758 key_id: key_pub.clone(),
3759 state: None,
3760 votes: None,
3761 meta: Vec::new(),
3762 signatures: Vec::new(),
3763 };
3764 check(
3765 "RemoveKey",
3766 &remove_key,
3767 "a30102025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f045820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
3768 "46be17c398760d2e649147b06a68e8576ee37c59cf0558046182f48ba20aa912",
3769 "46be17c398760d2e649147b06a68e8576ee37c59cf0558046182f48ba20aa912",
3770 );
3771
3772 // (c) UpdateKey with votes=2 + meta {"role":"ci"}.
3773 let update_key = Aum {
3774 message_kind: AumKind::UpdateKey,
3775 prev_aum_hash: Some(prev),
3776 key: None,
3777 key_id: key_pub.clone(),
3778 state: None,
3779 votes: Some(2),
3780 meta: alloc::vec![("role".into(), "ci".into())],
3781 signatures: Vec::new(),
3782 };
3783 check(
3784 "UpdateKey",
3785 &update_key,
3786 "a50104025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f045820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f060207a164726f6c65626369",
3787 "42d160d81a0922511b4ce60050dba76569f79423b6e721c5040faf414c250e43",
3788 "42d160d81a0922511b4ce60050dba76569f79423b6e721c5040faf414c250e43",
3789 );
3790
3791 // (e) AddKey carrying one Signature (CBOR key 23). hash (incl sigs) MUST differ from
3792 // sig_hash (excl sigs) — the property the whole signing scheme depends on.
3793 let signed = Aum {
3794 message_kind: AumKind::AddKey,
3795 prev_aum_hash: Some(prev),
3796 key: Some(AumKey {
3797 kind: KeyKind::Ed25519,
3798 votes: 1,
3799 public: key_pub.clone(),
3800 meta: Vec::new(),
3801 }),
3802 key_id: Vec::new(),
3803 state: None,
3804 votes: None,
3805 meta: Vec::new(),
3806 signatures: alloc::vec![AumSignature {
3807 key_id: key_pub2.clone(),
3808 signature: sig_bytes.clone(),
3809 }],
3810 };
3811 check(
3812 "AddKey+Signature",
3813 &signed,
3814 "a40101025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f03a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f1781a2015820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f025840808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
3815 "e70332d9a03b205577204f1896bb8dcb7c8f8894cc87a5b5c4d5dabcdf6ef135",
3816 "0a7a0ecdf854ad99e8728a1de89ac23c1f08457132a537a3add9594749a7f536",
3817 );
3818 assert_ne!(
3819 hex(&signed.hash().0),
3820 hex(&signed.sig_hash()),
3821 "Hash() must cover Signatures while SigHash() excludes them (Go AUM.SigHash nils them)"
3822 );
3823
3824 // (f) Checkpoint carrying a State with a POPULATED DisablementValues [{0xaa,0xbb}] + 1 key.
3825 // This is the common real shape and matches Go byte-for-byte (the array encoding is correct).
3826 let checkpoint = Aum {
3827 message_kind: AumKind::Checkpoint,
3828 prev_aum_hash: Some(prev),
3829 key: None,
3830 key_id: Vec::new(),
3831 state: Some(AumState {
3832 last_aum_hash: Some(prev),
3833 disablement_values: Some(alloc::vec![alloc::vec![0xaa, 0xbb]]),
3834 keys: Some(alloc::vec![AumKey {
3835 kind: KeyKind::Ed25519,
3836 votes: 1,
3837 public: key_pub.clone(),
3838 meta: Vec::new(),
3839 }]),
3840 state_id1: 0,
3841 state_id2: 0,
3842 }),
3843 votes: None,
3844 meta: Vec::new(),
3845 signatures: Vec::new(),
3846 };
3847 check(
3848 "Checkpoint(populated DisablementValues)",
3849 &checkpoint,
3850 "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f028142aabb0381a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
3851 "38c35c51580dcb75212d79c07695c8ec8b399b59ba552ea93f080b126fdaa0ae",
3852 "38c35c51580dcb75212d79c07695c8ec8b399b59ba552ea93f080b126fdaa0ae",
3853 );
3854 }
3855
3856 /// Go-match golden for the nil-`DisablementValues` checkpoint — the case that was a recorded
3857 /// interop bug (Rust forced an empty array `0x80` where Go emits CBOR null `0xf6`) and is now
3858 /// FIXED by making `AumState.{disablement_values,keys}` `Option` (None = Go nil = `0xf6`).
3859 ///
3860 /// When an `AUMCheckpoint`'s embedded `State` has a **nil** `DisablementValues` (Go's zero value,
3861 /// the overwhelmingly common case), Go's `fxamacker/cbor` CTAP2 encoder emits the field as
3862 /// **CBOR null `0xf6`**; a populated slice encodes as an array (proven by the populated case in
3863 /// [`aum_hash_sighash_matches_go_golden`]). This test pins the Go bytes + Hash for the nil case
3864 /// and asserts the Rust output now byte-matches — guarding the fix against regression.
3865 #[test]
3866 fn aum_checkpoint_nil_disablement_matches_go() {
3867 let prev = AumHash({
3868 let mut a = [0u8; AUM_HASH_LEN];
3869 let mut i = 0;
3870 while i < AUM_HASH_LEN {
3871 a[i] = 0x20u8.wrapping_add(i as u8);
3872 i += 1;
3873 }
3874 a
3875 });
3876 let key_pub: Vec<u8> = (0..32u16).map(|i| 0x40u8.wrapping_add(i as u8)).collect();
3877 let key_pub2: Vec<u8> = (0..32u16).map(|i| 0x60u8.wrapping_add(i as u8)).collect();
3878
3879 // Checkpoint with a State whose DisablementValues is EMPTY (== Go nil zero value), 2 keys.
3880 let checkpoint = Aum {
3881 message_kind: AumKind::Checkpoint,
3882 prev_aum_hash: Some(prev),
3883 key: None,
3884 key_id: Vec::new(),
3885 state: Some(AumState {
3886 last_aum_hash: Some(prev),
3887 disablement_values: Some(Vec::new()),
3888 keys: Some(alloc::vec![
3889 AumKey {
3890 kind: KeyKind::Ed25519,
3891 votes: 1,
3892 public: key_pub.clone(),
3893 meta: Vec::new(),
3894 },
3895 AumKey {
3896 kind: KeyKind::Ed25519,
3897 votes: 3,
3898 public: key_pub2.clone(),
3899 meta: alloc::vec![("k".into(), "v".into())],
3900 },
3901 ]),
3902 state_id1: 0,
3903 state_id2: 0,
3904 }),
3905 votes: None,
3906 meta: Vec::new(),
3907 signatures: Vec::new(),
3908 };
3909
3910 // Authoritative Go bytes (generator case "checkpoint: State w/ nil DisablementValues"):
3911 // the State map is `…02 f6 03 82 …` — a NIL DisablementValues encodes as CBOR null (0xf6).
3912 // FIXED: `AumState.disablement_values` is now `Option`, so the nil case (`None`) is
3913 // representable and encodes as null, byte-matching Go. (Was a recorded interop bug where the
3914 // `Vec` type forced an empty array `0x80` and diverged the checkpoint Hash from Go.)
3915 const GO_SERIALIZE: &str = "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f02f60382a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5fa401010203035820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f0ca1616b6176";
3916 const GO_HASH: &str = "cae17cc938c5a954cd4389d83c6afe4d3487edac38b94824bec3312b82f35710";
3917
3918 // Re-point the checkpoint's State to a genuinely-nil DisablementValues (`None`), which is the
3919 // case the Go golden above was generated from.
3920 let checkpoint = {
3921 let mut c = checkpoint;
3922 if let Some(state) = c.state.as_mut() {
3923 state.disablement_values = None;
3924 }
3925 c
3926 };
3927
3928 assert_eq!(
3929 hex(&checkpoint.serialize()),
3930 GO_SERIALIZE,
3931 "nil DisablementValues must encode as CBOR null (0xf6), byte-matching Go"
3932 );
3933 assert_eq!(
3934 hex(&checkpoint.hash().0),
3935 GO_HASH,
3936 "with the nil-vs-empty fix, the checkpoint chain-link Hash matches Go"
3937 );
3938 }
3939
3940 // =======================================================================================
3941 // MUST-1: AUM signature verification (`VerifiedAumChain` / Go `aumVerify`). The trust
3942 // boundary for a control-supplied chain — an AUM may advance the trusted-key state only if
3943 // every signature on it verifies against a key already trusted at its parent.
3944 // =======================================================================================
3945
3946 /// The signing key whose public key `test_aum_key(seed, _)` derives — so a key trusted via
3947 /// `test_aum_key(seed, v)` can be made to actually sign an AUM.
3948 fn signer_for(seed: u8) -> ed25519_dalek::SigningKey {
3949 ed25519_dalek::SigningKey::from_bytes(&[seed; 32])
3950 }
3951
3952 /// Sign `aum` with each `(seed)` signer, appending a real `AumSignature` over `aum.sig_hash()`.
3953 /// The signer's public key is `test_aum_key(seed, _).public`, so signing with `seed` produces a
3954 /// signature that a state trusting `test_aum_key(seed, _)` will accept.
3955 fn sign_aum(aum: &mut Aum, seeds: &[u8]) {
3956 use ed25519_dalek::Signer;
3957 let sig_hash = aum.sig_hash();
3958 for &seed in seeds {
3959 let signer = signer_for(seed);
3960 aum.signatures.push(AumSignature {
3961 key_id: signer.verifying_key().to_bytes().to_vec(),
3962 signature: signer.sign(&sig_hash).to_bytes().to_vec(),
3963 });
3964 }
3965 }
3966
3967 /// A genesis `AddKey` that adds `test_aum_key(seed, votes)` and is self-signed by that very key
3968 /// — the bootstrapping shape (Go verifies a genesis against the keys it itself establishes).
3969 fn signed_genesis_add(seed: u8, votes: u32) -> Aum {
3970 let mut g = genesis_add(test_aum_key(seed, votes));
3971 sign_aum(&mut g, &[seed]);
3972 g
3973 }
3974
3975 /// Happy path: a self-signed genesis followed by a child signed by the trusted genesis key
3976 /// verifies, and `from_verified_chain` yields the same state as the structural `from_chain`.
3977 #[test]
3978 fn verified_chain_accepts_properly_signed_chain() {
3979 let g = signed_genesis_add(1, 1);
3980 // Child adds a second key, signed by the trusted key from the genesis (seed 1).
3981 let mut a1 = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
3982 sign_aum(&mut a1, &[1]);
3983
3984 let chain = [g.clone(), a1.clone()];
3985 let verified = VerifiedAumChain::verify(&chain).expect("a properly signed chain verifies");
3986 let auth = Authority::from_verified_chain(verified);
3987
3988 assert_eq!(auth.head(), a1.hash(), "head = last AUM");
3989 assert_eq!(auth.state().keys.len(), 2, "both keys trusted");
3990 // The verified-path state must equal the structural-path state for an authentic chain.
3991 let structural = Authority::from_chain(&chain).unwrap();
3992 assert_eq!(auth.state(), structural.state());
3993 assert_eq!(auth.head(), structural.head());
3994 }
3995
3996 /// An unsigned AUM (no signatures at all) is rejected — Go `aumVerify` "unsigned AUM". This
3997 /// holds even for the genesis.
3998 #[test]
3999 fn verified_chain_rejects_unsigned_aum() {
4000 // Unsigned genesis.
4001 let g = genesis_add(test_aum_key(1, 1));
4002 assert_eq!(
4003 VerifiedAumChain::verify(core::slice::from_ref(&g)).unwrap_err(),
4004 TkaError::UnsignedAum,
4005 "an unsigned genesis must be rejected"
4006 );
4007
4008 // Signed genesis, but an unsigned child.
4009 let sg = signed_genesis_add(1, 1);
4010 let a1 = child(&sg, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4011 assert_eq!(
4012 VerifiedAumChain::verify(&[sg, a1]).unwrap_err(),
4013 TkaError::UnsignedAum,
4014 "an unsigned non-genesis AUM must be rejected"
4015 );
4016 }
4017
4018 /// THE headline security property: a malicious control plane inserts an `AddKey` that adds the
4019 /// attacker's own key, signed by the attacker (a key NOT trusted in the current state). MUST-1
4020 /// rejects it as `UntrustedKey` — so the forged key never reaches a live `Authority`. Without
4021 /// the signature gate, `from_chain` would happily fold it (demonstrated) — which is exactly the
4022 /// tailnet-lock-defeating forgery the type-enforced `VerifiedAumChain` prevents.
4023 #[test]
4024 fn verified_chain_rejects_forged_addkey_from_untrusted_signer() {
4025 let g = signed_genesis_add(1, 1); // only key seed=1 is trusted
4026 // Attacker forges an AddKey inserting their own key (seed 9), signed by seed 9 (untrusted).
4027 let mut forged = child(&g, AumKind::AddKey, Some(test_aum_key(9, 99)), Vec::new());
4028 sign_aum(&mut forged, &[9]);
4029
4030 assert_eq!(
4031 VerifiedAumChain::verify(&[g.clone(), forged.clone()]).unwrap_err(),
4032 TkaError::UntrustedKey,
4033 "an AddKey signed only by an untrusted key must be rejected"
4034 );
4035 // Contrast: the structural-only `from_chain` (NOT a trust boundary) DOES fold the forgery,
4036 // proving why the type-enforced verified path is necessary.
4037 let structural = Authority::from_chain(&[g, forged]).unwrap();
4038 assert_eq!(
4039 structural.state().keys.len(),
4040 2,
4041 "structural from_chain folds the forged key — exactly why it is not a trust boundary"
4042 );
4043 }
4044
4045 /// A signature whose `key_id` IS trusted but whose bytes were produced over different content
4046 /// (here: signed by the wrong private key but labelled with the trusted key's id) fails the
4047 /// cryptographic check → `BadSignature`.
4048 #[test]
4049 fn verified_chain_rejects_tampered_signature() {
4050 let g = signed_genesis_add(1, 1);
4051 let mut a1 = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4052 // Label the signature with the trusted key's id (seed 1) but sign with the WRONG key.
4053 use ed25519_dalek::Signer;
4054 let wrong = signer_for(42);
4055 a1.signatures.push(AumSignature {
4056 key_id: signer_for(1).verifying_key().to_bytes().to_vec(),
4057 signature: wrong.sign(&a1.sig_hash()).to_bytes().to_vec(),
4058 });
4059 assert_eq!(
4060 VerifiedAumChain::verify(&[g, a1]).unwrap_err(),
4061 TkaError::BadSignature,
4062 "a signature that doesn't verify under the named trusted key is rejected"
4063 );
4064 }
4065
4066 /// Every signature must verify (Go loops over all, failing on the first bad one): a child with
4067 /// one valid trusted signature AND one bad/untrusted signature is still rejected.
4068 #[test]
4069 fn verified_chain_requires_all_signatures_valid() {
4070 let g = signed_genesis_add(1, 1);
4071 let mut a1 = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4072 // First a valid signature by the trusted key, then a second by an untrusted key.
4073 sign_aum(&mut a1, &[1]); // valid (seed 1 trusted)
4074 sign_aum(&mut a1, &[7]); // untrusted (seed 7 not in state)
4075 assert_eq!(
4076 VerifiedAumChain::verify(&[g, a1]).unwrap_err(),
4077 TkaError::UntrustedKey,
4078 "a single untrusted signature rejects the AUM even alongside a valid one"
4079 );
4080 }
4081
4082 /// A genesis `Checkpoint` self-certifies against the keys it embeds (Go
4083 /// `aumVerify(bootstrap, *bootstrap.State, true)`): the checkpoint's signature must verify
4084 /// against a key inside its own `State`. The embedded `State` must itself be Go-valid (≥1
4085 /// disablement value of 32 bytes, ≥1 key) — `static_validate_checkpoint` enforces that.
4086 #[test]
4087 fn verified_chain_genesis_checkpoint_self_certifies() {
4088 let trusted = test_aum_key(1, 1);
4089 let mut g = Aum {
4090 message_kind: AumKind::Checkpoint,
4091 prev_aum_hash: None,
4092 key: None,
4093 key_id: Vec::new(),
4094 state: Some(AumState {
4095 last_aum_hash: None,
4096 // A valid checkpoint needs ≥1 disablement value, each exactly 32 bytes.
4097 disablement_values: Some(alloc::vec![alloc::vec![0xD5u8; DISABLEMENT_LENGTH]]),
4098 keys: Some(alloc::vec![trusted.clone()]),
4099 state_id1: 0,
4100 state_id2: 0,
4101 }),
4102 votes: None,
4103 meta: Vec::new(),
4104 signatures: Vec::new(),
4105 };
4106 // Unsigned → rejected.
4107 assert_eq!(
4108 VerifiedAumChain::verify(&[g.clone()]).unwrap_err(),
4109 TkaError::UnsignedAum
4110 );
4111 // Signed by the key embedded in its own State → accepted.
4112 sign_aum(&mut g, &[1]);
4113 let verified = VerifiedAumChain::verify(&[g.clone()])
4114 .expect("a checkpoint signed by an embedded key self-certifies");
4115 let auth = Authority::from_verified_chain(verified);
4116 assert_eq!(auth.state().keys.len(), 1);
4117 assert_eq!(auth.head(), g.hash());
4118 }
4119
4120 /// A genesis `Checkpoint` whose embedded `State` is malformed is rejected by
4121 /// `static_validate_checkpoint` (Go `staticValidateCheckpoint`), before any signature check.
4122 #[test]
4123 fn verified_chain_rejects_malformed_checkpoint_state() {
4124 let trusted = test_aum_key(1, 1);
4125 let mk = |state: AumState| {
4126 let mut g = Aum {
4127 message_kind: AumKind::Checkpoint,
4128 prev_aum_hash: None,
4129 key: None,
4130 key_id: Vec::new(),
4131 state: Some(state),
4132 votes: None,
4133 meta: Vec::new(),
4134 signatures: Vec::new(),
4135 };
4136 sign_aum(&mut g, &[1]);
4137 g
4138 };
4139 let base = AumState {
4140 last_aum_hash: None,
4141 disablement_values: Some(alloc::vec![alloc::vec![0xD5u8; DISABLEMENT_LENGTH]]),
4142 keys: Some(alloc::vec![trusted.clone()]),
4143 state_id1: 0,
4144 state_id2: 0,
4145 };
4146
4147 // No disablement values → rejected.
4148 let no_disable = AumState {
4149 disablement_values: None,
4150 ..base.clone()
4151 };
4152 assert_eq!(
4153 VerifiedAumChain::verify(&[mk(no_disable)]).unwrap_err(),
4154 TkaError::BadKeyState,
4155 "a checkpoint with no disablement value is rejected"
4156 );
4157
4158 // Disablement value of the wrong length → rejected.
4159 let bad_len = AumState {
4160 disablement_values: Some(alloc::vec![alloc::vec![0u8; 16]]),
4161 ..base.clone()
4162 };
4163 assert_eq!(
4164 VerifiedAumChain::verify(&[mk(bad_len)]).unwrap_err(),
4165 TkaError::BadKeyState,
4166 "a disablement value of the wrong length is rejected"
4167 );
4168
4169 // No keys → rejected.
4170 let no_keys = AumState {
4171 keys: Some(Vec::new()),
4172 ..base.clone()
4173 };
4174 assert_eq!(
4175 VerifiedAumChain::verify(&[mk(no_keys)]).unwrap_err(),
4176 TkaError::BadKeyState,
4177 "a checkpoint with no keys is rejected"
4178 );
4179
4180 // Duplicate keys → rejected.
4181 let dup_keys = AumState {
4182 keys: Some(alloc::vec![trusted.clone(), trusted.clone()]),
4183 ..base.clone()
4184 };
4185 assert_eq!(
4186 VerifiedAumChain::verify(&[mk(dup_keys)]).unwrap_err(),
4187 TkaError::BadKeyState,
4188 "a checkpoint with duplicate key ids is rejected"
4189 );
4190
4191 // F5: a NON-adjacent duplicate ([a, b, a]) is also caught (the prefix-scan dedup checks all
4192 // earlier elements, not just the neighbor).
4193 let nonadjacent_dup = AumState {
4194 keys: Some(alloc::vec![
4195 test_aum_key(1, 1),
4196 test_aum_key(2, 1),
4197 test_aum_key(1, 1)
4198 ]),
4199 ..base.clone()
4200 };
4201 assert_eq!(
4202 VerifiedAumChain::verify(&[mk(nonadjacent_dup)]).unwrap_err(),
4203 TkaError::BadKeyState,
4204 "a non-adjacent duplicate key id is rejected"
4205 );
4206
4207 // F4: more than MAX_KEYS (512) keys → rejected. Use distinct 32-byte public keys (index-
4208 // encoded) so there are no duplicate ids — only the `> MAX_KEYS` cap can be the failure.
4209 let distinct_over_cap: alloc::vec::Vec<AumKey> = (0u32..=(MAX_KEYS as u32))
4210 .map(|i| AumKey {
4211 kind: KeyKind::Ed25519,
4212 votes: 1,
4213 // Distinct 32-byte public keys by encoding the index — no dup, so only the >512 cap trips.
4214 public: {
4215 let mut p = alloc::vec![0u8; 32];
4216 p[0..4].copy_from_slice(&i.to_le_bytes());
4217 p
4218 },
4219 meta: Vec::new(),
4220 })
4221 .collect();
4222 assert_eq!(distinct_over_cap.len(), MAX_KEYS + 1);
4223 let over_keys = AumState {
4224 keys: Some(distinct_over_cap),
4225 ..base.clone()
4226 };
4227 assert_eq!(
4228 VerifiedAumChain::verify(&[mk(over_keys)]).unwrap_err(),
4229 TkaError::BadKeyState,
4230 "a checkpoint with > MAX_KEYS keys is rejected"
4231 );
4232
4233 // F4: more than MAX_DISABLEMENT_VALUES (32) disablement values → rejected (each distinct).
4234 let over_disablements = AumState {
4235 disablement_values: Some(
4236 (0u8..=(MAX_DISABLEMENT_VALUES as u8))
4237 .map(|i| {
4238 let mut d = alloc::vec![0u8; DISABLEMENT_LENGTH];
4239 d[0] = i;
4240 d
4241 })
4242 .collect(),
4243 ),
4244 ..base
4245 };
4246 assert_eq!(
4247 VerifiedAumChain::verify(&[mk(over_disablements)]).unwrap_err(),
4248 TkaError::BadKeyState,
4249 "a checkpoint with > MAX_DISABLEMENT_VALUES disablement values is rejected"
4250 );
4251 }
4252
4253 /// A broken parent link is still caught on the verified path (the structural fold runs after the
4254 /// signature check for non-genesis AUMs).
4255 #[test]
4256 fn verified_chain_rejects_broken_parent_link() {
4257 let g = signed_genesis_add(1, 1);
4258 let mut orphan = child(&g, AumKind::NoOp, None, alloc::vec![9]);
4259 orphan.prev_aum_hash = Some(AumHash([0xAB; 32])); // wrong parent
4260 sign_aum(&mut orphan, &[1]); // validly signed, but mis-linked
4261 assert_eq!(
4262 VerifiedAumChain::verify(&[g, orphan]).unwrap_err(),
4263 TkaError::BadParent,
4264 "a validly-signed but mis-linked AUM is still rejected"
4265 );
4266 }
4267
4268 // ===== Aum::from_cbor — the decode inverse of Aum::serialize (issue #7 chunk 2, tsr-2dr) =====
4269
4270 /// `Aum::from_cbor(aum.serialize())` reconstructs the exact `Aum` for every message kind and
4271 /// optional-field combination. This is the core round-trip contract the sync/bootstrap path
4272 /// relies on: bytes control sends → `Aum` → verify/replay.
4273 #[test]
4274 fn aum_from_cbor_roundtrips_every_shape() {
4275 let cases: alloc::vec::Vec<(&str, Aum)> = alloc::vec![
4276 (
4277 "RemoveKey, genesis (null prev), key_id",
4278 Aum {
4279 message_kind: AumKind::RemoveKey,
4280 prev_aum_hash: None,
4281 key: None,
4282 key_id: alloc::vec![1, 2],
4283 state: None,
4284 votes: None,
4285 meta: Vec::new(),
4286 signatures: Vec::new(),
4287 },
4288 ),
4289 (
4290 "UpdateKey with votes + meta (text-keyed map)",
4291 Aum {
4292 message_kind: AumKind::UpdateKey,
4293 prev_aum_hash: None,
4294 key: None,
4295 key_id: alloc::vec![1, 2],
4296 state: None,
4297 votes: Some(2),
4298 meta: alloc::vec![("a".into(), "b".into())],
4299 signatures: Vec::new(),
4300 },
4301 ),
4302 (
4303 "AddKey with an embedded Key + non-null prev + signatures",
4304 Aum {
4305 message_kind: AumKind::AddKey,
4306 prev_aum_hash: Some(AumHash([0x11; AUM_HASH_LEN])),
4307 key: Some(AumKey {
4308 kind: KeyKind::Ed25519,
4309 votes: 3,
4310 public: alloc::vec![9, 8, 7],
4311 meta: alloc::vec![("k".into(), "v".into())],
4312 }),
4313 key_id: Vec::new(),
4314 state: None,
4315 votes: None,
4316 meta: Vec::new(),
4317 signatures: alloc::vec![
4318 AumSignature {
4319 key_id: alloc::vec![1],
4320 signature: Vec::new(), // nil → null on the wire
4321 },
4322 AumSignature {
4323 key_id: alloc::vec![2, 3],
4324 signature: alloc::vec![4, 5, 6],
4325 },
4326 ],
4327 },
4328 ),
4329 (
4330 "Checkpoint with full State (null + empty-array + populated arms)",
4331 Aum {
4332 message_kind: AumKind::Checkpoint,
4333 prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
4334 key: None,
4335 key_id: Vec::new(),
4336 state: Some(AumState {
4337 last_aum_hash: Some(AumHash([0xAB; AUM_HASH_LEN])),
4338 disablement_values: Some(alloc::vec![alloc::vec![1, 2], alloc::vec![3]]),
4339 keys: Some(alloc::vec![AumKey {
4340 kind: KeyKind::Ed25519,
4341 votes: 1,
4342 public: alloc::vec![5, 6],
4343 meta: Vec::new(),
4344 }]),
4345 state_id1: 7,
4346 state_id2: 0, // omitted (omitempty)
4347 }),
4348 votes: None,
4349 meta: Vec::new(),
4350 signatures: Vec::new(),
4351 },
4352 ),
4353 (
4354 "Checkpoint with nil State arms (null) and empty disablement array",
4355 Aum {
4356 message_kind: AumKind::Checkpoint,
4357 prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
4358 key: None,
4359 key_id: Vec::new(),
4360 state: Some(AumState {
4361 last_aum_hash: None, // null
4362 disablement_values: Some(Vec::new()), // empty array 0x80
4363 keys: None, // null
4364 state_id1: 0,
4365 state_id2: 9,
4366 }),
4367 votes: None,
4368 meta: Vec::new(),
4369 signatures: Vec::new(),
4370 },
4371 ),
4372 (
4373 "NoOp, non-null prev, nothing else",
4374 Aum {
4375 message_kind: AumKind::NoOp,
4376 prev_aum_hash: Some(AumHash([0x42; AUM_HASH_LEN])),
4377 key: None,
4378 key_id: Vec::new(),
4379 state: None,
4380 votes: None,
4381 meta: Vec::new(),
4382 signatures: Vec::new(),
4383 },
4384 ),
4385 ];
4386
4387 for (label, aum) in cases {
4388 let bytes = aum.serialize();
4389 let decoded = Aum::from_cbor(&bytes)
4390 .unwrap_or_else(|e| panic!("from_cbor failed for {label:?}: {e}"));
4391 assert_eq!(decoded, aum, "round-trip mismatch for {label:?}");
4392 // And the decoded AUM re-serializes to the identical bytes (canonical-form preserved →
4393 // hash/sig_hash are stable across a decode/encode cycle, which the chain replayer needs).
4394 assert_eq!(
4395 decoded.serialize(),
4396 bytes,
4397 "re-serialize must be byte-identical for {label:?}"
4398 );
4399 assert_eq!(
4400 decoded.hash(),
4401 aum.hash(),
4402 "hash must survive round-trip for {label:?}"
4403 );
4404 }
4405 }
4406
4407 /// Decode the exact frozen Go `TestSerialization` byte vectors (the same literals asserted on the
4408 /// encode side) straight into `Aum`s — proving the decoder consumes real Go-produced bytes, not
4409 /// just our own encoder's output.
4410 #[test]
4411 fn aum_from_cbor_decodes_frozen_go_vectors() {
4412 // RemoveKey: a3 01 02 02 f6 04 42 01 02
4413 let remove_key = Aum::from_cbor(&[0xa3, 0x01, 0x02, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02])
4414 .expect("decode RemoveKey vector");
4415 assert_eq!(remove_key.message_kind, AumKind::RemoveKey);
4416 assert_eq!(remove_key.prev_aum_hash, None);
4417 assert_eq!(remove_key.key_id, alloc::vec![1, 2]);
4418
4419 // UpdateKey: a5 01 04 02 f6 04 42 01 02 06 02 07 a1 61 61 61 62
4420 let update_key = Aum::from_cbor(&[
4421 0xa5, 0x01, 0x04, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02, 0x06, 0x02, 0x07, 0xa1, 0x61,
4422 0x61, 0x61, 0x62,
4423 ])
4424 .expect("decode UpdateKey vector");
4425 assert_eq!(update_key.message_kind, AumKind::UpdateKey);
4426 assert_eq!(update_key.votes, Some(2));
4427 assert_eq!(
4428 update_key.meta,
4429 alloc::vec![(
4430 alloc::string::String::from("a"),
4431 alloc::string::String::from("b")
4432 )],
4433 "the text-keyed Meta map must decode to {{\"a\":\"b\"}}"
4434 );
4435
4436 // Signature: a3 01 01 02 f6 17 81 a2 01 41 01 02 f6
4437 let with_sig = Aum::from_cbor(&[
4438 0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6,
4439 ])
4440 .expect("decode Signature vector");
4441 assert_eq!(with_sig.message_kind, AumKind::AddKey);
4442 assert_eq!(with_sig.signatures.len(), 1);
4443 assert_eq!(with_sig.signatures[0].key_id, alloc::vec![1]);
4444 assert_eq!(
4445 with_sig.signatures[0].signature,
4446 Vec::<u8>::new(),
4447 "the nil Signature (CBOR null) must decode to an empty Vec"
4448 );
4449 // Byte-exact re-encode of every frozen vector.
4450 assert_eq!(
4451 with_sig.serialize(),
4452 alloc::vec![
4453 0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6
4454 ]
4455 );
4456 }
4457
4458 /// The `null` (0xf6) major-7 arm is accepted ONLY for null; other major-7 simple/float values
4459 /// are still rejected (fail-closed), and the `NodeKeySignature` path is unaffected because its
4460 /// `expect_bytes` rejects null where bytes are required.
4461 #[test]
4462 fn decode_value_accepts_only_null_in_major7() {
4463 // Bare null decodes.
4464 assert_eq!(decode_value(&[0xf6], 0).unwrap().0, Value::Null);
4465 // true (0xf5), false (0xf4), undefined (0xf7), a float64 (0xfb …) → rejected.
4466 for bad in [
4467 alloc::vec![0xf5u8],
4468 alloc::vec![0xf4],
4469 alloc::vec![0xf7],
4470 alloc::vec![0xfb, 0x40, 0x09, 0x21, 0xfb, 0x54, 0x44, 0x2d, 0x18],
4471 ] {
4472 assert!(
4473 decode_value(&bad, 0).is_err(),
4474 "major-7 value {bad:02x?} other than null must be rejected"
4475 );
4476 }
4477 }
4478
4479 /// `Aum::from_cbor` fails closed on malformed / adversarial input — never panics, never `Ok` on
4480 /// garbage. Complements the `cbor_decode_smoke` integration test (which targets the signature
4481 /// path) for the AUM path.
4482 #[test]
4483 fn aum_from_cbor_fails_closed() {
4484 // Empty input.
4485 assert!(Aum::from_cbor(&[]).is_err());
4486 // Not a map (a bare uint).
4487 assert!(Aum::from_cbor(&[0x00]).is_err());
4488 // Map missing the non-omitempty prev_aum_hash (only message_kind present): a1 01 03.
4489 assert!(
4490 Aum::from_cbor(&[0xa1, 0x01, 0x03]).is_err(),
4491 "an AUM without key 2 (prev_aum_hash) must be rejected (non-omitempty)"
4492 );
4493 // Unknown field key (99): a2 01 03 18 63 00.
4494 assert!(
4495 Aum::from_cbor(&[0xa2, 0x01, 0x03, 0x18, 0x63, 0x00]).is_err(),
4496 "an unknown AUM field key must be rejected"
4497 );
4498 // Unknown message kind (9): a2 01 09 02 f6.
4499 assert!(
4500 Aum::from_cbor(&[0xa2, 0x01, 0x09, 0x02, 0xf6]).is_err(),
4501 "an unknown message_kind must be rejected"
4502 );
4503 // Trailing byte after a complete AUM: (a2 01 03 02 f6) + 00.
4504 assert!(
4505 Aum::from_cbor(&[0xa2, 0x01, 0x03, 0x02, 0xf6, 0x00]).is_err(),
4506 "trailing bytes after the AUM must be rejected"
4507 );
4508 // prev_aum_hash present but wrong length (31 bytes) → rejected.
4509 let mut short_prev = alloc::vec![0xa2u8, 0x01, 0x03, 0x02, 0x58, 0x1f];
4510 short_prev.extend(core::iter::repeat_n(0u8, 31));
4511 assert!(
4512 Aum::from_cbor(&short_prev).is_err(),
4513 "a prev_aum_hash that is not 32 bytes must be rejected"
4514 );
4515 }
4516
4517 /// A text-keyed map (`Meta`) and an int-keyed map are distinguished on decode, and a mixed-key
4518 /// map is rejected (TKA emits no mixed-key maps).
4519 #[test]
4520 fn decode_map_rejects_mixed_key_types() {
4521 // map(2){ 1: 0, "a": "b" } — int key then text key. a2 01 00 61 61 61 62
4522 assert!(
4523 decode_value(&[0xa2, 0x01, 0x00, 0x61, 0x61, 0x61, 0x62], 0).is_err(),
4524 "a map mixing uint and text keys must be rejected"
4525 );
4526 // A pure text map decodes to TextMap.
4527 let (v, rest) = decode_value(&[0xa1, 0x61, 0x61, 0x61, 0x62], 0).unwrap();
4528 assert!(rest.is_empty());
4529 assert_eq!(
4530 v,
4531 Value::TextMap(alloc::vec![(b"a".to_vec(), Value::Text(b"b".to_vec()))])
4532 );
4533 }
4534
4535 // ===== Review follow-ups (PR #48 review): close decode coverage gaps =====
4536
4537 /// Gap 2 (highest value): decode the authoritative frozen **Go checkpoint** bytes — the most
4538 /// complex AUM shape (null `disablement_values` arm, two nested keys, the second carrying a
4539 /// `Meta`, 32-byte hashes). The encode side asserts these exact bytes
4540 /// (`aum_checkpoint_nil_disablement_matches_go`); here we prove the *decoder* consumes them and
4541 /// round-trips byte-identically (so `hash()` is stable), exercising `AumState::from_value` +
4542 /// nested `AumKey::from_value` against real Go output rather than our own encoder.
4543 #[test]
4544 fn aum_from_cbor_decodes_frozen_go_checkpoint() {
4545 const GO_SERIALIZE: &str = "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f02f60382a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5fa401010203035820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f0ca1616b6176";
4546 const GO_HASH: &str = "cae17cc938c5a954cd4389d83c6afe4d3487edac38b94824bec3312b82f35710";
4547 let bytes = unhex(GO_SERIALIZE);
4548
4549 let aum = Aum::from_cbor(&bytes).expect("decode the frozen Go checkpoint");
4550 assert_eq!(aum.message_kind, AumKind::Checkpoint);
4551 let st = aum.state.as_ref().expect("checkpoint carries a State");
4552 assert_eq!(
4553 st.disablement_values, None,
4554 "nil DisablementValues → the null arm → None"
4555 );
4556 let keys = st.keys.as_ref().expect("State has keys");
4557 assert_eq!(keys.len(), 2, "two nested keys");
4558 assert_eq!(keys[0].votes, 1);
4559 assert_eq!(keys[1].votes, 3);
4560 assert_eq!(
4561 keys[1].meta,
4562 alloc::vec![(
4563 alloc::string::String::from("k"),
4564 alloc::string::String::from("v")
4565 )],
4566 "the second nested key carries Meta {{\"k\":\"v\"}}"
4567 );
4568 // Byte-exact re-encode → the chain-link Hash matches Go's golden hash.
4569 assert_eq!(
4570 aum.serialize(),
4571 bytes,
4572 "re-serialize must be byte-identical to the Go bytes"
4573 );
4574 assert_eq!(
4575 hex(&aum.hash().0),
4576 GO_HASH,
4577 "decoded checkpoint's Hash matches Go golden"
4578 );
4579 }
4580
4581 /// Gap 1: round-trip the field combinations the original cases missed — multi-entry `Meta`
4582 /// (canonical-ordering), both `state_id`s non-zero (key-4/key-5 routing), `votes` at the u32
4583 /// boundary, a both-empty (`null`/`null`) `AumSignature`, and `key`+`key_id`+`signatures`
4584 /// coexisting (key 3/4/23 cross-talk).
4585 #[test]
4586 fn aum_from_cbor_roundtrips_review_gap_shapes() {
4587 let cases: alloc::vec::Vec<(&str, Aum)> = alloc::vec![
4588 (
4589 "multi-entry meta, pre-sorted (serialize() canonicalises key order)",
4590 Aum {
4591 message_kind: AumKind::UpdateKey,
4592 prev_aum_hash: None,
4593 key: None,
4594 key_id: alloc::vec![1],
4595 state: None,
4596 votes: Some(1),
4597 // Pre-sorted: `serialize()` emits TextMap keys in CTAP2 order, so the decoded
4598 // meta is sorted; supplying sorted input keeps the `==` round-trip exact.
4599 meta: alloc::vec![
4600 ("a".into(), "2".into()),
4601 ("mid".into(), "3".into()),
4602 ("zebra".into(), "1".into()),
4603 ],
4604 signatures: Vec::new(),
4605 },
4606 ),
4607 (
4608 "both state_ids non-zero (key 4 and key 5 must not be swapped)",
4609 Aum {
4610 message_kind: AumKind::Checkpoint,
4611 prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
4612 key: None,
4613 key_id: Vec::new(),
4614 state: Some(AumState {
4615 last_aum_hash: None,
4616 disablement_values: None,
4617 keys: Some(Vec::new()),
4618 state_id1: 7,
4619 state_id2: 9,
4620 }),
4621 votes: None,
4622 meta: Vec::new(),
4623 signatures: Vec::new(),
4624 },
4625 ),
4626 (
4627 "votes at u32::MAX + AddKey with key.votes at u32::MAX and multi-meta",
4628 Aum {
4629 message_kind: AumKind::AddKey,
4630 prev_aum_hash: Some(AumHash([0x33; AUM_HASH_LEN])),
4631 key: Some(AumKey {
4632 kind: KeyKind::Ed25519,
4633 votes: u32::MAX,
4634 public: alloc::vec![1, 2, 3],
4635 meta: alloc::vec![("a".into(), "x".into()), ("b".into(), "y".into())],
4636 }),
4637 key_id: Vec::new(),
4638 state: None,
4639 votes: None,
4640 meta: Vec::new(),
4641 signatures: Vec::new(),
4642 },
4643 ),
4644 (
4645 "both-empty AumSignature (key_id null AND signature null)",
4646 Aum {
4647 message_kind: AumKind::AddKey,
4648 prev_aum_hash: None,
4649 key: None,
4650 key_id: Vec::new(),
4651 state: None,
4652 votes: None,
4653 meta: Vec::new(),
4654 signatures: alloc::vec![AumSignature {
4655 key_id: Vec::new(),
4656 signature: Vec::new(),
4657 }],
4658 },
4659 ),
4660 (
4661 "key + key_id + signatures coexisting (keys 3, 4, 23)",
4662 Aum {
4663 message_kind: AumKind::AddKey,
4664 prev_aum_hash: Some(AumHash([0x55; AUM_HASH_LEN])),
4665 key: Some(AumKey {
4666 kind: KeyKind::Ed25519,
4667 votes: 2,
4668 public: alloc::vec![7, 7, 7],
4669 meta: Vec::new(),
4670 }),
4671 key_id: alloc::vec![9, 9],
4672 state: None,
4673 votes: None,
4674 meta: Vec::new(),
4675 signatures: alloc::vec![AumSignature {
4676 key_id: alloc::vec![1],
4677 signature: alloc::vec![2, 3, 4],
4678 }],
4679 },
4680 ),
4681 (
4682 "votes = 0 (boundary; Some(0) must survive, distinct from None)",
4683 Aum {
4684 message_kind: AumKind::UpdateKey,
4685 prev_aum_hash: None,
4686 key: None,
4687 key_id: alloc::vec![1],
4688 state: None,
4689 votes: Some(0),
4690 meta: Vec::new(),
4691 signatures: Vec::new(),
4692 },
4693 ),
4694 ];
4695 for (label, aum) in cases {
4696 let bytes = aum.serialize();
4697 let decoded = Aum::from_cbor(&bytes)
4698 .unwrap_or_else(|e| panic!("from_cbor failed for {label:?}: {e}"));
4699 assert_eq!(decoded, aum, "round-trip mismatch for {label:?}");
4700 assert_eq!(
4701 decoded.serialize(),
4702 bytes,
4703 "re-serialize differs for {label:?}"
4704 );
4705 }
4706 }
4707
4708 /// Gap 3: additional fail-closed guards on the AUM entry point — truncated map (count > entries),
4709 /// a duplicate key at the AUM level, votes > u32::MAX, an unsupported key kind, and a malformed
4710 /// (non-map) `state` value. Each must `Err`, never panic, never `Ok`.
4711 #[test]
4712 fn aum_from_cbor_fails_closed_review_gaps() {
4713 // Truncated map: header claims 3 pairs, only 2 present then EOF.
4714 assert!(
4715 Aum::from_cbor(&[0xa3, 0x01, 0x03, 0x02, 0xf6]).is_err(),
4716 "a map claiming more pairs than present must be rejected"
4717 );
4718 // Duplicate key at the AUM level: key 1 appears twice (a3 01 03 02 f6 01 04).
4719 assert!(
4720 Aum::from_cbor(&[0xa3, 0x01, 0x03, 0x02, 0xf6, 0x01, 0x04]).is_err(),
4721 "a duplicate AUM map key must be rejected"
4722 );
4723 // votes > u32::MAX: 06 1b 0000_0001_0000_0000 (= 2^32).
4724 assert!(
4725 Aum::from_cbor(&[
4726 0xa3, 0x01, 0x04, 0x02, 0xf6, 0x06, 0x1b, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
4727 0x00,
4728 ])
4729 .is_err(),
4730 "votes above u32::MAX must be rejected (fail-closed narrowing)"
4731 );
4732 // Unsupported key kind: AddKey embedding a key with kind=2. a3 01 01 02 f6 03 a3 01 02 02 01 03 41 09
4733 assert!(
4734 Aum::from_cbor(&[
4735 0xa3, 0x01, 0x01, 0x02, 0xf6, 0x03, 0xa3, 0x01, 0x02, 0x02, 0x01, 0x03, 0x41, 0x09,
4736 ])
4737 .is_err(),
4738 "an unsupported key kind must be rejected (not silently treated as Ed25519)"
4739 );
4740 // Malformed state: key 5 is a uint, not a map. a2 01 05 05 00 — but prev (key 2) missing too;
4741 // use a3 with prev null: a3 01 05 02 f6 05 00.
4742 assert!(
4743 Aum::from_cbor(&[0xa3, 0x01, 0x05, 0x02, 0xf6, 0x05, 0x00]).is_err(),
4744 "a non-map `state` value must be rejected"
4745 );
4746 // A deeply-nested array inside an AUM field must error (shared depth cap), not overflow.
4747 let mut nested = alloc::vec![0xa2u8, 0x01, 0x03, 0x02]; // map(2){1:3, 2: <nested>}
4748 nested.extend(core::iter::repeat_n(0x81u8, MAX_SIG_NESTING_DEPTH + 8)); // array(1) per level
4749 nested.push(0x00); // innermost uint
4750 assert!(
4751 Aum::from_cbor(&nested).is_err(),
4752 "an AUM field nested past the depth cap must be rejected, not overflow the stack"
4753 );
4754 }
4755
4756 /// Gap 5 + Finding L2: a NON-canonical encoding decodes to the SAME `Aum` (and thus the same
4757 /// `hash()`) as its canonical form — pinning the property that makes the lenient decode benign
4758 /// (the verify path re-serializes canonically, so wire-form variation can never forge a hash).
4759 #[test]
4760 fn aum_from_cbor_noncanonical_decodes_to_same_hash() {
4761 // Canonical NoOp with null prev: a2 01 03 02 f6.
4762 let canonical = [0xa2u8, 0x01, 0x03, 0x02, 0xf6];
4763 // Non-canonical variants that must decode to the SAME struct:
4764 // (a) message_kind via a non-minimal 2-byte int head (0x18 0x03 instead of 0x03):
4765 let noncanon_int = [0xa2u8, 0x01, 0x18, 0x03, 0x02, 0xf6];
4766 // (b) prev=null via the 2-byte simple-value form (0xf8 0x16 instead of 0xf6):
4767 let noncanon_null = [0xa2u8, 0x01, 0x03, 0x02, 0xf8, 0x16];
4768 // (c) map keys in DESCENDING order (2 before 1):
4769 let noncanon_order = [0xa2u8, 0x02, 0xf6, 0x01, 0x03];
4770
4771 let base = Aum::from_cbor(&canonical).expect("canonical decodes");
4772 for (label, bytes) in [
4773 ("non-minimal int head", &noncanon_int[..]),
4774 ("2-byte null simple value", &noncanon_null[..]),
4775 ("descending key order", &noncanon_order[..]),
4776 ] {
4777 let got = Aum::from_cbor(bytes)
4778 .unwrap_or_else(|e| panic!("non-canonical ({label}) should still decode: {e}"));
4779 assert_eq!(
4780 got, base,
4781 "non-canonical ({label}) must decode to the same Aum"
4782 );
4783 assert_eq!(
4784 got.hash(),
4785 base.hash(),
4786 "non-canonical ({label}) must hash identically (re-serialized canonically)"
4787 );
4788 // And it normalises: re-serialize equals the canonical bytes.
4789 assert_eq!(
4790 got.serialize(),
4791 canonical,
4792 "non-canonical ({label}) must re-serialize to the canonical form"
4793 );
4794 }
4795 }
4796 // ---- StaticValidate cluster (tsr-uvg): Go `AUM/Key/State.StaticValidate` parity ----
4797
4798 /// `Key::static_validate` — votes must be 1..=4096 (Go `Key.StaticValidate`).
4799 #[test]
4800 fn key_static_validate_votes_range() {
4801 let mut k = test_aum_key(1, 1);
4802 assert!(k.static_validate().is_ok(), "votes=1 ok");
4803 k.votes = 4096;
4804 assert!(k.static_validate().is_ok(), "votes=4096 ok (boundary)");
4805 k.votes = 0;
4806 assert_eq!(
4807 k.static_validate().unwrap_err(),
4808 TkaError::BadKeyState,
4809 "votes=0 rejected"
4810 );
4811 k.votes = 4097;
4812 assert_eq!(
4813 k.static_validate().unwrap_err(),
4814 TkaError::BadKeyState,
4815 "votes>4096 rejected"
4816 );
4817 }
4818
4819 /// `Key::static_validate` — metadata byte total must be ≤ MAX_META_BYTES.
4820 #[test]
4821 fn key_static_validate_meta_size() {
4822 let mut k = test_aum_key(2, 1);
4823 // 256-byte key + 256-byte value = 512 total = exactly MAX_META_BYTES → ok.
4824 k.meta = alloc::vec![(
4825 String::from_utf8(alloc::vec![b'k'; 256]).unwrap(),
4826 String::from_utf8(alloc::vec![b'v'; 256]).unwrap(),
4827 )];
4828 assert!(k.static_validate().is_ok(), "512 meta bytes ok (boundary)");
4829 // One more byte → rejected.
4830 k.meta[0].1.push('x');
4831 assert_eq!(
4832 k.static_validate().unwrap_err(),
4833 TkaError::BadKeyState,
4834 "meta>512 rejected"
4835 );
4836 }
4837
4838 /// `Aum::static_validate` — per-kind field allow-lists (Go `AUM.StaticValidate`).
4839 #[test]
4840 fn aum_static_validate_per_kind_field_allow_lists() {
4841 // AddKey must have a key and nothing else.
4842 let mut a = genesis_add(test_aum_key(1, 1));
4843 assert!(a.static_validate().is_ok());
4844 a.key_id = alloc::vec![1, 2, 3]; // foreign field
4845 assert!(
4846 a.static_validate().is_err(),
4847 "AddKey with a stray KeyID rejected"
4848 );
4849
4850 // RemoveKey must have a key_id and nothing else.
4851 let g = signed_genesis_add(1, 1);
4852 let mut rm = child(
4853 &g,
4854 AumKind::RemoveKey,
4855 None,
4856 test_aum_key(2, 1).public.clone(),
4857 );
4858 assert!(rm.static_validate().is_ok());
4859 rm.votes = Some(3); // foreign field
4860 assert!(
4861 rm.static_validate().is_err(),
4862 "RemoveKey with stray Votes rejected"
4863 );
4864
4865 // UpdateKey must have key_id AND (votes or meta).
4866 let mut up = child(
4867 &g,
4868 AumKind::UpdateKey,
4869 None,
4870 test_aum_key(2, 1).public.clone(),
4871 );
4872 assert!(
4873 up.static_validate().is_err(),
4874 "UpdateKey with neither votes nor meta rejected"
4875 );
4876 up.votes = Some(2);
4877 assert!(up.static_validate().is_ok(), "UpdateKey with votes ok");
4878 up.key = Some(test_aum_key(3, 1)); // foreign field
4879 assert!(
4880 up.static_validate().is_err(),
4881 "UpdateKey with a stray Key rejected"
4882 );
4883
4884 // Checkpoint must have state and nothing else.
4885 let mut cp = child(&g, AumKind::Checkpoint, None, Vec::new());
4886 cp.state = Some(AumState {
4887 last_aum_hash: None,
4888 disablement_values: Some(alloc::vec![alloc::vec![0xD5u8; DISABLEMENT_LENGTH]]),
4889 keys: Some(alloc::vec![test_aum_key(1, 1)]),
4890 state_id1: 0,
4891 state_id2: 0,
4892 });
4893 assert!(cp.static_validate().is_ok());
4894 cp.votes = Some(1); // foreign field
4895 assert!(
4896 cp.static_validate().is_err(),
4897 "Checkpoint with stray Votes rejected"
4898 );
4899 }
4900
4901 /// `Aum::static_validate` — every signature must have a 32-byte key_id and 64-byte signature.
4902 #[test]
4903 fn aum_static_validate_signature_lengths() {
4904 let mut a = genesis_add(test_aum_key(1, 1));
4905 a.signatures = alloc::vec![AumSignature {
4906 key_id: alloc::vec![0u8; 31], // wrong length (should be 32)
4907 signature: alloc::vec![0u8; 64],
4908 }];
4909 assert!(a.static_validate().is_err(), "31-byte keyID rejected");
4910 a.signatures[0].key_id = alloc::vec![0u8; 32];
4911 a.signatures[0].signature = alloc::vec![0u8; 63]; // wrong length (should be 64)
4912 assert!(a.static_validate().is_err(), "63-byte signature rejected");
4913 }
4914
4915 /// The last-key guard (Go `aumVerify`): a `RemoveKey` removing the only remaining trusted key is
4916 /// rejected — otherwise the authority would be left with an empty key set (lock disabled).
4917 #[test]
4918 fn verified_chain_rejects_removing_last_key() {
4919 let g = signed_genesis_add(1, 1); // exactly one trusted key (seed 1)
4920 let mut rm = child(
4921 &g,
4922 AumKind::RemoveKey,
4923 None,
4924 test_aum_key(1, 1).public.clone(),
4925 );
4926 sign_aum(&mut rm, &[1]); // validly signed by the trusted key
4927 assert_eq!(
4928 VerifiedAumChain::verify(&[g, rm]).unwrap_err(),
4929 TkaError::BadKeyState,
4930 "removing the last trusted key must be refused"
4931 );
4932 }
4933
4934 /// Removing a non-last key is fine: with two trusted keys, one can be removed.
4935 #[test]
4936 fn verified_chain_allows_removing_non_last_key() {
4937 let g = signed_genesis_add(1, 1);
4938 let mut add = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4939 sign_aum(&mut add, &[1]);
4940 let mut rm = child(
4941 &add,
4942 AumKind::RemoveKey,
4943 None,
4944 test_aum_key(2, 1).public.clone(),
4945 );
4946 sign_aum(&mut rm, &[1]);
4947 let verified = VerifiedAumChain::verify(&[g, add, rm]).expect("removing a non-last key ok");
4948 let auth = Authority::from_verified_chain(verified);
4949 assert_eq!(
4950 auth.state().keys.len(),
4951 1,
4952 "back to one key after the remove"
4953 );
4954 }
4955
4956 /// `UpdateKey` is re-validated after mutation (Go re-runs `Key.StaticValidate`): an update that
4957 /// sets votes out of range is rejected.
4958 #[test]
4959 fn verified_chain_rejects_updatekey_to_invalid_votes() {
4960 let g = signed_genesis_add(1, 1);
4961 let mut up = child(
4962 &g,
4963 AumKind::UpdateKey,
4964 None,
4965 test_aum_key(1, 1).public.clone(),
4966 );
4967 up.votes = Some(5000); // > 4096 → invalid after mutation
4968 sign_aum(&mut up, &[1]);
4969 assert_eq!(
4970 VerifiedAumChain::verify(&[g, up]).unwrap_err(),
4971 TkaError::BadKeyState,
4972 "an UpdateKey that sets votes > 4096 is rejected (post-mutation re-validate)"
4973 );
4974 }
4975
4976 // ===== AUM-chain sync: store + SyncOffer + MissingAUMs (issue #7 chunk 2, tsr-5po) =====
4977
4978 /// Build a simple linear chain `genesis(AddKey) -> NoOp -> NoOp -> ...` of `len` AUMs, returning
4979 /// the AUMs in parent→child order. The genesis adds `test_aum_key(1, 1)`.
4980 fn linear_chain(len: usize) -> Vec<Aum> {
4981 assert!(len >= 1);
4982 let mut chain = alloc::vec![genesis_add(test_aum_key(1, 1))];
4983 for _ in 1..len {
4984 let parent = chain.last().unwrap();
4985 chain.push(child(parent, AumKind::NoOp, None, Vec::new()));
4986 }
4987 chain
4988 }
4989
4990 /// An [`Authority`] whose head is the last AUM of `chain` (via the structural `from_chain`; the
4991 /// sync layer is signature-agnostic, so unsigned test chains are fine here).
4992 fn authority_at_head(chain: &[Aum]) -> Authority {
4993 Authority::from_chain(chain).expect("linear test chain replays")
4994 }
4995
4996 #[test]
4997 fn mem_store_indexes_by_hash_and_children() {
4998 let chain = linear_chain(3);
4999 let store = MemAumStore::from_aums(chain.clone());
5000 assert_eq!(store.len(), 3);
5001 // by-hash lookup
5002 assert_eq!(store.aum(&chain[1].hash()).as_ref(), Some(&chain[1]));
5003 assert!(store.aum(&AumHash([0xFF; AUM_HASH_LEN])).is_none());
5004 // child index: genesis has one child (chain[1]); the tail has none.
5005 let kids = store.child_aums(&chain[0].hash());
5006 assert_eq!(kids.len(), 1);
5007 assert_eq!(kids[0], chain[1]);
5008 assert!(store.child_aums(&chain[2].hash()).is_empty());
5009 // insert is idempotent on hash + child edge.
5010 let mut s2 = store.clone();
5011 s2.insert(chain[1].clone());
5012 assert_eq!(s2.len(), 3, "re-insert must not grow the store");
5013 assert_eq!(
5014 s2.child_aums(&chain[0].hash()).len(),
5015 1,
5016 "child edge not duplicated"
5017 );
5018 }
5019
5020 #[test]
5021 fn sync_offer_head_and_oldest_bookend() {
5022 let chain = linear_chain(5);
5023 let store = MemAumStore::from_aums(chain.clone());
5024 let auth = authority_at_head(&chain);
5025 let oldest = chain[0].hash();
5026
5027 let offer = auth.sync_offer(&store, oldest).expect("offer");
5028 assert_eq!(offer.head, chain[4].hash(), "offer head is the chain head");
5029 assert_eq!(
5030 *offer.ancestors.last().unwrap(),
5031 oldest,
5032 "the last ancestor is always the oldest AUM"
5033 );
5034 // Every ancestor is a real hash in the chain.
5035 for a in &offer.ancestors {
5036 assert!(
5037 store.aum(a).is_some(),
5038 "ancestor {a:?} must be in the store"
5039 );
5040 }
5041 }
5042
5043 #[test]
5044 fn sync_offer_truncates_on_a_gap() {
5045 // A store missing an interior AUM: the backward walk breaks early, but `oldest` is still
5046 // appended (matching Go's break-then-append). Drop chain[1] so walking back from head hits
5047 // a gap.
5048 let chain = linear_chain(4);
5049 let store = MemAumStore::from_aums(
5050 chain
5051 .iter()
5052 .enumerate()
5053 .filter(|(i, _)| *i != 1)
5054 .map(|(_, a)| a.clone()),
5055 );
5056 let auth = authority_at_head(&chain);
5057 let offer = auth
5058 .sync_offer(&store, chain[0].hash())
5059 .expect("offer despite gap");
5060 assert_eq!(*offer.ancestors.last().unwrap(), chain[0].hash());
5061 }
5062
5063 #[test]
5064 fn missing_aums_empty_when_up_to_date() {
5065 let chain = linear_chain(4);
5066 let store = MemAumStore::from_aums(chain.clone());
5067 let auth = authority_at_head(&chain);
5068 let oldest = chain[0].hash();
5069 // Peer offers the SAME head → nothing missing.
5070 let peer_offer = auth.sync_offer(&store, oldest).expect("offer");
5071 let missing = auth
5072 .missing_aums(&store, &peer_offer, oldest)
5073 .expect("missing");
5074 assert!(missing.is_empty(), "an up-to-date peer is missing nothing");
5075 }
5076
5077 #[test]
5078 fn missing_aums_head_intersection_sends_the_tail() {
5079 // We are at head chain[4]; the peer is behind at chain[2] (their head is an ancestor of
5080 // ours). We must send them chain[3] and chain[4] (everything after the intersection).
5081 let chain = linear_chain(5);
5082 let store = MemAumStore::from_aums(chain.clone());
5083 let oldest = chain[0].hash();
5084 let us = authority_at_head(&chain); // head = chain[4]
5085
5086 // The peer's offer: head = chain[2], ancestors back to oldest. Build it from a peer authority
5087 // whose head is chain[2] over a store holding the prefix [0..=2].
5088 let peer_prefix: Vec<Aum> = chain[0..=2].to_vec();
5089 let peer_store = MemAumStore::from_aums(peer_prefix.clone());
5090 let peer = authority_at_head(&peer_prefix); // head = chain[2]
5091 let peer_offer = peer.sync_offer(&peer_store, oldest).expect("peer offer");
5092
5093 let missing = us
5094 .missing_aums(&store, &peer_offer, oldest)
5095 .expect("missing");
5096 let missing_hashes: Vec<AumHash> = missing.iter().map(Aum::hash).collect();
5097 assert_eq!(
5098 missing_hashes,
5099 alloc::vec![chain[3].hash(), chain[4].hash()],
5100 "must send exactly the AUMs after the peer's head, in order"
5101 );
5102 }
5103
5104 #[test]
5105 fn missing_aums_excludes_the_intersection_itself() {
5106 // The intersection AUM (the peer's head) must NOT be in the sent set — they already have it.
5107 let chain = linear_chain(4);
5108 let store = MemAumStore::from_aums(chain.clone());
5109 let oldest = chain[0].hash();
5110 let us = authority_at_head(&chain);
5111
5112 let peer_prefix: Vec<Aum> = chain[0..=1].to_vec();
5113 let peer = authority_at_head(&peer_prefix);
5114 let peer_offer = peer
5115 .sync_offer(&MemAumStore::from_aums(peer_prefix.clone()), oldest)
5116 .expect("peer offer");
5117
5118 let missing = us
5119 .missing_aums(&store, &peer_offer, oldest)
5120 .expect("missing");
5121 assert!(
5122 !missing.iter().any(|a| a.hash() == chain[1].hash()),
5123 "the intersection AUM (peer's head) must be excluded"
5124 );
5125 assert_eq!(missing.len(), 2, "only chain[2] and chain[3] are missing");
5126 }
5127
5128 #[test]
5129 fn missing_aums_no_intersection_errors() {
5130 // Two totally unrelated chains (different genesis keys → different hashes everywhere): no
5131 // intersection, so `missing_aums` fails closed rather than mis-rooting.
5132 let ours = linear_chain(3);
5133 let store = MemAumStore::from_aums(ours.clone());
5134 let us = authority_at_head(&ours);
5135
5136 // A foreign chain the peer offers; we hold none of it.
5137 let theirs = {
5138 let mut c = alloc::vec![genesis_add(test_aum_key(9, 1))];
5139 c.push(child(&c[0], AumKind::NoOp, None, Vec::new()));
5140 c
5141 };
5142 let foreign_offer = SyncOffer {
5143 head: theirs[1].hash(),
5144 ancestors: alloc::vec![theirs[1].hash(), theirs[0].hash()],
5145 };
5146 assert!(
5147 us.missing_aums(&store, &foreign_offer, ours[0].hash())
5148 .is_err(),
5149 "no intersection must fail closed, not mis-root"
5150 );
5151 }
5152
5153 #[test]
5154 fn compute_state_at_matches_replay_at_each_point() {
5155 // The state computed at an interior AUM via the store walk must equal a direct linear replay
5156 // of the prefix up to that AUM (the verify-only Authority's state).
5157 let chain = linear_chain(4);
5158 let store = MemAumStore::from_aums(chain.clone());
5159 for i in 0..chain.len() {
5160 let want = chain[i].hash();
5161 let via_store = compute_state_at(&store, MAX_SYNC_ITER, want)
5162 .expect("compute_state_at ok")
5163 .expect("hash present");
5164 let via_replay = Authority::from_chain(&chain[0..=i]).expect("prefix replays");
5165 assert_eq!(
5166 via_store.to_state(),
5167 *via_replay.state(),
5168 "computed state at chain[{i}] must match a direct prefix replay"
5169 );
5170 }
5171 }
5172
5173 #[test]
5174 fn sync_offer_ancestors_are_exponentially_spaced() {
5175 // With a long chain the ancestor sampling thins out (skip 4, then 16, ...), so the count is
5176 // far below the chain length — the whole point of the offer.
5177 let chain = linear_chain(60);
5178 let store = MemAumStore::from_aums(chain.clone());
5179 let auth = authority_at_head(&chain);
5180 let offer = auth.sync_offer(&store, chain[0].hash()).expect("offer");
5181 assert!(
5182 offer.ancestors.len() < 12,
5183 "exponential spacing keeps the ancestor list small (got {})",
5184 offer.ancestors.len()
5185 );
5186 // First sampled ancestor is 4 back from head (i=4 is the first i%4==0 with i>0): chain[56].
5187 assert_eq!(offer.ancestors[0], chain[60 - 1 - 4].hash());
5188 assert_eq!(*offer.ancestors.last().unwrap(), chain[0].hash());
5189 }
5190}