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