Skip to main content

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}