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        // Fold the chain into a live `ReplayState` as we walk, so a fork is resolved against the
1391        // REAL trusted-key weights at the fork point — exactly as Go's `advanceByPrimary` folds
1392        // state before each `pickNextAUM`. Resolving with an empty (zero-key) state would make every
1393        // candidate's weight 0, collapsing the tiebreak to the lowest-hash rule; a genuinely
1394        // weight-decided fork (signed competing branches with different vote totals) would then pick
1395        // a *different* branch than a Go node — an accept-direction consensus split.
1396        //
1397        // This store is UNVERIFIED (signatures/structure are `verify`'s job, not ours), so a fold can
1398        // fail on a malformed AUM. We tolerate that best-effort: stop advancing the weight state but
1399        // keep ordering the chain, because the authoritative `VerifiedAumChain::verify` runs on the
1400        // result and will reject a malformed chain anyway. We never abort the walk on a fold error.
1401        let mut state = ReplayState::default();
1402        for _ in 0..MAX_SYNC_ITER {
1403            // Apply the current AUM before resolving its children (so the weight at a fork below
1404            // includes every AUM up to and including this one). Best-effort: a fold failure on this
1405            // unverified store leaves `state` as-is rather than aborting the ordering walk.
1406            let _ = state.apply_verified_aum(&curs);
1407
1408            out.push(curs.clone());
1409            let children = self.child_aums(&curs.hash());
1410            if children.is_empty() {
1411                return Ok(out);
1412            }
1413            // Deterministic branch choice at a fork (weight → RemoveKey → lowest-hash), now against
1414            // the real replayed state. For a linear chain there is exactly one child and the state is
1415            // irrelevant; at a fork the weight term is correct.
1416            let next = pick_next_aum(&state, &children).clone();
1417            curs = next;
1418        }
1419        Err(TkaError::BadChain) // iteration cap: cycle or over-long chain
1420    }
1421}
1422
1423impl AumStore for MemAumStore {
1424    fn aum(&self, hash: &AumHash) -> Option<Aum> {
1425        self.by_hash.get(hash).cloned()
1426    }
1427
1428    fn child_aums(&self, hash: &AumHash) -> Vec<Aum> {
1429        self.children
1430            .get(hash)
1431            .map(|kids| {
1432                kids.iter()
1433                    .filter_map(|h| self.by_hash.get(h).cloned())
1434                    .collect()
1435            })
1436            .unwrap_or_default()
1437    }
1438}
1439
1440/// A node's view of where its chain is, offered to a peer so the peer can work out what to send (Go
1441/// `tka.SyncOffer`): the current `head` plus a sparse, exponentially-spaced sample of `ancestors`
1442/// back to the oldest AUM the node holds. The last entry is always the oldest-known AUM.
1443#[derive(Debug, Clone, PartialEq, Eq)]
1444pub struct SyncOffer {
1445    /// The node's current chain head.
1446    pub head: AumHash,
1447    /// A subset of the chain's ancestors, newest-first, ending with the oldest-known AUM. Used by a
1448    /// peer to find a "tail intersection" when it doesn't recognise the head.
1449    pub ancestors: Vec<AumHash>,
1450}
1451
1452/// The result of comparing two [`SyncOffer`]s (Go `tka.intersection`): where (if anywhere) the two
1453/// chains meet, which tells [`missing_aums`](sync_missing_aums) where to start gathering.
1454#[derive(Debug, Clone, PartialEq, Eq)]
1455struct Intersection {
1456    /// Both heads are equal — nothing to exchange.
1457    up_to_date: bool,
1458    /// The newest common AUM that is the *remote's* head and an ancestor of ours (we have updates
1459    /// building on it to send).
1460    head_intersection: Option<AumHash>,
1461    /// The oldest common AUM, where the chains diverge — a starting point to send from when we don't
1462    /// recognise the remote's head.
1463    tail_intersection: Option<AumHash>,
1464}
1465
1466/// Compute the state at `want_hash` by walking back to a checkpoint or genesis, then forward along
1467/// the taken path (Go `computeStateAt`, `tka/tka.go`). Structural fold only (no signature check) —
1468/// the verify boundary is elsewhere. Returns `None` (not an error) if `want_hash` is not in the
1469/// store, mirroring the `os.ErrNotExist` sentinel Go callers special-case.
1470fn compute_state_at(
1471    storage: &dyn AumStore,
1472    max_iter: usize,
1473    want_hash: AumHash,
1474) -> Result<Option<ReplayState>, TkaError> {
1475    let Some(top) = storage.aum(&want_hash) else {
1476        return Ok(None);
1477    };
1478
1479    // Walk backwards to a starting point: a checkpoint AUM (which carries full state) or a genesis
1480    // AUM (no parent — valid only for NoOp/AddKey/Checkpoint). `path` records every hash on the way
1481    // so the forward pass can follow exactly this branch (which, for a non-primary fork, may differ
1482    // from standard fork resolution).
1483    let mut curs = top;
1484    let mut state = ReplayState::default();
1485    let mut path: alloc::collections::BTreeSet<AumHash> = alloc::collections::BTreeSet::new();
1486    let mut started = false;
1487    for i in 0..=max_iter {
1488        if i == max_iter {
1489            return Err(TkaError::BadChain); // iteration limit exceeded
1490        }
1491        path.insert(curs.hash());
1492
1493        if curs.message_kind == AumKind::Checkpoint {
1494            // A checkpoint encapsulates the state at that point: fold it into an empty state.
1495            let mut s = ReplayState::default();
1496            s.apply_verified_aum(&curs)?;
1497            state = s;
1498            started = true;
1499            break;
1500        }
1501        match curs.prev_aum_hash {
1502            None => {
1503                // Genesis: applies to the empty state. Only NoOp/AddKey reach here (Checkpoint broke
1504                // above); anything else is an invalid genesis. `apply_verified_aum` enforces the
1505                // same kind restriction, so just fold and let it reject a bad genesis.
1506                let mut s = ReplayState::default();
1507                s.apply_verified_aum(&curs)?;
1508                state = s;
1509                started = true;
1510                break;
1511            }
1512            Some(parent) => {
1513                let Some(p) = storage.aum(&parent) else {
1514                    return Err(TkaError::BadParent); // dangling parent link
1515                };
1516                curs = p;
1517            }
1518        }
1519    }
1520    debug_assert!(
1521        started,
1522        "compute_state_at must find a checkpoint or genesis"
1523    );
1524
1525    // Fast-forward from the starting point, following only AUMs on `path` (the custom advancer),
1526    // until we reach `want_hash`. No gather side-effect here — we only want the final state.
1527    let (_, end_state) = fast_forward(
1528        storage,
1529        max_iter,
1530        state,
1531        &mut |_: &Aum, _: &mut ReplayState| Ok(false),
1532        Some(&path),
1533        Some(want_hash),
1534    )?;
1535    Ok(Some(end_state))
1536}
1537
1538/// Fast-forward from `start_state` along the chain (Go `fastForwardWithAdvancer` +
1539/// `advanceByPrimary`). At each step it takes the children of the current AUM and advances by:
1540/// - the child on `path` when `path` is `Some` (the `computeStateAt` custom advancer), or
1541/// - [`pick_next_aum`]'s deterministic fork resolution otherwise (the primary advancer).
1542///
1543/// Stops when `stop_at` (when set) is reached — returning that AUM + the state *before* applying it
1544/// for a gather caller, matching Go's `done(curs, state)` check at the top of the loop — or when
1545/// there are no more children. `gather` is invoked for each visited AUM (Go's `done` callback side
1546/// effect) and its boolean return additionally stops the walk when true.
1547///
1548/// Returns the final AUM reached and the folded state at it. Structural fold only.
1549fn fast_forward(
1550    storage: &dyn AumStore,
1551    max_iter: usize,
1552    start_state: ReplayState,
1553    gather: &mut dyn FnMut(&Aum, &mut ReplayState) -> Result<bool, TkaError>,
1554    path: Option<&alloc::collections::BTreeSet<AumHash>>,
1555    stop_at: Option<AumHash>,
1556) -> Result<(Aum, ReplayState), TkaError> {
1557    let start_hash = start_state
1558        .last_aum_hash
1559        .ok_or(TkaError::Decode("fast_forward from a state with no head"))?;
1560    let mut curs = storage.aum(&start_hash).ok_or(TkaError::BadParent)?;
1561    let mut state = start_state;
1562
1563    for _ in 0..max_iter {
1564        // Done check runs BEFORE advancing (Go checks `done(curs, state)` at loop top): for a
1565        // `stop_at` caller this returns the stop AUM with the state *before* applying it.
1566        if Some(curs.hash()) == stop_at {
1567            return Ok((curs, state));
1568        }
1569        // Side-effect callback (the gather closure for `missing_aums`); a `true` return also stops.
1570        if gather(&curs, &mut state)? {
1571            return Ok((curs, state));
1572        }
1573
1574        let children = storage.child_aums(&curs.hash());
1575        let next = match path {
1576            // `computeStateAt` advancer: follow the unique child that is on the recorded path.
1577            Some(p) => children.into_iter().find(|c| p.contains(&c.hash())),
1578            // Primary advancer: deterministic fork resolution.
1579            None => {
1580                if children.is_empty() {
1581                    None
1582                } else {
1583                    Some(pick_next_aum(&state, &children).clone())
1584                }
1585            }
1586        };
1587        match next {
1588            None => return Ok((curs, state)), // no more children: we are at head
1589            Some(n) => {
1590                state.apply_verified_aum(&n)?;
1591                curs = n;
1592            }
1593        }
1594    }
1595    Err(TkaError::BadChain) // iteration limit exceeded
1596}
1597
1598impl Authority {
1599    /// Build the [`SyncOffer`] this authority would send a peer (Go `Authority.SyncOffer`): its
1600    /// `head` plus an exponentially-spaced sample of ancestors back to `oldest`, ending with
1601    /// `oldest`. `oldest` is the oldest AUM the caller holds (Go `a.oldestAncestor.Hash()`); our
1602    /// verify-only [`Authority`] does not track it, so it is passed in — typically the genesis hash
1603    /// of the chain the caller staged in `storage`.
1604    ///
1605    /// `storage` must contain the chain from `head` back to `oldest`; a gap simply truncates the
1606    /// ancestor list early (the walk breaks on the first missing parent, exactly like Go).
1607    pub fn sync_offer(
1608        &self,
1609        storage: &dyn AumStore,
1610        oldest: AumHash,
1611    ) -> Result<SyncOffer, TkaError> {
1612        let mut out = SyncOffer {
1613            head: self.head,
1614            ancestors: Vec::with_capacity(6),
1615        };
1616        let mut skip_amount = ANCESTORS_SKIP_START;
1617        let mut curs = self.head;
1618        for i in 0..MAX_SYNC_HEAD_INTERSECTION_ITER {
1619            if i > 0 && skip_amount != 0 && i % skip_amount == 0 {
1620                out.ancestors.push(curs);
1621                skip_amount <<= ANCESTORS_SKIP_SHIFT;
1622            }
1623            let Some(parent) = storage.aum(&curs) else {
1624                break; // os.ErrNotExist: stop, don't error
1625            };
1626            // We append `oldest` after the loop, so don't duplicate it.
1627            if parent.hash() == oldest {
1628                break;
1629            }
1630            match parent.prev_aum_hash {
1631                Some(prev) => curs = prev,
1632                None => break, // reached a genesis that isn't `oldest`; nothing earlier to walk
1633            }
1634        }
1635        out.ancestors.push(oldest);
1636        Ok(out)
1637    }
1638
1639    /// Given a peer's [`SyncOffer`], compute the AUMs **they** are missing — the ones to send them so
1640    /// their chain catches up to ours (Go `Authority.MissingAUMs`). `storage` must hold our chain.
1641    /// Returns an empty `Vec` when the peer is already up to date.
1642    ///
1643    /// Mirrors Go: compute our own offer, find the intersection of the two chains, then gather every
1644    /// AUM from the intersection forward to our head (excluding the intersection AUM itself).
1645    pub fn missing_aums(
1646        &self,
1647        storage: &dyn AumStore,
1648        remote_offer: &SyncOffer,
1649        oldest: AumHash,
1650    ) -> Result<Vec<Aum>, TkaError> {
1651        let local_offer = self.sync_offer(storage, oldest)?;
1652        let isect = compute_sync_intersection(storage, &local_offer, remote_offer)?;
1653        if isect.up_to_date {
1654            return Ok(Vec::new());
1655        }
1656        let from = isect
1657            .head_intersection
1658            .or(isect.tail_intersection)
1659            .ok_or(TkaError::BadChain)?; // Go panics "unreachable"; we fail closed instead.
1660
1661        let Some(state) = compute_state_at(storage, MAX_SYNC_ITER, from)? else {
1662            return Err(TkaError::BadParent);
1663        };
1664        let mut out: Vec<Aum> = Vec::with_capacity(12);
1665        fast_forward(
1666            storage,
1667            MAX_SYNC_ITER,
1668            state,
1669            &mut |curs: &Aum, _: &mut ReplayState| -> Result<bool, TkaError> {
1670                // Gather every AUM from the intersection forward, excluding the intersection itself.
1671                if curs.hash() != from {
1672                    out.push(curs.clone());
1673                }
1674                Ok(false) // never stop early; walk to head (no more children)
1675            },
1676            None,
1677            None,
1678        )?;
1679        Ok(out)
1680    }
1681}
1682
1683/// Find where two chains meet (Go `computeSyncIntersection`). See [`Intersection`].
1684fn compute_sync_intersection(
1685    storage: &dyn AumStore,
1686    local_offer: &SyncOffer,
1687    remote_offer: &SyncOffer,
1688) -> Result<Intersection, TkaError> {
1689    // Simple case: identical heads → up to date.
1690    if remote_offer.head == local_offer.head {
1691        return Ok(Intersection {
1692            up_to_date: true,
1693            head_intersection: Some(local_offer.head),
1694            tail_intersection: None,
1695        });
1696    }
1697
1698    // Head intersection: if we hold the remote's head, walk back from our head looking for it. If
1699    // found, their head is an ancestor of ours and we have the AUMs that build on it.
1700    if storage.aum(&remote_offer.head).is_some() {
1701        let mut curs = local_offer.head;
1702        for _ in 0..MAX_SYNC_HEAD_INTERSECTION_ITER {
1703            let Some(parent) = storage.aum(&curs) else {
1704                break; // os.ErrNotExist
1705            };
1706            if parent.hash() == remote_offer.head {
1707                return Ok(Intersection {
1708                    up_to_date: false,
1709                    head_intersection: Some(parent.hash()),
1710                    tail_intersection: None,
1711                });
1712            }
1713            match parent.prev_aum_hash {
1714                Some(prev) => curs = prev,
1715                None => break,
1716            }
1717        }
1718    }
1719
1720    // Tail intersection: we don't recognise their head, but if one of the ancestors they offered is
1721    // on our chain, that's a starting point. Iterate in their order (newest-first) so we pick the
1722    // most-recent shared ancestor and send the fewest AUMs.
1723    for ancestor in &remote_offer.ancestors {
1724        let state = match compute_state_at(storage, MAX_SYNC_ITER, *ancestor)? {
1725            Some(s) => s,
1726            None => continue, // os.ErrNotExist: we don't have this ancestor; try the next
1727        };
1728        let (end, _) = fast_forward(
1729            storage,
1730            MAX_SYNC_ITER,
1731            state,
1732            &mut |_: &Aum, _: &mut ReplayState| Ok(false),
1733            None,
1734            Some(local_offer.head),
1735        )?;
1736        // fast_forward can stop early (no more children) before reaching the target, so re-check.
1737        if end.hash() == local_offer.head {
1738            return Ok(Intersection {
1739                up_to_date: false,
1740                head_intersection: None,
1741                tail_intersection: Some(*ancestor),
1742            });
1743        }
1744    }
1745
1746    Err(TkaError::BadChain) // ErrNoIntersection
1747}
1748
1749/// Verify a standard (RFC 8032, non-cofactored) Ed25519 signature.
1750fn verify_ed25519_std(public: &[u8], msg: &[u8], sig: &[u8]) -> Result<(), TkaError> {
1751    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
1752    let pk: [u8; 32] = public
1753        .try_into()
1754        .map_err(|_| TkaError::Decode("bad pubkey len"))?;
1755    let vk = VerifyingKey::from_bytes(&pk).map_err(|_| TkaError::Decode("bad ed25519 pubkey"))?;
1756    let sig: [u8; 64] = sig
1757        .try_into()
1758        .map_err(|_| TkaError::Decode("bad sig len"))?;
1759    vk.verify(msg, &Signature::from_bytes(&sig))
1760        .map_err(|_| TkaError::BadSignature)
1761}
1762
1763/// Verify a ZIP-215 (cofactored) Ed25519 signature, matching Go `ed25519consensus.Verify`.
1764fn verify_ed25519_zip215(public: &[u8], msg: &[u8], sig: &[u8]) -> Result<(), TkaError> {
1765    let pk: [u8; 32] = public
1766        .try_into()
1767        .map_err(|_| TkaError::Decode("bad pubkey len"))?;
1768    let vk = ed25519_zebra::VerificationKey::try_from(pk)
1769        .map_err(|_| TkaError::Decode("bad ed25519 pubkey"))?;
1770    let sig_bytes: [u8; 64] = sig
1771        .try_into()
1772        .map_err(|_| TkaError::Decode("bad sig len"))?;
1773    let sig = ed25519_zebra::Signature::from(sig_bytes);
1774    vk.verify(&sig, msg).map_err(|_| TkaError::BadSignature)
1775}
1776
1777/// Decode a [`NodeKeySignature`] from canonical CBOR. This is a minimal decoder for the exact map
1778/// shape Go emits (integer keys 1..=6); anything else is rejected (fail-closed).
1779fn decode_node_key_signature(buf: &[u8]) -> Result<NodeKeySignature, TkaError> {
1780    let (val, rest) = decode_value(buf, 0)?;
1781    if !rest.is_empty() {
1782        return Err(TkaError::Decode("trailing bytes after signature"));
1783    }
1784    node_key_signature_from_value(val, 0)
1785}
1786
1787fn node_key_signature_from_value(val: Value, depth: usize) -> Result<NodeKeySignature, TkaError> {
1788    if depth > MAX_SIG_NESTING_DEPTH {
1789        return Err(TkaError::Decode("nested signature too deep"));
1790    }
1791    let Value::IntMap(entries) = val else {
1792        return Err(TkaError::Decode("signature is not an int-keyed map"));
1793    };
1794    let mut sig_kind = None;
1795    let mut pubkey = Vec::new();
1796    let mut key_id = Vec::new();
1797    let mut signature = Vec::new();
1798    let mut nested = None;
1799    let mut wrapping_pubkey = Vec::new();
1800
1801    for (k, v) in entries {
1802        match k {
1803            1 => {
1804                let Value::Uint(n) = v else {
1805                    return Err(TkaError::Decode("sig kind not uint"));
1806                };
1807                sig_kind = Some(
1808                    SigKind::from_u8(
1809                        u8::try_from(n).map_err(|_| TkaError::Decode("sig kind range"))?,
1810                    )
1811                    .ok_or(TkaError::Decode("unknown sig kind"))?,
1812                );
1813            }
1814            2 => pubkey = expect_bytes(v)?,
1815            3 => key_id = expect_bytes(v)?,
1816            4 => signature = expect_bytes(v)?,
1817            5 => {
1818                nested = Some(alloc::boxed::Box::new(node_key_signature_from_value(
1819                    v,
1820                    depth + 1,
1821                )?))
1822            }
1823            6 => wrapping_pubkey = expect_bytes(v)?,
1824            _ => return Err(TkaError::Decode("unknown signature field")),
1825        }
1826    }
1827
1828    Ok(NodeKeySignature {
1829        sig_kind: sig_kind.ok_or(TkaError::Decode("signature missing kind"))?,
1830        pubkey,
1831        key_id,
1832        signature,
1833        nested,
1834        wrapping_pubkey,
1835    })
1836}
1837
1838fn expect_bytes(v: Value) -> Result<Vec<u8>, TkaError> {
1839    match v {
1840        Value::Bytes(b) => Ok(b),
1841        _ => Err(TkaError::Decode("expected byte string")),
1842    }
1843}
1844
1845/// A byte string, or an empty `Vec` for CBOR `null` — the decode inverse of [`bytes_or_null`]. Go's
1846/// `fxamacker/cbor` encodes a nil *non*-`omitempty` `[]byte` field as CBOR null (`0xf6`); on decode
1847/// that round-trips back to an empty `Vec` (the field's zero value). Any other CBOR type is rejected.
1848fn expect_bytes_or_null(v: Value) -> Result<Vec<u8>, TkaError> {
1849    match v {
1850        Value::Bytes(b) => Ok(b),
1851        Value::Null => Ok(Vec::new()),
1852        _ => Err(TkaError::Decode("expected byte string or null")),
1853    }
1854}
1855
1856fn expect_uint(v: Value) -> Result<u64, TkaError> {
1857    match v {
1858        Value::Uint(n) => Ok(n),
1859        _ => Err(TkaError::Decode("expected unsigned integer")),
1860    }
1861}
1862
1863/// Decode a `map[string]string` (`Meta`) from a [`Value::TextMap`]. Values must be text strings.
1864fn meta_from_value(
1865    v: Value,
1866) -> Result<Vec<(alloc::string::String, alloc::string::String)>, TkaError> {
1867    let Value::TextMap(entries) = v else {
1868        return Err(TkaError::Decode("meta is not a text-keyed map"));
1869    };
1870    let mut out = Vec::with_capacity(entries.len());
1871    for (k, val) in entries {
1872        let Value::Text(vbytes) = val else {
1873            return Err(TkaError::Decode("meta value not text"));
1874        };
1875        let key = alloc::string::String::from_utf8(k)
1876            .map_err(|_| TkaError::Decode("meta key not utf-8"))?;
1877        let value = alloc::string::String::from_utf8(vbytes)
1878            .map_err(|_| TkaError::Decode("meta value not utf-8"))?;
1879        out.push((key, value));
1880    }
1881    Ok(out)
1882}
1883
1884/// Decode a 32-byte [`AumHash`] from a CBOR byte string of exactly 32 bytes.
1885fn aum_hash_from_bytes(b: Vec<u8>) -> Result<AumHash, TkaError> {
1886    let arr: [u8; AUM_HASH_LEN] = b
1887        .try_into()
1888        .map_err(|_| TkaError::Decode("AUM hash not 32 bytes"))?;
1889    Ok(AumHash(arr))
1890}
1891
1892impl AumKey {
1893    /// Decode an [`AumKey`] from its CBOR value (Go `tka.Key`; keymap `kind`=1, `votes`=2,
1894    /// `public`=3, `meta`=12). The inverse of [`AumKey::to_cbor`]. Only `Key25519` (kind `1`) is
1895    /// supported (the sole [`KeyKind`] this fork models); any other kind is rejected (fail-closed).
1896    fn from_value(v: Value) -> Result<AumKey, TkaError> {
1897        let Value::IntMap(entries) = v else {
1898            return Err(TkaError::Decode("key is not an int-keyed map"));
1899        };
1900        let mut kind = None;
1901        let mut votes = None;
1902        let mut public = None;
1903        let mut meta = Vec::new();
1904        for (k, val) in entries {
1905            match k {
1906                1 => {
1907                    kind = Some(match expect_uint(val)? {
1908                        1 => KeyKind::Ed25519,
1909                        _ => return Err(TkaError::Decode("unsupported key kind")),
1910                    })
1911                }
1912                2 => {
1913                    votes = Some(
1914                        u32::try_from(expect_uint(val)?)
1915                            .map_err(|_| TkaError::Decode("key votes out of range"))?,
1916                    )
1917                }
1918                3 => public = Some(expect_bytes_or_null(val)?),
1919                12 => meta = meta_from_value(val)?,
1920                _ => return Err(TkaError::Decode("unknown key field")),
1921            }
1922        }
1923        Ok(AumKey {
1924            kind: kind.ok_or(TkaError::Decode("key missing kind"))?,
1925            votes: votes.ok_or(TkaError::Decode("key missing votes"))?,
1926            public: public.ok_or(TkaError::Decode("key missing public"))?,
1927            meta,
1928        })
1929    }
1930}
1931
1932impl AumState {
1933    /// Decode an [`AumState`] from its CBOR value (Go `tka.State`; keymap `last_aum_hash`=1,
1934    /// `disablement_values`=2, `keys`=3, `state_id1`=4, `state_id2`=5). The inverse of
1935    /// [`AumState::to_cbor`]: keys 1/2/3 are non-`omitempty`, so a nil one arrives as CBOR null
1936    /// (`None`); a present array arrives as `Some(vec)` (possibly empty). Keys 4/5 are `omitempty`,
1937    /// defaulting to 0 when absent.
1938    fn from_value(v: Value) -> Result<AumState, TkaError> {
1939        let Value::IntMap(entries) = v else {
1940            return Err(TkaError::Decode("state is not an int-keyed map"));
1941        };
1942        let mut state = AumState::default();
1943        for (k, val) in entries {
1944            match k {
1945                1 => {
1946                    state.last_aum_hash = match val {
1947                        Value::Null => None,
1948                        Value::Bytes(b) => Some(aum_hash_from_bytes(b)?),
1949                        _ => return Err(TkaError::Decode("last_aum_hash not bytes or null")),
1950                    }
1951                }
1952                2 => {
1953                    state.disablement_values = match val {
1954                        Value::Null => None,
1955                        Value::Array(items) => Some(
1956                            items
1957                                .into_iter()
1958                                .map(expect_bytes)
1959                                .collect::<Result<Vec<_>, _>>()?,
1960                        ),
1961                        _ => return Err(TkaError::Decode("disablement_values not array or null")),
1962                    }
1963                }
1964                3 => {
1965                    state.keys = match val {
1966                        Value::Null => None,
1967                        Value::Array(items) => Some(
1968                            items
1969                                .into_iter()
1970                                .map(AumKey::from_value)
1971                                .collect::<Result<Vec<_>, _>>()?,
1972                        ),
1973                        _ => return Err(TkaError::Decode("state keys not array or null")),
1974                    }
1975                }
1976                4 => state.state_id1 = expect_uint(val)?,
1977                5 => state.state_id2 = expect_uint(val)?,
1978                _ => return Err(TkaError::Decode("unknown state field")),
1979            }
1980        }
1981        Ok(state)
1982    }
1983}
1984
1985impl AumSignature {
1986    /// Decode an [`AumSignature`] from its CBOR value (Go `tkatype.Signature`; keymap `key_id`=1,
1987    /// `signature`=2, both non-`omitempty` → a nil one is CBOR null → empty `Vec`).
1988    fn from_value(v: Value) -> Result<AumSignature, TkaError> {
1989        let Value::IntMap(entries) = v else {
1990            return Err(TkaError::Decode("signature is not an int-keyed map"));
1991        };
1992        let mut key_id = None;
1993        let mut signature = None;
1994        for (k, val) in entries {
1995            match k {
1996                1 => key_id = Some(expect_bytes_or_null(val)?),
1997                2 => signature = Some(expect_bytes_or_null(val)?),
1998                _ => return Err(TkaError::Decode("unknown AUM signature field")),
1999            }
2000        }
2001        Ok(AumSignature {
2002            key_id: key_id.ok_or(TkaError::Decode("AUM signature missing key_id"))?,
2003            signature: signature.ok_or(TkaError::Decode("AUM signature missing signature"))?,
2004        })
2005    }
2006}
2007
2008impl Aum {
2009    /// Decode an [`Aum`] from its canonical CBOR serialization (the inverse of [`Aum::serialize`] /
2010    /// [`Aum::to_cbor`]). This is the acquisition primitive a sync/bootstrap path uses to turn the
2011    /// raw `MarshaledAUM` bytes control sends into an [`Aum`] before it is verified and replayed
2012    /// (Go `tka.AUM` CBOR unmarshal).
2013    ///
2014    /// The keymap (Go `cbor:"…,keyasint"`): `message_kind`=1 and `prev_aum_hash`=2 are
2015    /// non-`omitempty` (a nil prev arrives as CBOR null → `None`); `key`=3, `key_id`=4, `state`=5,
2016    /// `votes`=6, `meta`=7, `signatures`=23 are `omitempty` (absent ⇒ the field's zero value).
2017    ///
2018    /// Fail-closed: a trailing byte after the AUM, an unknown field key, a wrong value type, an
2019    /// unknown `message_kind`, or any malformed CBOR head all return [`TkaError::Decode`]. The
2020    /// decoder does NOT re-canonicalize or validate chain structure — that is the verifier's job; it
2021    /// only reconstructs the struct the bytes describe.
2022    ///
2023    /// # Errors
2024    ///
2025    /// Returns [`TkaError::Decode`] if `buf` is not exactly one canonical-shaped AUM CBOR map.
2026    pub fn from_cbor(buf: &[u8]) -> Result<Aum, TkaError> {
2027        let (val, rest) = decode_value(buf, 0)?;
2028        if !rest.is_empty() {
2029            return Err(TkaError::Decode("trailing bytes after AUM"));
2030        }
2031        let Value::IntMap(entries) = val else {
2032            return Err(TkaError::Decode("AUM is not an int-keyed map"));
2033        };
2034        let mut message_kind = None;
2035        let mut prev_aum_hash = None;
2036        let mut have_prev = false;
2037        let mut key = None;
2038        let mut key_id = Vec::new();
2039        let mut state = None;
2040        let mut votes = None;
2041        let mut meta = Vec::new();
2042        let mut signatures = Vec::new();
2043        for (k, v) in entries {
2044            match k {
2045                1 => {
2046                    message_kind = Some(
2047                        AumKind::from_u8(
2048                            u8::try_from(expect_uint(v)?)
2049                                .map_err(|_| TkaError::Decode("message kind out of range"))?,
2050                        )
2051                        .ok_or(TkaError::Decode("unknown AUM message kind"))?,
2052                    )
2053                }
2054                2 => {
2055                    have_prev = true;
2056                    prev_aum_hash = match v {
2057                        Value::Null => None,
2058                        Value::Bytes(b) => Some(aum_hash_from_bytes(b)?),
2059                        _ => return Err(TkaError::Decode("prev_aum_hash not bytes or null")),
2060                    }
2061                }
2062                3 => key = Some(AumKey::from_value(v)?),
2063                4 => key_id = expect_bytes_or_null(v)?,
2064                5 => state = Some(AumState::from_value(v)?),
2065                6 => {
2066                    votes = Some(
2067                        u32::try_from(expect_uint(v)?)
2068                            .map_err(|_| TkaError::Decode("votes out of range"))?,
2069                    )
2070                }
2071                7 => meta = meta_from_value(v)?,
2072                23 => {
2073                    let Value::Array(items) = v else {
2074                        return Err(TkaError::Decode("signatures not an array"));
2075                    };
2076                    signatures = items
2077                        .into_iter()
2078                        .map(AumSignature::from_value)
2079                        .collect::<Result<Vec<_>, _>>()?;
2080                }
2081                _ => return Err(TkaError::Decode("unknown AUM field")),
2082            }
2083        }
2084        // `message_kind` (1) and `prev_aum_hash` (2) are non-`omitempty`: both keys must be present
2085        // on the wire (the prev *value* may be null, but the key itself is always emitted).
2086        if !have_prev {
2087            return Err(TkaError::Decode("AUM missing prev_aum_hash"));
2088        }
2089        Ok(Aum {
2090            message_kind: message_kind.ok_or(TkaError::Decode("AUM missing message kind"))?,
2091            prev_aum_hash,
2092            key,
2093            key_id,
2094            state,
2095            votes,
2096            meta,
2097            signatures,
2098        })
2099    }
2100}
2101
2102/// Decode one CBOR value (the subset the encoder produces) from `buf`, returning the value and the
2103/// remaining bytes. Minimal — only the major types TKA uses.
2104fn decode_value(buf: &[u8], depth: usize) -> Result<(Value, &[u8]), TkaError> {
2105    // Bound generic CBOR container nesting so a deeply-nested array/map (even a non-signature one,
2106    // e.g. an AUM with nested arrays) cannot overflow the recursive decoder before shape validation
2107    // runs. Shared by the AUM and node-key-signature paths, so the message is kept neutral (the
2108    // signature-specific depth guard with its own message lives in `node_key_signature_from_value`).
2109    if depth > MAX_SIG_NESTING_DEPTH {
2110        return Err(TkaError::Decode("CBOR nesting too deep"));
2111    }
2112    let (major, arg, rest) = decode_head(buf)?;
2113    match major {
2114        0 => Ok((Value::Uint(arg), rest)),
2115        2 => {
2116            // `usize::try_from` rather than `as usize`: on a 32-bit target a `u64` length above
2117            // `usize::MAX` must fail closed, not silently truncate to a smaller in-bounds length.
2118            let len = usize::try_from(arg).map_err(|_| TkaError::Decode("byte string too long"))?;
2119            if rest.len() < len {
2120                return Err(TkaError::Decode("byte string truncated"));
2121            }
2122            Ok((Value::Bytes(rest[..len].to_vec()), &rest[len..]))
2123        }
2124        3 => {
2125            let len = usize::try_from(arg).map_err(|_| TkaError::Decode("text string too long"))?;
2126            if rest.len() < len {
2127                return Err(TkaError::Decode("text string truncated"));
2128            }
2129            Ok((Value::Text(rest[..len].to_vec()), &rest[len..]))
2130        }
2131        4 => {
2132            let mut items = Vec::new();
2133            let mut cur = rest;
2134            for _ in 0..arg {
2135                let (v, next) = decode_value(cur, depth + 1)?;
2136                items.push(v);
2137                cur = next;
2138            }
2139            Ok((Value::Array(items), cur))
2140        }
2141        5 => {
2142            // A CBOR map decodes to either an `IntMap` (unsigned-integer keys — the `keyasint`
2143            // structs: AUM, Key, State, signatures) or a `TextMap` (text-string keys — Go
2144            // `map[string]string` `Meta` fields). The variant is chosen by the FIRST key's major
2145            // type and every key must match it: TKA never emits a mixed-key map, so a key whose
2146            // type differs from the first is rejected (fail-closed). An empty map decodes to an
2147            // empty `IntMap` (matching the prior behavior; an empty `map[string]string` is
2148            // `omitempty`-dropped by Go, so an empty map on the wire is always a struct).
2149            decode_map(rest, arg, depth)
2150        }
2151        // Major type 7: only the `null` simple value (`0xf6`, argument 22) is accepted. Go's
2152        // `fxamacker/cbor` emits CBOR null for a nil *non*-`omitempty` byte/slice/pointer field —
2153        // an AUM's genesis `prev_aum_hash`, `AumSignature.{key_id,signature}`, and an `AumState`'s
2154        // `last_aum_hash`/`disablement_values`/`keys` (see the encoder's `Value::Null` arm). Any
2155        // other major-7 simple value or float (booleans, undefined, `f16`/`f32`/`f64`) is rejected:
2156        // TKA never emits them, so accepting them would only widen the attack surface. The
2157        // `NodeKeySignature` path is unaffected — its `expect_bytes` rejects `Value::Null`, so a
2158        // null where bytes are required still fails closed there.
2159        7 if arg == 22 => Ok((Value::Null, rest)),
2160        // Major types 1 (negative int) and 6 (tag), and any other major-7 value, are unsupported.
2161        _ => Err(TkaError::Decode("unsupported CBOR major type")),
2162    }
2163}
2164
2165/// Decode the `count` key/value pairs of a CBOR map (major type 5) from `buf`, producing either a
2166/// [`Value::IntMap`] (unsigned-integer keys) or a [`Value::TextMap`] (text-string keys). The key
2167/// type is fixed by the first pair; every subsequent key must use the same major type (TKA emits no
2168/// mixed-key maps). An empty map decodes to an empty `IntMap`. Duplicate keys are rejected
2169/// (fail-closed), matching the CTAP2 / Go "no duplicate map keys" rule. Map *key ordering is not
2170/// enforced* on decode: the verify path re-serializes canonically before hashing, so a non-canonical
2171/// input simply produces a different (still self-consistent) struct, never a hash that silently
2172/// matches Go's for different bytes.
2173fn decode_map(buf: &[u8], count: u64, depth: usize) -> Result<(Value, &[u8]), TkaError> {
2174    if count == 0 {
2175        return Ok((Value::IntMap(Vec::new()), buf));
2176    }
2177    // Peek the first key's major type to pick the map variant.
2178    let (first_major, ..) = decode_head(buf)?;
2179    match first_major {
2180        0 => {
2181            let mut entries: Vec<(u64, Value)> = Vec::new();
2182            let mut cur = buf;
2183            for _ in 0..count {
2184                let (k, next) = decode_head(cur).and_then(|(m, a, r)| {
2185                    if m == 0 {
2186                        Ok((a, r))
2187                    } else {
2188                        Err(TkaError::Decode("mixed map key types"))
2189                    }
2190                })?;
2191                let (v, next2) = decode_value(next, depth + 1)?;
2192                entries.push((k, v));
2193                cur = next2;
2194            }
2195            reject_duplicate_keys(entries.iter().map(|(k, _)| *k))?;
2196            Ok((Value::IntMap(entries), cur))
2197        }
2198        3 => {
2199            let mut entries: Vec<(Vec<u8>, Value)> = Vec::new();
2200            let mut cur = buf;
2201            for _ in 0..count {
2202                // Decode the text-string key via the shared value decoder so its length/truncation
2203                // checks apply uniformly, then require it to be `Value::Text`.
2204                let (key_val, next) = decode_value(cur, depth + 1)?;
2205                let Value::Text(k) = key_val else {
2206                    return Err(TkaError::Decode("mixed map key types"));
2207                };
2208                let (v, next2) = decode_value(next, depth + 1)?;
2209                entries.push((k, v));
2210                cur = next2;
2211            }
2212            reject_duplicate_keys(entries.iter().map(|(k, _)| k.clone()))?;
2213            Ok((Value::TextMap(entries), cur))
2214        }
2215        _ => Err(TkaError::Decode("map key not uint or text string")),
2216    }
2217}
2218
2219/// Reject a CBOR map with duplicate keys (CTAP2 / Go forbid them) in `O(n log n)` via a sort, rather
2220/// than the `O(n²)` per-insert linear scan a naive decoder uses. The map element count is
2221/// attacker-controlled (a CBOR head can claim a large count), so the quadratic form is a latent
2222/// super-linear CPU-DoS on a hostile control-plane blob; the sort keeps it linear-ish. Insertion
2223/// order of the map itself is preserved by the caller (the verify path re-serializes canonically
2224/// before hashing, so wire order never reaches a hash).
2225fn reject_duplicate_keys<K: Ord>(keys: impl Iterator<Item = K>) -> Result<(), TkaError> {
2226    let mut ks: Vec<K> = keys.collect();
2227    ks.sort_unstable();
2228    if ks.windows(2).any(|w| w[0] == w[1]) {
2229        return Err(TkaError::Decode("duplicate map key"));
2230    }
2231    Ok(())
2232}
2233
2234/// Decode a CBOR head: returns `(major, argument, rest)`.
2235fn decode_head(buf: &[u8]) -> Result<(u8, u64, &[u8]), TkaError> {
2236    let first = *buf.first().ok_or(TkaError::Decode("empty CBOR"))?;
2237    let major = first >> 5;
2238    let info = first & 0x1f;
2239    let rest = &buf[1..];
2240    let (arg, rest) = match info {
2241        n @ 0..=23 => (n as u64, rest),
2242        24 => {
2243            let b = *rest.first().ok_or(TkaError::Decode("truncated u8"))?;
2244            (b as u64, &rest[1..])
2245        }
2246        25 => {
2247            if rest.len() < 2 {
2248                return Err(TkaError::Decode("truncated u16"));
2249            }
2250            (u16::from_be_bytes([rest[0], rest[1]]) as u64, &rest[2..])
2251        }
2252        26 => {
2253            if rest.len() < 4 {
2254                return Err(TkaError::Decode("truncated u32"));
2255            }
2256            (
2257                u32::from_be_bytes([rest[0], rest[1], rest[2], rest[3]]) as u64,
2258                &rest[4..],
2259            )
2260        }
2261        27 => {
2262            if rest.len() < 8 {
2263                return Err(TkaError::Decode("truncated u64"));
2264            }
2265            let mut b = [0u8; 8];
2266            b.copy_from_slice(&rest[..8]);
2267            (u64::from_be_bytes(b), &rest[8..])
2268        }
2269        _ => return Err(TkaError::Decode("indefinite/reserved CBOR length")),
2270    };
2271    Ok((major, arg, rest))
2272}
2273
2274// ----- RFC 4648 base32 (standard alphabet, no padding) -----
2275
2276const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
2277
2278fn base32_encode_nopad(data: &[u8]) -> String {
2279    let mut out = String::new();
2280    let mut buffer: u32 = 0;
2281    let mut bits: u32 = 0;
2282    for &b in data {
2283        buffer = (buffer << 8) | b as u32;
2284        bits += 8;
2285        while bits >= 5 {
2286            bits -= 5;
2287            let idx = ((buffer >> bits) & 0x1f) as usize;
2288            out.push(BASE32_ALPHABET[idx] as char);
2289        }
2290    }
2291    if bits > 0 {
2292        let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
2293        out.push(BASE32_ALPHABET[idx] as char);
2294    }
2295    out
2296}
2297
2298fn base32_decode_nopad(text: &str) -> Option<Vec<u8>> {
2299    let mut buffer: u32 = 0;
2300    let mut bits: u32 = 0;
2301    let mut out = Vec::new();
2302    for c in text.chars() {
2303        let val = match c {
2304            'A'..='Z' => c as u32 - 'A' as u32,
2305            '2'..='7' => c as u32 - '2' as u32 + 26,
2306            _ => return None,
2307        };
2308        buffer = (buffer << 5) | val;
2309        bits += 5;
2310        if bits >= 8 {
2311            bits -= 8;
2312            out.push((buffer >> bits) as u8);
2313        }
2314    }
2315    Some(out)
2316}
2317
2318#[cfg(test)]
2319mod tests {
2320    use super::*;
2321
2322    #[test]
2323    fn base32_roundtrip_32_bytes() {
2324        let h = AumHash([0xABu8; 32]);
2325        let text = h.to_base32();
2326        let back = AumHash::from_base32(&text).unwrap();
2327        assert_eq!(h, back);
2328    }
2329
2330    #[test]
2331    fn base32_rejects_wrong_length() {
2332        // "AAAA" decodes to fewer than 32 bytes.
2333        assert!(AumHash::from_base32("AAAA").is_none());
2334        // Lowercase / invalid alphabet rejected.
2335        assert!(AumHash::from_base32("aaaa").is_none());
2336    }
2337
2338    #[test]
2339    fn base32_matches_known_vector() {
2340        // RFC 4648 base32 of "foobar" is "MZXW6YTBOI" (with padding "======"); no-pad drops the pad.
2341        assert_eq!(base32_encode_nopad(b"foobar"), "MZXW6YTBOI");
2342        assert_eq!(base32_decode_nopad("MZXW6YTBOI").unwrap(), b"foobar");
2343    }
2344
2345    #[test]
2346    fn credential_signature_cannot_authorize() {
2347        let auth = Authority::from_state(AumHash([0; 32]), State::default());
2348        let sig = NodeKeySignature {
2349            sig_kind: SigKind::Credential,
2350            pubkey: alloc::vec![1, 2, 3],
2351            key_id: alloc::vec![4, 5, 6],
2352            signature: alloc::vec![0; 64],
2353            nested: None,
2354            wrapping_pubkey: Vec::new(),
2355        };
2356        let cbor = sig.to_cbor(true).to_vec();
2357        let err = auth.node_key_authorized(&[1, 2, 3], &cbor).unwrap_err();
2358        assert_eq!(err, TkaError::CredentialCannotAuthorize);
2359    }
2360
2361    #[test]
2362    fn untrusted_key_denied() {
2363        // A direct signature whose key id is not in the (empty) trusted state.
2364        let auth = Authority::from_state(AumHash([0; 32]), State::default());
2365        let sig = NodeKeySignature {
2366            sig_kind: SigKind::Direct,
2367            pubkey: alloc::vec![9; 32],
2368            key_id: alloc::vec![7; 32],
2369            signature: alloc::vec![0; 64],
2370            nested: None,
2371            wrapping_pubkey: Vec::new(),
2372        };
2373        let cbor = sig.to_cbor(true).to_vec();
2374        let err = auth.node_key_authorized(&[9; 32], &cbor).unwrap_err();
2375        assert_eq!(err, TkaError::UntrustedKey);
2376    }
2377
2378    #[test]
2379    fn direct_signature_verifies_end_to_end() {
2380        use ed25519_dalek::{Signer, SigningKey};
2381
2382        // A trusted Ed25519 key signs a node key directly.
2383        let signing = SigningKey::from_bytes(&[42u8; 32]);
2384        let trusted_pub = signing.verifying_key().to_bytes().to_vec();
2385        let node_key = alloc::vec![7u8; 32];
2386
2387        let trusted = Key {
2388            kind: KeyKind::Ed25519,
2389            votes: 1,
2390            public: trusted_pub.clone(),
2391        };
2392        let auth = Authority::from_state(
2393            AumHash([0; 32]),
2394            State {
2395                keys: alloc::vec![trusted],
2396            },
2397        );
2398
2399        // Build the signature, compute its sig-hash preimage, sign, then fill in the signature.
2400        let mut sig = NodeKeySignature {
2401            sig_kind: SigKind::Direct,
2402            pubkey: node_key.clone(),
2403            key_id: trusted_pub.clone(),
2404            signature: Vec::new(),
2405            nested: None,
2406            wrapping_pubkey: Vec::new(),
2407        };
2408        let sig_hash = sig.sig_hash();
2409        // NOTE: Go verifies Direct with ZIP-215; a standard ed25519-dalek signature is accepted by
2410        // ZIP-215 verification (ZIP-215 is a superset), so signing with dalek here is valid.
2411        sig.signature = signing.sign(&sig_hash).to_bytes().to_vec();
2412
2413        let cbor = sig.to_cbor(true).to_vec();
2414        assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
2415
2416        // A different node key must NOT be authorized by this signature.
2417        let other = alloc::vec![8u8; 32];
2418        assert_eq!(
2419            auth.node_key_authorized(&other, &cbor).unwrap_err(),
2420            TkaError::NodeKeyMismatch
2421        );
2422    }
2423
2424    #[test]
2425    fn tampered_signature_denied() {
2426        use ed25519_dalek::{Signer, SigningKey};
2427
2428        let signing = SigningKey::from_bytes(&[42u8; 32]);
2429        let trusted_pub = signing.verifying_key().to_bytes().to_vec();
2430        let node_key = alloc::vec![7u8; 32];
2431        let auth = Authority::from_state(
2432            AumHash([0; 32]),
2433            State {
2434                keys: alloc::vec![Key {
2435                    kind: KeyKind::Ed25519,
2436                    votes: 1,
2437                    public: trusted_pub.clone(),
2438                }],
2439            },
2440        );
2441        let mut sig = NodeKeySignature {
2442            sig_kind: SigKind::Direct,
2443            pubkey: node_key.clone(),
2444            key_id: trusted_pub,
2445            signature: Vec::new(),
2446            nested: None,
2447            wrapping_pubkey: Vec::new(),
2448        };
2449        let sig_hash = sig.sig_hash();
2450        let mut sigbytes = signing.sign(&sig_hash).to_bytes();
2451        sigbytes[0] ^= 0xff; // tamper
2452        sig.signature = sigbytes.to_vec();
2453
2454        let cbor = sig.to_cbor(true).to_vec();
2455        assert_eq!(
2456            auth.node_key_authorized(&node_key, &cbor).unwrap_err(),
2457            TkaError::BadSignature
2458        );
2459    }
2460
2461    #[test]
2462    fn head_matches_check() {
2463        let h = AumHash([5u8; 32]);
2464        let auth = Authority::from_state(h, State::default());
2465        assert!(auth.head_matches(&h));
2466        assert!(!auth.head_matches(&AumHash([6u8; 32])));
2467    }
2468
2469    // ----- Fix 1: depth cap on attacker-controlled nesting -----
2470
2471    #[test]
2472    fn deeply_nested_signature_rejected_without_overflow() {
2473        // Wrap a NodeKeySignature inside `nested` far past MAX_SIG_NESTING_DEPTH. This is cheap to
2474        // construct (a few bytes per level) and would overflow an unbounded recursive decoder. The
2475        // decoder must reject it as a Decode error — never panic / stack-overflow.
2476        let mut sig = NodeKeySignature {
2477            sig_kind: SigKind::Direct,
2478            pubkey: alloc::vec![1u8; 32],
2479            key_id: alloc::vec![2u8; 32],
2480            signature: alloc::vec![3u8; 64],
2481            nested: None,
2482            wrapping_pubkey: Vec::new(),
2483        };
2484        for _ in 0..(MAX_SIG_NESTING_DEPTH + 8) {
2485            sig = NodeKeySignature {
2486                sig_kind: SigKind::Rotation,
2487                pubkey: alloc::vec![1u8; 32],
2488                key_id: Vec::new(),
2489                signature: alloc::vec![3u8; 64],
2490                nested: Some(alloc::boxed::Box::new(sig)),
2491                wrapping_pubkey: alloc::vec![1u8; 32],
2492            };
2493        }
2494        let cbor = sig.to_cbor(true).to_vec();
2495        let err = decode_node_key_signature(&cbor).unwrap_err();
2496        // The shared generic-container depth guard in `decode_value` trips first (the CBOR is
2497        // nested past the cap before the signature-shape walk runs), so the neutral message.
2498        assert_eq!(err, TkaError::Decode("CBOR nesting too deep"));
2499    }
2500
2501    // ----- Fix 5: duplicate CBOR map keys rejected -----
2502
2503    #[test]
2504    fn duplicate_map_key_rejected() {
2505        // Hand-craft a CBOR map with key 1 repeated: map(2) { 1:0, 1:1 } => 0xa2 01 00 01 01.
2506        let blob = [0xa2u8, 0x01, 0x00, 0x01, 0x01];
2507        let err = decode_node_key_signature(&blob).unwrap_err();
2508        assert_eq!(err, TkaError::Decode("duplicate map key"));
2509    }
2510
2511    // ----- Fix 3: rotation-chain happy path + ZIP-215/std split -----
2512
2513    // ZIP-215 vs standard ed25519 in TKA, and why this crate carries BOTH verifiers:
2514    //
2515    //   * Direct/Credential signatures are verified with `verify_ed25519_zip215` (ed25519-zebra),
2516    //     matching Go `ed25519consensus.Verify` — the *cofactored* ZIP-215 rule the TKA leaf
2517    //     signatures are produced under. ZIP-215 is a strict superset of RFC 8032: any standard
2518    //     (dalek) signature is accepted by it, which is why the tests below can sign leaves with
2519    //     dalek and still verify under zebra.
2520    //   * The outer rotation WRAP signature is verified with `verify_ed25519_std` (ed25519-dalek),
2521    //     matching Go's plain `ed25519.Verify` for the rotation wrap. Collapsing these two
2522    //     verifiers into one would silently diverge from Go on the wire — hence both deps are kept
2523    //     (see Cargo.toml comment).
2524    #[test]
2525    fn rotation_chain_verifies_end_to_end() {
2526        use ed25519_dalek::{Signer, SigningKey};
2527
2528        // Trusted key signs the inner Direct over the wrapping (pivot) pubkey.
2529        let trusted = SigningKey::from_bytes(&[7u8; 32]);
2530        let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
2531
2532        // The rotation pivot: a fresh keypair whose public key the inner Direct authorizes and
2533        // whose private key signs the outer rotation wrap.
2534        let wrapping = SigningKey::from_bytes(&[9u8; 32]);
2535        let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
2536
2537        let node_key = alloc::vec![5u8; 32];
2538
2539        let auth = Authority::from_state(
2540            AumHash([0; 32]),
2541            State {
2542                keys: alloc::vec![Key {
2543                    kind: KeyKind::Ed25519,
2544                    votes: 1,
2545                    public: trusted_pub.clone(),
2546                }],
2547            },
2548        );
2549
2550        // Inner Direct: trusted key authorizes the wrapping pubkey. Verified ZIP-215, so a dalek
2551        // signature is accepted.
2552        let mut inner = NodeKeySignature {
2553            sig_kind: SigKind::Direct,
2554            pubkey: wrapping_pub.clone(),
2555            key_id: trusted_pub.clone(),
2556            signature: Vec::new(),
2557            nested: None,
2558            wrapping_pubkey: wrapping_pub.clone(),
2559        };
2560        let inner_hash = inner.sig_hash();
2561        inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
2562
2563        // Outer Rotation: signs the node key with the wrapping key (verified STANDARD ed25519).
2564        let mut outer = NodeKeySignature {
2565            sig_kind: SigKind::Rotation,
2566            pubkey: node_key.clone(),
2567            key_id: Vec::new(),
2568            signature: Vec::new(),
2569            nested: Some(alloc::boxed::Box::new(inner)),
2570            wrapping_pubkey: Vec::new(),
2571        };
2572        let outer_hash = outer.sig_hash();
2573        outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
2574
2575        let cbor = outer.to_cbor(true).to_vec();
2576        assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
2577
2578        // A tampered rotation-wrap signature must be rejected by the STANDARD ed25519 verifier.
2579        let mut tampered = outer.clone();
2580        let mut sb = tampered.signature.clone();
2581        sb[0] ^= 0xff;
2582        tampered.signature = sb;
2583        let cbor_bad = tampered.to_cbor(true).to_vec();
2584        assert_eq!(
2585            auth.node_key_authorized(&node_key, &cbor_bad).unwrap_err(),
2586            TkaError::BadSignature
2587        );
2588    }
2589
2590    // ----- tsr-358: nested-Credential `pubkey` is UNUSED (Go parity) -----
2591
2592    /// A rotation wrapping a nested **Credential** must verify regardless of the credential's
2593    /// `pubkey` field — Go's `SigCredential` "certifies an indirection key rather than a node key,
2594    /// so there's no need to check the node key", and `verifySignature` adds NO `nested.pubkey ==
2595    /// wrappingPublic` bind. The pre-fix code rejected the real Go shape (empty credential `pubkey`)
2596    /// and only accepted a fork-invented `pubkey == wrapping_pub` construction — a deny-direction
2597    /// consensus split (legitimate credential-provisioned peers wrongly denied under enforce). This
2598    /// pins the Go behavior: empty `pubkey` accepted, and an arbitrary (ignored) `pubkey` also
2599    /// accepted; security comes purely from the two signatures verifying, not from the field.
2600    #[test]
2601    fn rotation_nested_credential_pubkey_is_unused() {
2602        use ed25519_dalek::{Signer, SigningKey};
2603
2604        let trusted = SigningKey::from_bytes(&[11u8; 32]);
2605        let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
2606        let wrapping = SigningKey::from_bytes(&[13u8; 32]);
2607        let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
2608        let node_key = alloc::vec![6u8; 32];
2609
2610        let auth = Authority::from_state(
2611            AumHash([0; 32]),
2612            State {
2613                keys: alloc::vec![Key {
2614                    kind: KeyKind::Ed25519,
2615                    votes: 1,
2616                    public: trusted_pub.clone(),
2617                }],
2618            },
2619        );
2620
2621        // Build a rotation wrapping a nested Credential whose `pubkey` is `cred_pubkey`. The
2622        // credential is signed by the trusted key over its own sig-hash; the credential's
2623        // `wrapping_pubkey` is the rotation pivot the outer signature is verified against.
2624        let build = |cred_pubkey: Vec<u8>| -> Vec<u8> {
2625            let mut inner = NodeKeySignature {
2626                sig_kind: SigKind::Credential,
2627                pubkey: cred_pubkey,
2628                key_id: trusted_pub.clone(),
2629                signature: Vec::new(),
2630                nested: None,
2631                wrapping_pubkey: wrapping_pub.clone(),
2632            };
2633            let inner_hash = inner.sig_hash();
2634            inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
2635
2636            let mut outer = NodeKeySignature {
2637                sig_kind: SigKind::Rotation,
2638                pubkey: node_key.clone(),
2639                key_id: Vec::new(),
2640                signature: Vec::new(),
2641                nested: Some(alloc::boxed::Box::new(inner)),
2642                wrapping_pubkey: Vec::new(),
2643            };
2644            let outer_hash = outer.sig_hash();
2645            outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
2646            outer.to_cbor(true).to_vec()
2647        };
2648
2649        // The real Go shape: a SigCredential leaves `Pubkey` EMPTY. Must be accepted.
2650        let cbor_empty = build(Vec::new());
2651        assert!(
2652            auth.node_key_authorized(&node_key, &cbor_empty).is_ok(),
2653            "a credential with empty pubkey (the Go shape) must verify"
2654        );
2655
2656        // An arbitrary credential `pubkey` is IGNORED (Go never checks it) — also accepted.
2657        let cbor_arbitrary = build(alloc::vec![0xaau8; 32]);
2658        assert!(
2659            auth.node_key_authorized(&node_key, &cbor_arbitrary).is_ok(),
2660            "a credential's pubkey is unused; an arbitrary value must not change the verdict"
2661        );
2662
2663        // Sanity: tampering the OUTER rotation signature is still rejected (the real security gate).
2664        let mut outer_bad_cbor = {
2665            let mut inner = NodeKeySignature {
2666                sig_kind: SigKind::Credential,
2667                pubkey: Vec::new(),
2668                key_id: trusted_pub.clone(),
2669                signature: Vec::new(),
2670                nested: None,
2671                wrapping_pubkey: wrapping_pub.clone(),
2672            };
2673            let ih = inner.sig_hash();
2674            inner.signature = trusted.sign(&ih).to_bytes().to_vec();
2675            let mut outer = NodeKeySignature {
2676                sig_kind: SigKind::Rotation,
2677                pubkey: node_key.clone(),
2678                key_id: Vec::new(),
2679                signature: Vec::new(),
2680                nested: Some(alloc::boxed::Box::new(inner)),
2681                wrapping_pubkey: Vec::new(),
2682            };
2683            let oh = outer.sig_hash();
2684            let mut sig = wrapping.sign(&oh).to_bytes().to_vec();
2685            sig[0] ^= 0xff;
2686            outer.signature = sig;
2687            outer.to_cbor(true).to_vec()
2688        };
2689        assert_eq!(
2690            auth.node_key_authorized(&node_key, &outer_bad_cbor)
2691                .unwrap_err(),
2692            TkaError::BadSignature,
2693            "a tampered rotation-wrap signature must still be rejected"
2694        );
2695        outer_bad_cbor.clear();
2696    }
2697
2698    /// Multi-level rotation: an intermediate rotation layer omits its own `wrapping_pubkey`, so the
2699    /// outer signature's verify key must be resolved by RECURSING (`wrapping_public`) into the
2700    /// inner-most layer that defines one — Go `NodeKeySignature.wrappingPublic`. The pre-fix code
2701    /// read `nested.wrapping_pubkey` directly and rejected this with "wrapping pubkey wrong length"
2702    /// (the second deny-direction consensus split).
2703    #[test]
2704    fn multi_level_rotation_resolves_wrapping_key_by_recursion() {
2705        use ed25519_dalek::{Signer, SigningKey};
2706
2707        let trusted = SigningKey::from_bytes(&[21u8; 32]);
2708        let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
2709        // The single rotation pivot key, carried only on the INNERMOST signature.
2710        let pivot = SigningKey::from_bytes(&[23u8; 32]);
2711        let pivot_pub = pivot.verifying_key().to_bytes().to_vec();
2712        let node_key = alloc::vec![7u8; 32];
2713
2714        let auth = Authority::from_state(
2715            AumHash([0; 32]),
2716            State {
2717                keys: alloc::vec![Key {
2718                    kind: KeyKind::Ed25519,
2719                    votes: 1,
2720                    public: trusted_pub.clone(),
2721                }],
2722            },
2723        );
2724
2725        // Innermost: a Direct signature by the trusted key, carrying the pivot as its wrapping key.
2726        let mut inner = NodeKeySignature {
2727            sig_kind: SigKind::Direct,
2728            pubkey: pivot_pub.clone(),
2729            key_id: trusted_pub.clone(),
2730            signature: Vec::new(),
2731            nested: None,
2732            wrapping_pubkey: pivot_pub.clone(),
2733        };
2734        let inner_hash = inner.sig_hash();
2735        inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
2736
2737        // Middle rotation: OMITS its own wrapping_pubkey (empty) — wrapping_public must recurse to
2738        // `inner`'s pivot. Its outer-of-inner signature is verified under the pivot key.
2739        let mut middle = NodeKeySignature {
2740            sig_kind: SigKind::Rotation,
2741            pubkey: pivot_pub.clone(),
2742            key_id: Vec::new(),
2743            signature: Vec::new(),
2744            nested: Some(alloc::boxed::Box::new(inner)),
2745            wrapping_pubkey: Vec::new(),
2746        };
2747        let middle_hash = middle.sig_hash();
2748        middle.signature = pivot.sign(&middle_hash).to_bytes().to_vec();
2749
2750        // Outer rotation over `middle`: also resolves its verify key by recursing to the pivot.
2751        let mut outer = NodeKeySignature {
2752            sig_kind: SigKind::Rotation,
2753            pubkey: node_key.clone(),
2754            key_id: Vec::new(),
2755            signature: Vec::new(),
2756            nested: Some(alloc::boxed::Box::new(middle)),
2757            wrapping_pubkey: Vec::new(),
2758        };
2759        let outer_hash = outer.sig_hash();
2760        outer.signature = pivot.sign(&outer_hash).to_bytes().to_vec();
2761
2762        let cbor = outer.to_cbor(true).to_vec();
2763        assert!(
2764            auth.node_key_authorized(&node_key, &cbor).is_ok(),
2765            "a multi-level rotation with an intermediate omitting wrapping_pubkey must verify via recursion"
2766        );
2767    }
2768
2769    // ----- CTAP2-CBOR byte-exactness FROZEN regression vector -----
2770
2771    /// A small hex helper for embedding captured bytes in a failure message.
2772    fn hex(bytes: &[u8]) -> String {
2773        let mut s = String::new();
2774        for b in bytes {
2775            s.push_str(&alloc::format!("{b:02x}"));
2776        }
2777        s
2778    }
2779
2780    /// FROZEN CTAP2-CBOR byte-exactness vector for the wire/signing serialization.
2781    ///
2782    /// The crate docs (and the `cbor` module) state the CTAP2-canonical CBOR encoding is asserted by
2783    /// construction but NOT cross-validated against Go's `fxamacker/cbor` (CTAP2 mode) in this fork.
2784    /// The existing TKA tests build a signature, sign it, and verify round-trip — so they would all
2785    /// still pass if the canonical encoding silently changed (int-map key ordering, smallest-int
2786    /// rule, omitempty), because both sides of the round-trip use the same encoder. That class of
2787    /// change would, however, break wire-compat with a live Go TKA.
2788    ///
2789    /// This pins the EXACT bytes for a fixed `NodeKeySignature` (Direct, deterministic key material):
2790    /// the full `to_cbor(true)` serialization, the `to_cbor(false)` SigHash preimage, the resulting
2791    /// `sig_hash` (BLAKE2s-256 of the preimage), and the `aum_hash` over the full serialization. ANY
2792    /// accidental change to canonical-CBOR encoding or the BLAKE2s digest breaks this test.
2793    ///
2794    /// NOTE: this is a regression-FREEZE vector captured from the current encoder, NOT a Go-sourced
2795    /// cross-vector. It should be replaced with a real `fxamacker/cbor` CTAP2 vector (the same
2796    /// `NodeKeySignature` encoded by a live Go `tka`) once one can be captured.
2797    #[test]
2798    fn node_key_signature_cbor_frozen_vector() {
2799        // Deterministic, fixed key material — NOT random. byte i = i, so the bytes are obvious.
2800        let pubkey: Vec<u8> = (0u8..32).collect();
2801        let key_id: Vec<u8> = (32u8..64).collect();
2802        let signature: Vec<u8> = (64u8..128).collect();
2803
2804        let sig = NodeKeySignature {
2805            sig_kind: SigKind::Direct,
2806            pubkey,
2807            key_id,
2808            signature,
2809            nested: None,
2810            wrapping_pubkey: Vec::new(), // empty -> omitted (omitempty), key 6 must NOT appear
2811        };
2812
2813        // 1. Full serialization (include_signature = true): keys 1,2,3,4 present, 5/6 omitted.
2814        let full = sig.to_cbor(true).to_vec();
2815        const EXPECTED_FULL: &[u8] = &[
2816            0xa4, 0x01, 0x01, 0x02, 0x58, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
2817            0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
2818            0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x03, 0x58, 0x20, 0x20,
2819            0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e,
2820            0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
2821            0x3d, 0x3e, 0x3f, 0x04, 0x58, 0x40, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47,
2822            0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55,
2823            0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, 0x61, 0x62, 0x63,
2824            0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71,
2825            0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f,
2826        ];
2827        assert_eq!(
2828            full,
2829            EXPECTED_FULL,
2830            "full CBOR serialization changed (canonical-CBOR encoding drift). actual: {}",
2831            hex(&full)
2832        );
2833
2834        // 2. SigHash preimage (include_signature = false): key 4 (signature) omitted.
2835        let preimage = sig.to_cbor(false).to_vec();
2836        const EXPECTED_PREIMAGE: &[u8] = &[
2837            0xa3, 0x01, 0x01, 0x02, 0x58, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
2838            0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
2839            0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x03, 0x58, 0x20, 0x20,
2840            0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e,
2841            0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
2842            0x3d, 0x3e, 0x3f,
2843        ];
2844        assert_eq!(
2845            preimage,
2846            EXPECTED_PREIMAGE,
2847            "SigHash preimage CBOR changed. actual: {}",
2848            hex(&preimage)
2849        );
2850
2851        // 3. sig_hash = BLAKE2s-256(preimage) — pinned.
2852        let sig_hash = sig.sig_hash();
2853        const EXPECTED_SIG_HASH: [u8; AUM_HASH_LEN] = [
2854            0x22, 0x6f, 0x9c, 0xbc, 0x63, 0x73, 0x92, 0x75, 0x2e, 0x0e, 0xb1, 0x32, 0x9c, 0xc4,
2855            0x99, 0x07, 0x01, 0x4a, 0xb6, 0x4f, 0x8e, 0x5d, 0x82, 0x85, 0xc2, 0x91, 0x42, 0x62,
2856            0xf6, 0xa6, 0xa8, 0x33,
2857        ];
2858        assert_eq!(
2859            sig_hash,
2860            EXPECTED_SIG_HASH,
2861            "sig_hash (BLAKE2s-256 of preimage) changed. actual: {}",
2862            hex(&sig_hash)
2863        );
2864
2865        // 4. aum_hash over the full serialization — pinned (exercises the public `aum_hash` helper
2866        //    + BLAKE2s digest over a frozen input).
2867        let aum = aum_hash(&full);
2868        const EXPECTED_AUM_HASH: [u8; AUM_HASH_LEN] = [
2869            0xa4, 0x40, 0x71, 0xa3, 0x7a, 0xbf, 0x80, 0x92, 0xd6, 0xff, 0x23, 0x84, 0xb2, 0xb0,
2870            0xa3, 0x50, 0xc7, 0xcb, 0x48, 0x41, 0xed, 0x68, 0x99, 0x62, 0x41, 0x7c, 0xd4, 0x23,
2871            0x68, 0xdc, 0x72, 0x49,
2872        ];
2873        assert_eq!(
2874            aum.0,
2875            EXPECTED_AUM_HASH,
2876            "aum_hash over full serialization changed. actual: {}",
2877            hex(&aum.0)
2878        );
2879    }
2880
2881    // ----- ed25519-speccheck KAT: dual-verifier (dalek std vs zebra ZIP-215) -----
2882
2883    /// Decode an ASCII hex string to bytes. Panics on malformed input (test-only).
2884    fn unhex(s: &str) -> Vec<u8> {
2885        assert!(s.len().is_multiple_of(2), "odd hex length");
2886        let nib = |c: u8| -> u8 {
2887            match c {
2888                b'0'..=b'9' => c - b'0',
2889                b'a'..=b'f' => c - b'a' + 10,
2890                b'A'..=b'F' => c - b'A' + 10,
2891                _ => panic!("bad hex nibble"),
2892            }
2893        };
2894        let b = s.as_bytes();
2895        let mut out = Vec::with_capacity(s.len() / 2);
2896        let mut i = 0;
2897        while i < b.len() {
2898            out.push((nib(b[i]) << 4) | nib(b[i + 1]));
2899            i += 2;
2900        }
2901        out
2902    }
2903
2904    /// The 12 adversarial Ed25519 vectors from `novifinancial/ed25519-speccheck`.
2905    ///
2906    /// Provenance: `cases.json` at commit `65519336fda78a3d016e947df6d82848aca0c9da`
2907    /// (<https://github.com/novifinancial/ed25519-speccheck/blob/main/cases.json>), the canonical
2908    /// generated vectors backing the "Taming the many EdDSAs" paper (IACR 2020/1244, Table 6c).
2909    /// The hex below is copied byte-for-byte from that file; the `message` field is itself hex
2910    /// (the speccheck driver hex-decodes it before verifying), so we decode it the same way.
2911    ///
2912    /// Tuple layout: `(message_hex, pubkey_hex, signature_hex)`.
2913    const SPECCHECK_VECTORS: [(&str, &str, &str); 12] = [
2914        // 0: S = 0; both A and R small-order.
2915        (
2916            "8c93255d71dcab10e8f379c26200f3c7bd5f09d9bc3068d3ef4edeb4853022b6",
2917            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
2918            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a0000000000000000000000000000000000000000000000000000000000000000",
2919        ),
2920        // 1: 0 < S < L; small A only.
2921        (
2922            "9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79",
2923            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
2924            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43a5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
2925        ),
2926        // 2: 0 < S < L; small R only.
2927        (
2928            "aebf3f2601a0c8c5d39cc7d8911642f740b78168218da8471772b35f9d35b9ab",
2929            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
2930            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa8c4bd45aecaca5b24fb97bc10ac27ac8751a7dfe1baff8b953ec9f5833ca260e",
2931        ),
2932        // 3: A and R mixed-order; passes both (unless full-order checked).
2933        (
2934            "9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79",
2935            "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
2936            "9046a64750444938de19f227bb80485e92b83fdb4b6506c160484c016cc1852f87909e14428a7a1d62e9f22f3d3ad7802db02eb2e688b6c52fcd6648a98bd009",
2937        ),
2938        // 4: A and R mixed; passes cofactored, FAILS cofactorless — the cofactored discriminator.
2939        (
2940            "e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c",
2941            "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
2942            "160a1cb0dc9c0258cd0a7d23e94d8fa878bcb1925f2c64246b2dee1796bed5125ec6bc982a269b723e0668e540911a9a6a58921d6925e434ab10aa7940551a09",
2943        ),
2944        // 5: A mixed, R order L; "fails cofactored iff (8h) prereduced".
2945        (
2946            "e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c",
2947            "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
2948            "21122a84e0b5fca4052f5b1235c80a537878b38f3142356b2c2384ebad4668b7e40bc836dac0f71076f9abe3a53f9c03c1ceeeddb658d0030494ace586687405",
2949        ),
2950        // 6: S > L (out of bounds) — malleability vector; std verifier MUST reject.
2951        (
2952            "85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40",
2953            "442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623",
2954            "e96f66be976d82e60150baecff9906684aebb1ef181f67a7189ac78ea23b6c0e547f7690a0e2ddcd04d87dbc3490dc19b3b3052f7ff0538cb68afb369ba3a514",
2955        ),
2956        // 7: S >> L (no canonical serialization with null high bit) — std verifier MUST reject.
2957        (
2958            "85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40",
2959            "442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623",
2960            "8ce5b96c8f26d0ab6c47958c9e68b937104cd36e13c33566acd2fe8d38aa19427e71f98a473474f2f13f06f97c20d58cc3f54b8bd0d272f42b695dd7e89a8c22",
2961        ),
2962        // 8: 0 < S < L; non-canonical R, reduced for hash.
2963        (
2964            "9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41",
2965            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
2966            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff03be9678ac102edcd92b0210bb34d7428d12ffc5df5f37e359941266a4e35f0f",
2967        ),
2968        // 9: 0 < S < L; non-canonical R, NOT reduced for hash.
2969        (
2970            "9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41",
2971            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
2972            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffca8c5b64cd208982aa38d4936621a4775aa233aa0505711d8fdcfdaa943d4908",
2973        ),
2974        // 10: 0 < S < L; non-canonical A, reduced for hash.
2975        (
2976            "e96b7021eb39c1a163b6da4e3093dcd3f21387da4cc4572be588fafae23c155b",
2977            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
2978            "a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
2979        ),
2980        // 11: 0 < S < L; non-canonical A, NOT reduced for hash.
2981        (
2982            "39a591f5321bbe07fd5a23dc2f39d025d74526615746727ceefd6e82ae65c06f",
2983            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
2984            "a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
2985        ),
2986    ];
2987
2988    /// Known-answer test guarding the dual-verifier split that backs TKA consensus correctness.
2989    ///
2990    /// `verify_ed25519_std` wraps `ed25519-dalek 2.x` (standard RFC-8032-ish, cofactorless) and is
2991    /// used for SigRotation WRAPPING signatures. `verify_ed25519_zip215` wraps `ed25519-zebra 4.x`
2992    /// (ZIP-215 cofactored) and is used for Direct/Credential signatures to match Go
2993    /// `ed25519consensus`. If these two ever collapse to identical behavior, Go wire-compat for
2994    /// Tailnet-Lock silently breaks — this test proves they remain distinct on the adversarial set.
2995    ///
2996    /// The accept/reject matrix is asserted **as actually observed** from the pinned crate versions
2997    /// (`ed25519-dalek 2.2.0`, `ed25519-zebra 4.2.0`). These are newer than the versions tabulated
2998    /// in the "Taming the many EdDSAs" paper (Table 5: dalek 1.0.0-pre.4, zebra 2.1.1), so the
2999    /// non-canonical cases (8–11) may differ from the paper; we lock in current behavior as a
3000    /// regression guard. The SECURITY-CRITICAL invariants are NOT version-tunable: the standard
3001    /// verifier MUST reject the S >= L malleability vectors (6, 7), and the two verifiers MUST
3002    /// disagree on the cofactored discriminator (vector 4). Those are hard, separate assertions.
3003    #[test]
3004    fn ed25519_speccheck_dual_verifier_kat() {
3005        // Observed accept(true)/reject(false) matrix for the pinned crates, vectors 0..=11.
3006        // Anchored to the speccheck paper rows then corrected to what the crates actually do
3007        // (see the per-vector run below — any divergence makes the test fail loudly).
3008        //
3009        //                              0    1    2    3    4    5    6    7    8    9    10   11
3010        const STD_EXPECT: [bool; 12] = [
3011            true, true, true, true, false, false, false, false, false, false, false, true,
3012        ];
3013        const ZIP215_EXPECT: [bool; 12] = [
3014            true, true, true, true, true, true, false, false, false, true, true, true,
3015        ];
3016
3017        for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
3018            let msg = unhex(msg_hex);
3019            let pk = unhex(pk_hex);
3020            let sig = unhex(sig_hex);
3021            assert_eq!(pk.len(), 32, "vector {i}: pubkey not 32 bytes");
3022            assert_eq!(sig.len(), 64, "vector {i}: signature not 64 bytes");
3023
3024            let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
3025            let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
3026
3027            assert_eq!(
3028                std_ok, STD_EXPECT[i],
3029                "speccheck vector {i}: verify_ed25519_std accept={std_ok}, expected {}",
3030                STD_EXPECT[i]
3031            );
3032            assert_eq!(
3033                zip_ok, ZIP215_EXPECT[i],
3034                "speccheck vector {i}: verify_ed25519_zip215 accept={zip_ok}, expected {}",
3035                ZIP215_EXPECT[i]
3036            );
3037        }
3038
3039        // SECURITY-CRITICAL invariant (NOT version-tunable): the standard verifier must reject
3040        // signatures whose scalar S is out of range (S >= L). These are vectors 6 and 7 — the
3041        // EdDSA malleability guard. If either ACCEPTS, that is a real security finding.
3042        for &i in &[6usize, 7usize] {
3043            let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[i];
3044            let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
3045            assert!(
3046                verify_ed25519_std(&pk, &msg, &sig).is_err(),
3047                "SECURITY: verify_ed25519_std ACCEPTED S>=L malleability vector {i}"
3048            );
3049        }
3050
3051        // KEY DISCRIMINATOR (vector 4): cofactored (ZIP-215/zebra) accepts, cofactorless
3052        // (standard/dalek) rejects, on the SAME (pk, msg, sig). This proves the dual-verifier
3053        // split is real and not accidentally identical.
3054        {
3055            let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[4];
3056            let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
3057            assert!(
3058                verify_ed25519_zip215(&pk, &msg, &sig).is_ok(),
3059                "vector 4: ZIP-215 (zebra) should ACCEPT the cofactored discriminator"
3060            );
3061            assert!(
3062                verify_ed25519_std(&pk, &msg, &sig).is_err(),
3063                "vector 4: standard (dalek) should REJECT the cofactored discriminator"
3064            );
3065        }
3066    }
3067
3068    // ----- Cross-implementation KATs against real Go `tailscale.com/tka` v1.100.0 -----
3069
3070    /// Cross-implementation Known-Answer-Test: the CTAP2-CBOR serialization and BLAKE2s-256
3071    /// `SigHash` of three `NodeKeySignature` shapes must byte-match the REAL Go
3072    /// `tailscale.com/tka` package, version **v1.100.0** (toolchain **go1.26.4**).
3073    ///
3074    /// Provenance: the golden bytes below were produced by a Go generator that imports the real
3075    /// upstream `tailscale.com/tka` and calls `NodeKeySignature.Serialize()` (full CBOR including
3076    /// the signature field) and `NodeKeySignature.SigHash()` (BLAKE2s-256 of the CBOR with the
3077    /// `Signature` field nil'd). They are authoritative upstream output, NOT this fork's own
3078    /// encoder echoed back — this is the cross-validation the `node_key_signature_cbor_frozen_vector`
3079    /// freeze-test could not provide. The generator lives alongside the speccheck generator under
3080    /// `tests/vectors/gen` (Go module pinned to `tailscale.com v1.100.0`).
3081    ///
3082    /// Three shapes are covered: a `Direct` leaf, a `Credential` leaf (same fields, different
3083    /// `sigKind`), and a `Rotation` wrapping a nested `Direct` (the rotation-chain wire form). The
3084    /// int-map keys are 1=sigKind, 2=pubkey, 3=keyID, 4=signature, 5=nested, 6=wrappingPubkey;
3085    /// empty byte fields are omitted (`omitempty`).
3086    #[test]
3087    fn tka_cbor_matches_go_golden() {
3088        // Common fixed field material (real Go generator inputs).
3089        let pubkey32 = unhex("a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf");
3090        let key_id32 = unhex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f");
3091        let sig64 = unhex(
3092            "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
3093        );
3094        let wrap32 = unhex("101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f");
3095        let rot_sig64 = unhex(
3096            "55565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f9091929394",
3097        );
3098
3099        // GOLDEN 1 — Direct.
3100        {
3101            let sig = NodeKeySignature {
3102                sig_kind: SigKind::Direct,
3103                pubkey: pubkey32.clone(),
3104                key_id: key_id32.clone(),
3105                signature: sig64.clone(),
3106                nested: None,
3107                wrapping_pubkey: Vec::new(),
3108            };
3109            let full = sig.to_cbor(true).to_vec();
3110            let expected_full = unhex(
3111                "a40101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
3112            );
3113            assert_eq!(
3114                full,
3115                expected_full,
3116                "GOLDEN 1 (Direct) full CBOR diverged from Go tka v1.100.0. actual: {}",
3117                hex(&full)
3118            );
3119            let expected_hash =
3120                unhex("7e9653c97d35485b37b9bf942b1861cd2f3cb0663b5bb154f1178cca72101e74");
3121            assert_eq!(
3122                sig.sig_hash().as_slice(),
3123                expected_hash.as_slice(),
3124                "GOLDEN 1 (Direct) sig_hash diverged from Go tka v1.100.0. actual: {}",
3125                hex(&sig.sig_hash())
3126            );
3127        }
3128
3129        // GOLDEN 2 — Credential (same fields as Direct, sigKind=3).
3130        {
3131            let sig = NodeKeySignature {
3132                sig_kind: SigKind::Credential,
3133                pubkey: pubkey32.clone(),
3134                key_id: key_id32.clone(),
3135                signature: sig64.clone(),
3136                nested: None,
3137                wrapping_pubkey: Vec::new(),
3138            };
3139            let full = sig.to_cbor(true).to_vec();
3140            let expected_full = unhex(
3141                "a40103025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
3142            );
3143            assert_eq!(
3144                full,
3145                expected_full,
3146                "GOLDEN 2 (Credential) full CBOR diverged from Go tka v1.100.0. actual: {}",
3147                hex(&full)
3148            );
3149            let expected_hash =
3150                unhex("b6070ea8bc7ae8989ef4293f5031bedaa4a499803ade99f9e2f34dc2898ac03f");
3151            assert_eq!(
3152                sig.sig_hash().as_slice(),
3153                expected_hash.as_slice(),
3154                "GOLDEN 2 (Credential) sig_hash diverged from Go tka v1.100.0. actual: {}",
3155                hex(&sig.sig_hash())
3156            );
3157        }
3158
3159        // GOLDEN 3 — Rotation wrapping a nested Direct.
3160        //
3161        // Decoded from the authoritative Go bytes, the OUTER map is `a4` = 4 entries: keys
3162        // 1=sigKind(Rotation), 2=pubkey(wrap32), 4=signature(rotSig64), 5=nested. The outer has NO
3163        // key 6 (its `wrapping_pubkey` is EMPTY → omitted) and NO key 3 (its `key_id` is EMPTY →
3164        // omitted). The trailing `065820<wrap32>` in the hex belongs to the NESTED Direct map
3165        // (`a5` = 5 entries: keys 1,2,3,4,6), whose `wrapping_pubkey` IS set to wrap32. Constructing
3166        // the structs this way (outer wrapping_pubkey empty, nested wrapping_pubkey=wrap32)
3167        // reproduces the Go bytes exactly.
3168        {
3169            let nested = NodeKeySignature {
3170                sig_kind: SigKind::Direct,
3171                pubkey: pubkey32.clone(),
3172                key_id: key_id32.clone(),
3173                signature: sig64.clone(),
3174                nested: None,
3175                wrapping_pubkey: wrap32.clone(),
3176            };
3177            let sig = NodeKeySignature {
3178                sig_kind: SigKind::Rotation,
3179                pubkey: wrap32.clone(),
3180                key_id: Vec::new(),
3181                signature: rot_sig64.clone(),
3182                nested: Some(alloc::boxed::Box::new(nested)),
3183                wrapping_pubkey: Vec::new(),
3184            };
3185            let full = sig.to_cbor(true).to_vec();
3186            let expected_full = unhex(
3187                "a40102025820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f04584055565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939405a50101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf065820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f",
3188            );
3189            assert_eq!(
3190                full,
3191                expected_full,
3192                "GOLDEN 3 (Rotation) full CBOR diverged from Go tka v1.100.0. actual: {}",
3193                hex(&full)
3194            );
3195            let expected_hash =
3196                unhex("fac0a5a6781bb945369c28a0b3d3eea04e1648b60ec1a990a1ff68a9a566e6a7");
3197            assert_eq!(
3198                sig.sig_hash().as_slice(),
3199                expected_hash.as_slice(),
3200                "GOLDEN 3 (Rotation) sig_hash diverged from Go tka v1.100.0. actual: {}",
3201                hex(&sig.sig_hash())
3202            );
3203        }
3204    }
3205
3206    /// Cross-bind the dual Ed25519 verifier accept/reject matrix to the verdicts produced by the
3207    /// REAL Go implementations on the adversarial speccheck set (see [`SPECCHECK_VECTORS`]).
3208    ///
3209    /// Provenance of the Go verdicts: Go `crypto/ed25519.Verify` (standard, cofactorless) and
3210    /// `github.com/hdevalence/ed25519consensus v0.2.0` (ZIP-215, cofactored), toolchain
3211    /// **go1.26.4**, driven by the generator under `tests/vectors/gen/zip215`. These are the SAME
3212    /// verdicts the pinned Rust crates produce — proving `ed25519-dalek 2.x` == Go-std and
3213    /// `ed25519-zebra 4.x` == Go-`ed25519consensus` on the adversarial set. The arrays below MUST
3214    /// therefore equal `STD_EXPECT` / `ZIP215_EXPECT` asserted in
3215    /// `ed25519_speccheck_dual_verifier_kat`; this test additionally pins them to Go's behavior.
3216    ///
3217    /// NOTE: [`SPECCHECK_VECTORS`] is duplicated (byte-for-byte) in the Go generator at
3218    /// `tests/vectors/gen/zip215/main.go`. Both copies derive from the same upstream
3219    /// `cases.json` commit; if you edit one you MUST edit the other, or this proof would compare
3220    /// inputs the Go verdicts were never computed over.
3221    #[test]
3222    fn ed25519_dual_verifier_matches_go_verdicts() {
3223        //                                  0    1    2    3    4    5    6    7    8    9   10   11
3224        const GO_STD_ACCEPT: [bool; 12] = [
3225            true, true, true, true, false, false, false, false, false, false, false, true,
3226        ];
3227        const GO_ZIP215_ACCEPT: [bool; 12] = [
3228            true, true, true, true, true, true, false, false, false, true, true, true,
3229        ];
3230
3231        for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
3232            let msg = unhex(msg_hex);
3233            let pk = unhex(pk_hex);
3234            let sig = unhex(sig_hex);
3235
3236            let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
3237            let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
3238
3239            assert_eq!(
3240                std_ok, GO_STD_ACCEPT[i],
3241                "vector {i}: Rust verify_ed25519_std accept={std_ok} disagrees with Go \
3242                 crypto/ed25519.Verify={}",
3243                GO_STD_ACCEPT[i]
3244            );
3245            assert_eq!(
3246                zip_ok, GO_ZIP215_ACCEPT[i],
3247                "vector {i}: Rust verify_ed25519_zip215 accept={zip_ok} disagrees with Go \
3248                 ed25519consensus.Verify={}",
3249                GO_ZIP215_ACCEPT[i]
3250            );
3251        }
3252    }
3253
3254    /// Byte-exact cross-validation of [`Aum::serialize`] against the literal `[]byte` vectors in Go
3255    /// `tka/aum_test.go` `TestSerialization` (tailscale v1.100.0, fxamacker/cbor v2.9.2 CTAP2 mode).
3256    /// These are the authoritative oracle: if our CTAP2 CBOR diverges from Go by a single byte, the
3257    /// `AUM.Hash` chain links and every signature digest break. Each case reproduces the exact Go
3258    /// `AUM{…}` literal and asserts identical canonical bytes.
3259    #[test]
3260    fn aum_serialize_matches_go_test_serialization_vectors() {
3261        // AddKey: AUM{MessageKind: AUMAddKey, Key: &Key{}}. Go's *zero* Key{} has Kind=0
3262        // (KeyInvalid) and Public=nil, which our `AumKey` (always a valid KeyKind + Vec) cannot
3263        // model — that zero-Key encoding (`03 a3 01 00 02 00 03 f6`) is asserted directly at the
3264        // CBOR layer here, while the AUM keymap around it (map3, kind=AddKey, null prev, Key at
3265        // key 3) is covered by the structural assertions plus the three full vectors below.
3266        let add_key_inner_zero_key = cbor::Value::IntMap(alloc::vec![
3267            (1, cbor::Value::Uint(0)), // Kind = KeyInvalid(0)
3268            (2, cbor::Value::Uint(0)), // Votes = 0
3269            (3, cbor::Value::Null),    // Public = nil -> null
3270        ]);
3271        assert_eq!(
3272            add_key_inner_zero_key.to_vec(),
3273            alloc::vec![0xa3, 0x01, 0x00, 0x02, 0x00, 0x03, 0xf6],
3274            "Go's zero Key{{}} encodes as map(3){{kind=0, votes=0, public=null}}"
3275        );
3276
3277        // RemoveKey: AUM{MessageKind: AUMRemoveKey, KeyID: []byte{1, 2}}
3278        let remove_key = Aum {
3279            message_kind: AumKind::RemoveKey,
3280            prev_aum_hash: None,
3281            key: None,
3282            key_id: alloc::vec![1, 2],
3283            state: None,
3284            votes: None,
3285            meta: Vec::new(),
3286            signatures: Vec::new(),
3287        };
3288        assert_eq!(
3289            remove_key.serialize(),
3290            // a3 (map3) 01 02 (kind=RemoveKey) 02 f6 (prev=null) 04 42 01 02 (KeyID=bytes{1,2})
3291            alloc::vec![0xa3, 0x01, 0x02, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02],
3292            "RemoveKey AUM serialization must match Go TestSerialization byte-for-byte"
3293        );
3294
3295        // UpdateKey: AUM{MessageKind: AUMUpdateKey, Votes: &uint(2), KeyID: []byte{1,2},
3296        //                Meta: map[string]string{"a":"b"}}
3297        let update_key = Aum {
3298            message_kind: AumKind::UpdateKey,
3299            prev_aum_hash: None,
3300            key: None,
3301            key_id: alloc::vec![1, 2],
3302            state: None,
3303            votes: Some(2),
3304            meta: alloc::vec![("a".into(), "b".into())],
3305            signatures: Vec::new(),
3306        };
3307        assert_eq!(
3308            update_key.serialize(),
3309            // a5 (map5) 01 04 (UpdateKey) 02 f6 (prev null) 04 42 01 02 (KeyID) 06 02 (Votes=2)
3310            // 07 a1 61 61 61 62 (Meta = {"a":"b"})  — keys ascending 1,2,4,6,7
3311            alloc::vec![
3312                0xa5, 0x01, 0x04, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02, 0x06, 0x02, 0x07, 0xa1, 0x61,
3313                0x61, 0x61, 0x62
3314            ],
3315            "UpdateKey AUM serialization must match Go TestSerialization byte-for-byte"
3316        );
3317
3318        // Signature: AUM{MessageKind: AUMAddKey, Signatures: []tkatype.Signature{{KeyID: []byte{1}}}}
3319        let with_sig = Aum {
3320            message_kind: AumKind::AddKey,
3321            prev_aum_hash: None,
3322            key: None,
3323            key_id: Vec::new(),
3324            state: None,
3325            votes: None,
3326            meta: Vec::new(),
3327            signatures: alloc::vec![AumSignature {
3328                key_id: alloc::vec![1],
3329                signature: Vec::new(),
3330            }],
3331        };
3332        assert_eq!(
3333            with_sig.serialize(),
3334            // a3 (map3) 01 01 (AddKey) 02 f6 (prev null) 17 (key 23 = Signatures) 81 (array1)
3335            // a2 (map2) 01 41 01 (Signature.KeyID = bytes{1}) 02 f6 (Signature.Signature = null)
3336            alloc::vec![
3337                0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6
3338            ],
3339            "Signature AUM serialization must match Go TestSerialization (key 23 + nil sig = null)"
3340        );
3341
3342        // sig_hash must drop key 23 (Go SigHash nils Signatures → omitempty): the with_sig AUM's
3343        // sig_hash equals the BLAKE2s of the same AUM with no signatures.
3344        let no_sig = Aum {
3345            signatures: Vec::new(),
3346            ..with_sig.clone()
3347        };
3348        assert_eq!(
3349            with_sig.sig_hash(),
3350            blake2s_256(&no_sig.serialize()),
3351            "SigHash preimage must omit key 23 (Signatures), matching Go AUM.SigHash"
3352        );
3353        // And the full Hash differs from the SigHash (signatures are in the chain-link hash).
3354        assert_ne!(
3355            with_sig.hash().0,
3356            with_sig.sig_hash(),
3357            "Hash (incl. signatures) must differ from SigHash (excl.) when signatures are present"
3358        );
3359    }
3360
3361    /// Checkpoint AUM with an embedded `State`: exercises [`AumState`]/[`AumKey`] CBOR (the 32-byte
3362    /// `LastAUMHash` as a definite-length byte string, the `DisablementValues`/`Keys` arrays, and the
3363    /// `Key.Public` at key 3). Mirrors the structure of Go's `TestSerialization` Checkpoint case.
3364    #[test]
3365    fn aum_checkpoint_state_serialization() {
3366        let checkpoint = Aum {
3367            message_kind: AumKind::Checkpoint,
3368            prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
3369            key: None,
3370            key_id: Vec::new(),
3371            state: Some(AumState {
3372                last_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
3373                disablement_values: Some(Vec::new()),
3374                keys: Some(alloc::vec![AumKey {
3375                    kind: KeyKind::Ed25519,
3376                    votes: 1,
3377                    public: alloc::vec![5, 6],
3378                    meta: Vec::new(),
3379                }]),
3380                state_id1: 0,
3381                state_id2: 0,
3382            }),
3383            votes: None,
3384            meta: Vec::new(),
3385            signatures: Vec::new(),
3386        };
3387        let bytes = checkpoint.serialize();
3388        // Spot-check the structurally-load-bearing pieces (full-vector parity is covered by the
3389        // three exact vectors above; here we pin the State/Key encoding shape):
3390        // map3: key1=Checkpoint(5), key2=prev(32-byte bytestring 0x58 0x20 …), key5=State.
3391        assert_eq!(
3392            &bytes[0..3],
3393            &[0xa3, 0x01, 0x05],
3394            "map(3), MessageKind=Checkpoint(5)"
3395        );
3396        assert_eq!(
3397            &bytes[3..6],
3398            &[0x02, 0x58, 0x20],
3399            "key2 prev = 32-byte byte string head"
3400        );
3401        // The embedded State map (key 5) must contain: LastAUMHash (1) as 32-byte bytes, an empty
3402        // DisablementValues array (2 → 0x80), and a Keys array (3 → 0x81 with one Key map).
3403        // Locate the State map head (key 5) after the 32-byte prev hash: 3 + 3 + 32 = offset 38.
3404        assert_eq!(bytes[38], 0x05, "key 5 = State");
3405        // State is a map; its first entry is key1 (LastAUMHash) = 32-byte byte string.
3406        assert_eq!(
3407            &bytes[39..42],
3408            &[0xa3, 0x01, 0x58],
3409            "State map(3), key1 LastAUMHash bytes"
3410        );
3411        // The Key inside Keys carries Public={5,6} at key 3 (…03 42 05 06) and Votes=1 at key 2.
3412        let tail = &bytes[bytes.len() - 4..];
3413        assert_eq!(
3414            tail,
3415            &[0x03, 0x42, 0x05, 0x06],
3416            "Key.Public (key 3) = bytes{{5,6}}"
3417        );
3418        // Round-trips deterministically (hash is stable).
3419        assert_eq!(checkpoint.hash(), checkpoint.hash());
3420    }
3421
3422    // ---- AUM-chain replay (chunk 1B) -----------------------------------------------------------
3423
3424    /// A test trusted key from a seed byte (deterministic public key + given votes).
3425    fn test_aum_key(seed: u8, votes: u32) -> AumKey {
3426        use ed25519_dalek::SigningKey;
3427        let pubk = SigningKey::from_bytes(&[seed; 32])
3428            .verifying_key()
3429            .to_bytes()
3430            .to_vec();
3431        AumKey {
3432            kind: KeyKind::Ed25519,
3433            votes,
3434            public: pubk,
3435            meta: Vec::new(),
3436        }
3437    }
3438
3439    /// A genesis `AUMAddKey` (no parent) adding `key`.
3440    fn genesis_add(key: AumKey) -> Aum {
3441        Aum {
3442            message_kind: AumKind::AddKey,
3443            prev_aum_hash: None,
3444            key: Some(key),
3445            key_id: Vec::new(),
3446            state: None,
3447            votes: None,
3448            meta: Vec::new(),
3449            signatures: Vec::new(),
3450        }
3451    }
3452
3453    /// A child AUM of `parent` of the given kind, optionally carrying a key / key_id.
3454    fn child(parent: &Aum, kind: AumKind, key: Option<AumKey>, key_id: Vec<u8>) -> Aum {
3455        Aum {
3456            message_kind: kind,
3457            prev_aum_hash: Some(parent.hash()),
3458            key,
3459            key_id,
3460            state: None,
3461            votes: None,
3462            meta: Vec::new(),
3463            signatures: Vec::new(),
3464        }
3465    }
3466
3467    /// Linear replay applies each kind: genesis AddKey(k0), AddKey(k1), UpdateKey(k1 votes), then
3468    /// RemoveKey(k0). The final state has only k1 with its updated votes, and head = last AUM hash.
3469    #[test]
3470    fn replay_linear_chain_folds_all_kinds() {
3471        let k0 = test_aum_key(1, 1);
3472        let k1 = test_aum_key(2, 1);
3473
3474        let a0 = genesis_add(k0.clone());
3475        let a1 = child(&a0, AumKind::AddKey, Some(k1.clone()), Vec::new());
3476        let mut a2 = child(&a1, AumKind::UpdateKey, None, k1.public.clone());
3477        a2.votes = Some(5);
3478        let a3 = child(&a2, AumKind::RemoveKey, None, k0.public.clone());
3479
3480        let auth = Authority::from_chain(&[a0, a1, a2, a3.clone()]).unwrap();
3481
3482        // Only k1 remains, with the updated vote weight.
3483        assert_eq!(auth.state().keys.len(), 1, "k0 removed, k1 remains");
3484        let remaining = &auth.state().keys[0];
3485        assert_eq!(remaining.public, k1.public, "k1 is the surviving key");
3486        assert_eq!(remaining.votes, 5, "UpdateKey raised k1's votes to 5");
3487        // Head is the hash of the last applied AUM.
3488        assert_eq!(auth.head(), a3.hash(), "head = last AUM hash");
3489    }
3490
3491    /// A broken chain link (wrong `prev_aum_hash`) is rejected with `BadParent`.
3492    #[test]
3493    fn replay_rejects_broken_parent_link() {
3494        let k0 = test_aum_key(1, 1);
3495        let k1 = test_aum_key(2, 1);
3496        let a0 = genesis_add(k0);
3497        // a1 claims a bogus parent, not a0's hash.
3498        let mut a1 = child(&a0, AumKind::AddKey, Some(k1), Vec::new());
3499        a1.prev_aum_hash = Some(AumHash([0xab; 32]));
3500        assert_eq!(
3501            Authority::from_chain(&[a0, a1]).unwrap_err(),
3502            TkaError::BadParent
3503        );
3504    }
3505
3506    /// AddKey of an already-trusted key, and Remove/Update of an absent key, are rejected.
3507    #[test]
3508    fn replay_rejects_bad_key_state() {
3509        let k0 = test_aum_key(1, 1);
3510        let a0 = genesis_add(k0.clone());
3511        // Duplicate add of k0.
3512        let dup = child(&a0, AumKind::AddKey, Some(k0.clone()), Vec::new());
3513        assert_eq!(
3514            Authority::from_chain(&[a0.clone(), dup]).unwrap_err(),
3515            TkaError::BadKeyState
3516        );
3517        // Remove of a key that was never added.
3518        let absent = test_aum_key(9, 1);
3519        let rm = child(&a0, AumKind::RemoveKey, None, absent.public.clone());
3520        assert_eq!(
3521            Authority::from_chain(&[a0, rm]).unwrap_err(),
3522            TkaError::BadKeyState
3523        );
3524    }
3525
3526    /// An empty chain is rejected.
3527    #[test]
3528    fn replay_empty_chain_is_bad_chain() {
3529        assert_eq!(Authority::from_chain(&[]).unwrap_err(), TkaError::BadChain);
3530    }
3531
3532    /// `weight` sums the votes of distinct trusted signing keys: an unknown signer contributes 0, and
3533    /// a key that signs twice counts once (Go `TestAUMWeight` "Double use" → its votes, not double).
3534    #[test]
3535    fn replay_weight_dedups_and_ignores_unknown() {
3536        let k0 = test_aum_key(1, 2);
3537        let k1 = test_aum_key(2, 3);
3538        let state = ReplayState {
3539            keys: alloc::vec![k0.clone(), k1.clone()],
3540            last_aum_hash: None,
3541            state_id: None,
3542        };
3543
3544        // Empty signatures → 0.
3545        let mut aum = genesis_add(test_aum_key(5, 1));
3546        assert_eq!(state.weight(&aum), 0);
3547
3548        // One known signer (k0, votes 2).
3549        aum.signatures = alloc::vec![AumSignature {
3550            key_id: k0.public.clone(),
3551            signature: Vec::new()
3552        }];
3553        assert_eq!(state.weight(&aum), 2);
3554
3555        // Two distinct known signers → 2 + 3 = 5.
3556        aum.signatures = alloc::vec![
3557            AumSignature {
3558                key_id: k0.public.clone(),
3559                signature: Vec::new()
3560            },
3561            AumSignature {
3562                key_id: k1.public.clone(),
3563                signature: Vec::new()
3564            },
3565        ];
3566        assert_eq!(state.weight(&aum), 5);
3567
3568        // Double-use of k0 → counted once (2), not 4.
3569        aum.signatures = alloc::vec![
3570            AumSignature {
3571                key_id: k0.public.clone(),
3572                signature: Vec::new()
3573            },
3574            AumSignature {
3575                key_id: k0.public.clone(),
3576                signature: Vec::new()
3577            },
3578        ];
3579        assert_eq!(state.weight(&aum), 2, "a key signing twice counts once");
3580
3581        // Unknown signer → 0.
3582        aum.signatures = alloc::vec![AumSignature {
3583            key_id: alloc::vec![0xff; 32],
3584            signature: Vec::new()
3585        }];
3586        assert_eq!(
3587            state.weight(&aum),
3588            0,
3589            "an untrusted signing key contributes no weight"
3590        );
3591    }
3592
3593    /// `pick_next_aum` rule 3 (the deterministic tiebreak): with equal weight (0, no signatures) and
3594    /// neither a RemoveKey, the candidate with the lexicographically-lowest `Hash()` wins —
3595    /// regardless of input order, so two nodes select the same branch.
3596    #[test]
3597    fn pick_next_aum_lowest_hash_tiebreak_is_order_independent() {
3598        let k = test_aum_key(1, 1);
3599        let a0 = genesis_add(k);
3600        // Two distinct NoOp children of a0 (differ by key_id so their hashes differ).
3601        let c1 = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
3602        let c2 = child(&a0, AumKind::NoOp, None, alloc::vec![2]);
3603        let state = ReplayState::default();
3604
3605        let lower = if c1.hash().0 < c2.hash().0 {
3606            c1.hash()
3607        } else {
3608            c2.hash()
3609        };
3610        let ab = [c1.clone(), c2.clone()];
3611        let ba = [c2, c1];
3612        let pick_ab = pick_next_aum(&state, &ab).hash();
3613        let pick_ba = pick_next_aum(&state, &ba).hash();
3614        assert_eq!(pick_ab, lower, "lowest hash wins");
3615        assert_eq!(
3616            pick_ab, pick_ba,
3617            "selection is independent of candidate order"
3618        );
3619    }
3620
3621    /// `pick_next_aum` rule 1 (weight) dominates rule 3 (hash): a signed child with real weight beats
3622    /// an unsigned child even if the unsigned one has a lower hash.
3623    #[test]
3624    fn pick_next_aum_weight_beats_hash() {
3625        use ed25519_dalek::SigningKey;
3626        let signer_seed = 3u8;
3627        let signer_pub = SigningKey::from_bytes(&[signer_seed; 32])
3628            .verifying_key()
3629            .to_bytes()
3630            .to_vec();
3631        let state = ReplayState {
3632            keys: alloc::vec![AumKey {
3633                kind: KeyKind::Ed25519,
3634                votes: 4,
3635                public: signer_pub.clone(),
3636                meta: Vec::new(),
3637            }],
3638            last_aum_hash: None,
3639            state_id: None,
3640        };
3641
3642        let a0 = genesis_add(test_aum_key(1, 1));
3643        let unsigned = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
3644        let mut signed = child(&a0, AumKind::NoOp, None, alloc::vec![2]);
3645        signed.signatures = alloc::vec![AumSignature {
3646            key_id: signer_pub,
3647            signature: Vec::new(),
3648        }];
3649
3650        // The signed child wins on weight (4 > 0) no matter the hash order.
3651        let candidates = [unsigned.clone(), signed.clone()];
3652        let winner = pick_next_aum(&state, &candidates);
3653        assert_eq!(
3654            winner.hash(),
3655            signed.hash(),
3656            "higher weight wins over lower hash"
3657        );
3658    }
3659
3660    /// `from_forked_chain`: a shared genesis, then two competing RemoveKey vs NoOp branches at equal
3661    /// weight — rule 2 prefers the RemoveKey branch. The resulting state reflects the chosen branch.
3662    #[test]
3663    fn forked_chain_prefers_removekey_branch() {
3664        let k0 = test_aum_key(1, 1);
3665        let k1 = test_aum_key(2, 1);
3666        // Genesis adds both keys (two AUMs).
3667        let a0 = genesis_add(k0.clone());
3668        let a1 = child(&a0, AumKind::AddKey, Some(k1.clone()), Vec::new());
3669        // Fork at a1: branch A removes k0; branch B is a NoOp. Equal weight (0 sigs).
3670        let branch_remove = child(&a1, AumKind::RemoveKey, None, k0.public.clone());
3671        let branch_noop = child(&a1, AumKind::NoOp, None, alloc::vec![9]);
3672
3673        let noop_branch = [branch_noop.clone()];
3674        let remove_branch = [branch_remove.clone()];
3675        let auth = Authority::from_forked_chain(&[a0, a1], &[&noop_branch[..], &remove_branch[..]])
3676            .unwrap();
3677
3678        // RemoveKey branch wins → k0 gone, only k1 remains; head = the RemoveKey AUM.
3679        assert_eq!(auth.state().keys.len(), 1);
3680        assert_eq!(auth.state().keys[0].public, k1.public);
3681        assert_eq!(
3682            auth.head(),
3683            branch_remove.hash(),
3684            "active head = RemoveKey branch"
3685        );
3686    }
3687
3688    /// End-to-end: replay a chain to an `Authority`, then verify it authorizes a node key signed by a
3689    /// trusted key — proving the replayed state drives `node_key_authorized` identically to
3690    /// `from_state`. A key removed by the chain no longer authorizes.
3691    #[test]
3692    fn replayed_authority_authorizes_node_end_to_end() {
3693        use ed25519_dalek::{Signer, SigningKey};
3694
3695        let signing = SigningKey::from_bytes(&[77u8; 32]);
3696        let trusted_pub = signing.verifying_key().to_bytes().to_vec();
3697        let trusted = AumKey {
3698            kind: KeyKind::Ed25519,
3699            votes: 1,
3700            public: trusted_pub.clone(),
3701            meta: Vec::new(),
3702        };
3703        // A second key we'll add then remove, to show a removed key can't authorize.
3704        let revoked_signing = SigningKey::from_bytes(&[88u8; 32]);
3705        let revoked_pub = revoked_signing.verifying_key().to_bytes().to_vec();
3706        let revoked = AumKey {
3707            kind: KeyKind::Ed25519,
3708            votes: 1,
3709            public: revoked_pub.clone(),
3710            meta: Vec::new(),
3711        };
3712
3713        let a0 = genesis_add(trusted);
3714        let a1 = child(&a0, AumKind::AddKey, Some(revoked), Vec::new());
3715        let a2 = child(&a1, AumKind::RemoveKey, None, revoked_pub.clone());
3716        let auth = Authority::from_chain(&[a0, a1, a2]).unwrap();
3717
3718        let node_key = alloc::vec![7u8; 32];
3719        // Signature from the still-trusted key authorizes.
3720        let mut sig = NodeKeySignature {
3721            sig_kind: SigKind::Direct,
3722            pubkey: node_key.clone(),
3723            key_id: trusted_pub.clone(),
3724            signature: Vec::new(),
3725            nested: None,
3726            wrapping_pubkey: Vec::new(),
3727        };
3728        sig.signature = signing.sign(&sig.sig_hash()).to_bytes().to_vec();
3729        assert!(
3730            auth.node_key_authorized(&node_key, &sig.to_cbor(true).to_vec())
3731                .is_ok(),
3732            "the replayed authority must authorize a node signed by a still-trusted key"
3733        );
3734
3735        // The same node key signed by the REVOKED key must be rejected (key no longer in state).
3736        let mut bad = NodeKeySignature {
3737            sig_kind: SigKind::Direct,
3738            pubkey: node_key.clone(),
3739            key_id: revoked_pub.clone(),
3740            signature: Vec::new(),
3741            nested: None,
3742            wrapping_pubkey: Vec::new(),
3743        };
3744        bad.signature = revoked_signing.sign(&bad.sig_hash()).to_bytes().to_vec();
3745        assert_eq!(
3746            auth.node_key_authorized(&node_key, &bad.to_cbor(true).to_vec())
3747                .unwrap_err(),
3748            TkaError::UntrustedKey,
3749            "a key the chain removed must not authorize"
3750        );
3751    }
3752
3753    /// Genesis-kind guard (Go `computeStateAt` "invalid genesis update"): a chain whose first AUM is
3754    /// a `RemoveKey`/`UpdateKey` is rejected. (A genesis `NoOp`/`AddKey`/`Checkpoint` is allowed.)
3755    #[test]
3756    fn replay_rejects_invalid_genesis_kind() {
3757        // A bare RemoveKey as genesis: no key to remove → today this is BadKeyState, but the genesis
3758        // guard catches an UpdateKey before the key lookup. Use UpdateKey to exercise the guard arm.
3759        let mut g = genesis_add(test_aum_key(1, 1));
3760        g.message_kind = AumKind::UpdateKey;
3761        g.key = None;
3762        g.key_id = test_aum_key(1, 1).public.clone();
3763        assert_eq!(
3764            Authority::from_chain(&[g]).unwrap_err(),
3765            TkaError::BadChain,
3766            "an UpdateKey cannot be a genesis AUM"
3767        );
3768    }
3769
3770    /// Genesis must carry no parent: a first AUM with a non-None `prev_aum_hash` (i.e. a chain
3771    /// *suffix* mis-supplied as a whole chain) is rejected as `BadParent`, not silently re-rooted.
3772    #[test]
3773    fn replay_rejects_genesis_with_parent() {
3774        let mut g = genesis_add(test_aum_key(1, 1));
3775        g.prev_aum_hash = Some(AumHash([0x11; 32])); // names a parent not in the slice
3776        assert_eq!(
3777            Authority::from_chain(&[g]).unwrap_err(),
3778            TkaError::BadParent,
3779            "a genesis AUM that names a parent must be rejected (not treated as genesis)"
3780        );
3781    }
3782
3783    /// Checkpoint StateID guard (Go "checkpointed state has an incorrect stateID"): a genesis
3784    /// checkpoint seeds the StateID; a later checkpoint with a different StateID is rejected.
3785    #[test]
3786    fn replay_rejects_checkpoint_stateid_mismatch() {
3787        let k = test_aum_key(1, 1);
3788        // Genesis checkpoint seeds StateID (7, 0).
3789        let genesis = Aum {
3790            message_kind: AumKind::Checkpoint,
3791            prev_aum_hash: None,
3792            key: None,
3793            key_id: Vec::new(),
3794            state: Some(AumState {
3795                last_aum_hash: None,
3796                disablement_values: Some(Vec::new()),
3797                keys: Some(alloc::vec![k.clone()]),
3798                state_id1: 7,
3799                state_id2: 0,
3800            }),
3801            votes: None,
3802            meta: Vec::new(),
3803            signatures: Vec::new(),
3804        };
3805        // A second checkpoint, correctly chained, but with a FOREIGN StateID (8, 0).
3806        let bad = Aum {
3807            message_kind: AumKind::Checkpoint,
3808            prev_aum_hash: Some(genesis.hash()),
3809            key: None,
3810            key_id: Vec::new(),
3811            state: Some(AumState {
3812                last_aum_hash: Some(genesis.hash()),
3813                disablement_values: Some(Vec::new()),
3814                keys: Some(alloc::vec![k.clone()]),
3815                state_id1: 8, // ← mismatch
3816                state_id2: 0,
3817            }),
3818            votes: None,
3819            meta: Vec::new(),
3820            signatures: Vec::new(),
3821        };
3822        assert_eq!(
3823            Authority::from_chain(&[genesis.clone(), bad]).unwrap_err(),
3824            TkaError::BadKeyState,
3825            "a checkpoint with a foreign StateID belongs to another authority and must be rejected"
3826        );
3827        // A matching-StateID second checkpoint is accepted.
3828        let ok = Aum {
3829            message_kind: AumKind::Checkpoint,
3830            prev_aum_hash: Some(genesis.hash()),
3831            key: None,
3832            key_id: Vec::new(),
3833            state: Some(AumState {
3834                last_aum_hash: Some(genesis.hash()),
3835                disablement_values: Some(Vec::new()),
3836                keys: Some(alloc::vec![k]),
3837                state_id1: 7,
3838                state_id2: 0,
3839            }),
3840            votes: None,
3841            meta: Vec::new(),
3842            signatures: Vec::new(),
3843        };
3844        assert!(Authority::from_chain(&[genesis, ok]).is_ok());
3845    }
3846
3847    /// `from_forked_chain` rejects a multi-step branch rather than mis-resolving it (Go re-runs
3848    /// pickNextAUM per link; judging a whole branch by its first AUM could diverge).
3849    #[test]
3850    fn forked_chain_rejects_multistep_branch() {
3851        let k0 = test_aum_key(1, 1);
3852        let a0 = genesis_add(k0.clone());
3853        let b1 = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
3854        // A two-AUM branch (b1 → b2): must be rejected as BadChain.
3855        let b2 = child(&b1, AumKind::NoOp, None, alloc::vec![2]);
3856        let single = [child(&a0, AumKind::NoOp, None, alloc::vec![3])];
3857        let multi = [b1, b2];
3858        assert_eq!(
3859            Authority::from_forked_chain(&[a0], &[&single[..], &multi[..]]).unwrap_err(),
3860            TkaError::BadChain,
3861            "a multi-step branch must be rejected, not judged by its first AUM"
3862        );
3863    }
3864
3865    /// Cross-implementation Known-Answer-Test for the **AUM** type: [`Aum::serialize`],
3866    /// [`Aum::hash`] (Go `AUM.Hash`), and [`Aum::sig_hash`] (Go `AUM.SigHash`) must byte-match the
3867    /// REAL `tailscale.com/tka` package, version **v1.100.0** (toolchain **go1.26.3+**).
3868    ///
3869    /// Provenance: every golden below is authoritative upstream output produced by the Go generator
3870    /// at `tests/vectors/gen/tka/main.go` (which imports the real `tailscale.com/tka`, builds one
3871    /// `tka.AUM` per `MessageKind`, and dumps `AUM.Serialize()`/`AUM.Hash()`/`AUM.SigHash()` hex).
3872    /// The same values are committed for provenance at `tests/vectors/tka_aum_hash_golden.json`.
3873    /// This is the missing half of axis-B for AUM: the sibling
3874    /// [`aum_serialize_matches_go_test_serialization_vectors`] test pins Go's *Serialize()* literals
3875    /// from `tka/aum_test.go`, but no Go-produced *AUM.Hash()* digest was pinned until now — so an
3876    /// error in the BLAKE2s-over-canonical-CBOR digest (the value that links the whole chain and is
3877    /// signed) would have gone undetected. Here `hash`/`sig_hash` are pinned to Go directly.
3878    ///
3879    /// Covered kinds: AddKey (genesis, with a real Key25519 + meta), RemoveKey, UpdateKey
3880    /// (votes+meta), a signed AddKey (Signatures at CBOR key 23), and a Checkpoint with a populated
3881    /// `State`. The signed AUM additionally proves `hash() != sig_hash()` — i.e. `Hash()` covers the
3882    /// signatures and `SigHash()` excludes them, exactly as Go's `AUM.SigHash` nils `Signatures`
3883    /// before serializing.
3884    #[test]
3885    fn aum_hash_sighash_matches_go_golden() {
3886        // Deterministic field material — identical to the Go generator's inputs.
3887        let prev = AumHash({
3888            let mut a = [0u8; AUM_HASH_LEN];
3889            let mut i = 0;
3890            while i < AUM_HASH_LEN {
3891                a[i] = 0x20u8.wrapping_add(i as u8);
3892                i += 1;
3893            }
3894            a
3895        });
3896        let key_pub: Vec<u8> = (0..32u16).map(|i| 0x40u8.wrapping_add(i as u8)).collect();
3897        let key_pub2: Vec<u8> = (0..32u16).map(|i| 0x60u8.wrapping_add(i as u8)).collect();
3898        let sig_bytes: Vec<u8> = (0..64u16).map(|i| 0x80u8.wrapping_add(i as u8)).collect();
3899
3900        // Assert one AUM's serialize/hash/sig_hash against the authoritative Go hex.
3901        let check = |label: &str, aum: &Aum, ser_hex: &str, hash_hex: &str, sig_hash_hex: &str| {
3902            assert_eq!(
3903                hex(&aum.serialize()),
3904                ser_hex,
3905                "{label}: Aum::serialize diverged from Go tka v1.100.0"
3906            );
3907            assert_eq!(
3908                hex(&aum.hash().0),
3909                hash_hex,
3910                "{label}: Aum::hash (Go AUM.Hash) diverged from Go tka v1.100.0"
3911            );
3912            assert_eq!(
3913                hex(&aum.sig_hash()),
3914                sig_hash_hex,
3915                "{label}: Aum::sig_hash (Go AUM.SigHash) diverged from Go tka v1.100.0"
3916            );
3917        };
3918
3919        // (a) AddKey genesis (nil prev) with a real Key25519 + meta {"name":"alpha"}.
3920        let add_key = Aum {
3921            message_kind: AumKind::AddKey,
3922            prev_aum_hash: None,
3923            key: Some(AumKey {
3924                kind: KeyKind::Ed25519,
3925                votes: 7,
3926                public: key_pub.clone(),
3927                meta: alloc::vec![("name".into(), "alpha".into())],
3928            }),
3929            key_id: Vec::new(),
3930            state: None,
3931            votes: None,
3932            meta: Vec::new(),
3933            signatures: Vec::new(),
3934        };
3935        check(
3936            "AddKey",
3937            &add_key,
3938            "a3010102f603a401010207035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f0ca1646e616d6565616c706861",
3939            "921ca301077ae2b892ca8c40b3315e5f2a9ccc9ac99eec784e93b323577e1e14",
3940            "921ca301077ae2b892ca8c40b3315e5f2a9ccc9ac99eec784e93b323577e1e14",
3941        );
3942
3943        // (b) RemoveKey with a non-nil prev.
3944        let remove_key = Aum {
3945            message_kind: AumKind::RemoveKey,
3946            prev_aum_hash: Some(prev),
3947            key: None,
3948            key_id: key_pub.clone(),
3949            state: None,
3950            votes: None,
3951            meta: Vec::new(),
3952            signatures: Vec::new(),
3953        };
3954        check(
3955            "RemoveKey",
3956            &remove_key,
3957            "a30102025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f045820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
3958            "46be17c398760d2e649147b06a68e8576ee37c59cf0558046182f48ba20aa912",
3959            "46be17c398760d2e649147b06a68e8576ee37c59cf0558046182f48ba20aa912",
3960        );
3961
3962        // (c) UpdateKey with votes=2 + meta {"role":"ci"}.
3963        let update_key = Aum {
3964            message_kind: AumKind::UpdateKey,
3965            prev_aum_hash: Some(prev),
3966            key: None,
3967            key_id: key_pub.clone(),
3968            state: None,
3969            votes: Some(2),
3970            meta: alloc::vec![("role".into(), "ci".into())],
3971            signatures: Vec::new(),
3972        };
3973        check(
3974            "UpdateKey",
3975            &update_key,
3976            "a50104025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f045820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f060207a164726f6c65626369",
3977            "42d160d81a0922511b4ce60050dba76569f79423b6e721c5040faf414c250e43",
3978            "42d160d81a0922511b4ce60050dba76569f79423b6e721c5040faf414c250e43",
3979        );
3980
3981        // (e) AddKey carrying one Signature (CBOR key 23). hash (incl sigs) MUST differ from
3982        // sig_hash (excl sigs) — the property the whole signing scheme depends on.
3983        let signed = Aum {
3984            message_kind: AumKind::AddKey,
3985            prev_aum_hash: Some(prev),
3986            key: Some(AumKey {
3987                kind: KeyKind::Ed25519,
3988                votes: 1,
3989                public: key_pub.clone(),
3990                meta: Vec::new(),
3991            }),
3992            key_id: Vec::new(),
3993            state: None,
3994            votes: None,
3995            meta: Vec::new(),
3996            signatures: alloc::vec![AumSignature {
3997                key_id: key_pub2.clone(),
3998                signature: sig_bytes.clone(),
3999            }],
4000        };
4001        check(
4002            "AddKey+Signature",
4003            &signed,
4004            "a40101025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f03a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f1781a2015820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f025840808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
4005            "e70332d9a03b205577204f1896bb8dcb7c8f8894cc87a5b5c4d5dabcdf6ef135",
4006            "0a7a0ecdf854ad99e8728a1de89ac23c1f08457132a537a3add9594749a7f536",
4007        );
4008        assert_ne!(
4009            hex(&signed.hash().0),
4010            hex(&signed.sig_hash()),
4011            "Hash() must cover Signatures while SigHash() excludes them (Go AUM.SigHash nils them)"
4012        );
4013
4014        // (f) Checkpoint carrying a State with a POPULATED DisablementValues [{0xaa,0xbb}] + 1 key.
4015        // This is the common real shape and matches Go byte-for-byte (the array encoding is correct).
4016        let checkpoint = Aum {
4017            message_kind: AumKind::Checkpoint,
4018            prev_aum_hash: Some(prev),
4019            key: None,
4020            key_id: Vec::new(),
4021            state: Some(AumState {
4022                last_aum_hash: Some(prev),
4023                disablement_values: Some(alloc::vec![alloc::vec![0xaa, 0xbb]]),
4024                keys: Some(alloc::vec![AumKey {
4025                    kind: KeyKind::Ed25519,
4026                    votes: 1,
4027                    public: key_pub.clone(),
4028                    meta: Vec::new(),
4029                }]),
4030                state_id1: 0,
4031                state_id2: 0,
4032            }),
4033            votes: None,
4034            meta: Vec::new(),
4035            signatures: Vec::new(),
4036        };
4037        check(
4038            "Checkpoint(populated DisablementValues)",
4039            &checkpoint,
4040            "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f028142aabb0381a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
4041            "38c35c51580dcb75212d79c07695c8ec8b399b59ba552ea93f080b126fdaa0ae",
4042            "38c35c51580dcb75212d79c07695c8ec8b399b59ba552ea93f080b126fdaa0ae",
4043        );
4044    }
4045
4046    /// Go-match golden for the nil-`DisablementValues` checkpoint — the case that was a recorded
4047    /// interop bug (Rust forced an empty array `0x80` where Go emits CBOR null `0xf6`) and is now
4048    /// FIXED by making `AumState.{disablement_values,keys}` `Option` (None = Go nil = `0xf6`).
4049    ///
4050    /// When an `AUMCheckpoint`'s embedded `State` has a **nil** `DisablementValues` (Go's zero value,
4051    /// the overwhelmingly common case), Go's `fxamacker/cbor` CTAP2 encoder emits the field as
4052    /// **CBOR null `0xf6`**; a populated slice encodes as an array (proven by the populated case in
4053    /// [`aum_hash_sighash_matches_go_golden`]). This test pins the Go bytes + Hash for the nil case
4054    /// and asserts the Rust output now byte-matches — guarding the fix against regression.
4055    #[test]
4056    fn aum_checkpoint_nil_disablement_matches_go() {
4057        let prev = AumHash({
4058            let mut a = [0u8; AUM_HASH_LEN];
4059            let mut i = 0;
4060            while i < AUM_HASH_LEN {
4061                a[i] = 0x20u8.wrapping_add(i as u8);
4062                i += 1;
4063            }
4064            a
4065        });
4066        let key_pub: Vec<u8> = (0..32u16).map(|i| 0x40u8.wrapping_add(i as u8)).collect();
4067        let key_pub2: Vec<u8> = (0..32u16).map(|i| 0x60u8.wrapping_add(i as u8)).collect();
4068
4069        // Checkpoint with a State whose DisablementValues is EMPTY (== Go nil zero value), 2 keys.
4070        let checkpoint = Aum {
4071            message_kind: AumKind::Checkpoint,
4072            prev_aum_hash: Some(prev),
4073            key: None,
4074            key_id: Vec::new(),
4075            state: Some(AumState {
4076                last_aum_hash: Some(prev),
4077                disablement_values: Some(Vec::new()),
4078                keys: Some(alloc::vec![
4079                    AumKey {
4080                        kind: KeyKind::Ed25519,
4081                        votes: 1,
4082                        public: key_pub.clone(),
4083                        meta: Vec::new(),
4084                    },
4085                    AumKey {
4086                        kind: KeyKind::Ed25519,
4087                        votes: 3,
4088                        public: key_pub2.clone(),
4089                        meta: alloc::vec![("k".into(), "v".into())],
4090                    },
4091                ]),
4092                state_id1: 0,
4093                state_id2: 0,
4094            }),
4095            votes: None,
4096            meta: Vec::new(),
4097            signatures: Vec::new(),
4098        };
4099
4100        // Authoritative Go bytes (generator case "checkpoint: State w/ nil DisablementValues"):
4101        // the State map is `…02 f6 03 82 …` — a NIL DisablementValues encodes as CBOR null (0xf6).
4102        // FIXED: `AumState.disablement_values` is now `Option`, so the nil case (`None`) is
4103        // representable and encodes as null, byte-matching Go. (Was a recorded interop bug where the
4104        // `Vec` type forced an empty array `0x80` and diverged the checkpoint Hash from Go.)
4105        const GO_SERIALIZE: &str = "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f02f60382a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5fa401010203035820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f0ca1616b6176";
4106        const GO_HASH: &str = "cae17cc938c5a954cd4389d83c6afe4d3487edac38b94824bec3312b82f35710";
4107
4108        // Re-point the checkpoint's State to a genuinely-nil DisablementValues (`None`), which is the
4109        // case the Go golden above was generated from.
4110        let checkpoint = {
4111            let mut c = checkpoint;
4112            if let Some(state) = c.state.as_mut() {
4113                state.disablement_values = None;
4114            }
4115            c
4116        };
4117
4118        assert_eq!(
4119            hex(&checkpoint.serialize()),
4120            GO_SERIALIZE,
4121            "nil DisablementValues must encode as CBOR null (0xf6), byte-matching Go"
4122        );
4123        assert_eq!(
4124            hex(&checkpoint.hash().0),
4125            GO_HASH,
4126            "with the nil-vs-empty fix, the checkpoint chain-link Hash matches Go"
4127        );
4128    }
4129
4130    // =======================================================================================
4131    // MUST-1: AUM signature verification (`VerifiedAumChain` / Go `aumVerify`). The trust
4132    // boundary for a control-supplied chain — an AUM may advance the trusted-key state only if
4133    // every signature on it verifies against a key already trusted at its parent.
4134    // =======================================================================================
4135
4136    /// The signing key whose public key `test_aum_key(seed, _)` derives — so a key trusted via
4137    /// `test_aum_key(seed, v)` can be made to actually sign an AUM.
4138    fn signer_for(seed: u8) -> ed25519_dalek::SigningKey {
4139        ed25519_dalek::SigningKey::from_bytes(&[seed; 32])
4140    }
4141
4142    /// Sign `aum` with each `(seed)` signer, appending a real `AumSignature` over `aum.sig_hash()`.
4143    /// The signer's public key is `test_aum_key(seed, _).public`, so signing with `seed` produces a
4144    /// signature that a state trusting `test_aum_key(seed, _)` will accept.
4145    fn sign_aum(aum: &mut Aum, seeds: &[u8]) {
4146        use ed25519_dalek::Signer;
4147        let sig_hash = aum.sig_hash();
4148        for &seed in seeds {
4149            let signer = signer_for(seed);
4150            aum.signatures.push(AumSignature {
4151                key_id: signer.verifying_key().to_bytes().to_vec(),
4152                signature: signer.sign(&sig_hash).to_bytes().to_vec(),
4153            });
4154        }
4155    }
4156
4157    /// A genesis `AddKey` that adds `test_aum_key(seed, votes)` and is self-signed by that very key
4158    /// — the bootstrapping shape (Go verifies a genesis against the keys it itself establishes).
4159    fn signed_genesis_add(seed: u8, votes: u32) -> Aum {
4160        let mut g = genesis_add(test_aum_key(seed, votes));
4161        sign_aum(&mut g, &[seed]);
4162        g
4163    }
4164
4165    /// Happy path: a self-signed genesis followed by a child signed by the trusted genesis key
4166    /// verifies, and `from_verified_chain` yields the same state as the structural `from_chain`.
4167    #[test]
4168    fn verified_chain_accepts_properly_signed_chain() {
4169        let g = signed_genesis_add(1, 1);
4170        // Child adds a second key, signed by the trusted key from the genesis (seed 1).
4171        let mut a1 = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4172        sign_aum(&mut a1, &[1]);
4173
4174        let chain = [g.clone(), a1.clone()];
4175        let verified = VerifiedAumChain::verify(&chain).expect("a properly signed chain verifies");
4176        let auth = Authority::from_verified_chain(verified);
4177
4178        assert_eq!(auth.head(), a1.hash(), "head = last AUM");
4179        assert_eq!(auth.state().keys.len(), 2, "both keys trusted");
4180        // The verified-path state must equal the structural-path state for an authentic chain.
4181        let structural = Authority::from_chain(&chain).unwrap();
4182        assert_eq!(auth.state(), structural.state());
4183        assert_eq!(auth.head(), structural.head());
4184    }
4185
4186    /// An unsigned AUM (no signatures at all) is rejected — Go `aumVerify` "unsigned AUM". This
4187    /// holds even for the genesis.
4188    #[test]
4189    fn verified_chain_rejects_unsigned_aum() {
4190        // Unsigned genesis.
4191        let g = genesis_add(test_aum_key(1, 1));
4192        assert_eq!(
4193            VerifiedAumChain::verify(core::slice::from_ref(&g)).unwrap_err(),
4194            TkaError::UnsignedAum,
4195            "an unsigned genesis must be rejected"
4196        );
4197
4198        // Signed genesis, but an unsigned child.
4199        let sg = signed_genesis_add(1, 1);
4200        let a1 = child(&sg, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4201        assert_eq!(
4202            VerifiedAumChain::verify(&[sg, a1]).unwrap_err(),
4203            TkaError::UnsignedAum,
4204            "an unsigned non-genesis AUM must be rejected"
4205        );
4206    }
4207
4208    /// THE headline security property: a malicious control plane inserts an `AddKey` that adds the
4209    /// attacker's own key, signed by the attacker (a key NOT trusted in the current state). MUST-1
4210    /// rejects it as `UntrustedKey` — so the forged key never reaches a live `Authority`. Without
4211    /// the signature gate, `from_chain` would happily fold it (demonstrated) — which is exactly the
4212    /// tailnet-lock-defeating forgery the type-enforced `VerifiedAumChain` prevents.
4213    #[test]
4214    fn verified_chain_rejects_forged_addkey_from_untrusted_signer() {
4215        let g = signed_genesis_add(1, 1); // only key seed=1 is trusted
4216        // Attacker forges an AddKey inserting their own key (seed 9), signed by seed 9 (untrusted).
4217        let mut forged = child(&g, AumKind::AddKey, Some(test_aum_key(9, 99)), Vec::new());
4218        sign_aum(&mut forged, &[9]);
4219
4220        assert_eq!(
4221            VerifiedAumChain::verify(&[g.clone(), forged.clone()]).unwrap_err(),
4222            TkaError::UntrustedKey,
4223            "an AddKey signed only by an untrusted key must be rejected"
4224        );
4225        // Contrast: the structural-only `from_chain` (NOT a trust boundary) DOES fold the forgery,
4226        // proving why the type-enforced verified path is necessary.
4227        let structural = Authority::from_chain(&[g, forged]).unwrap();
4228        assert_eq!(
4229            structural.state().keys.len(),
4230            2,
4231            "structural from_chain folds the forged key — exactly why it is not a trust boundary"
4232        );
4233    }
4234
4235    /// A signature whose `key_id` IS trusted but whose bytes were produced over different content
4236    /// (here: signed by the wrong private key but labelled with the trusted key's id) fails the
4237    /// cryptographic check → `BadSignature`.
4238    #[test]
4239    fn verified_chain_rejects_tampered_signature() {
4240        let g = signed_genesis_add(1, 1);
4241        let mut a1 = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4242        // Label the signature with the trusted key's id (seed 1) but sign with the WRONG key.
4243        use ed25519_dalek::Signer;
4244        let wrong = signer_for(42);
4245        a1.signatures.push(AumSignature {
4246            key_id: signer_for(1).verifying_key().to_bytes().to_vec(),
4247            signature: wrong.sign(&a1.sig_hash()).to_bytes().to_vec(),
4248        });
4249        assert_eq!(
4250            VerifiedAumChain::verify(&[g, a1]).unwrap_err(),
4251            TkaError::BadSignature,
4252            "a signature that doesn't verify under the named trusted key is rejected"
4253        );
4254    }
4255
4256    /// Every signature must verify (Go loops over all, failing on the first bad one): a child with
4257    /// one valid trusted signature AND one bad/untrusted signature is still rejected.
4258    #[test]
4259    fn verified_chain_requires_all_signatures_valid() {
4260        let g = signed_genesis_add(1, 1);
4261        let mut a1 = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4262        // First a valid signature by the trusted key, then a second by an untrusted key.
4263        sign_aum(&mut a1, &[1]); // valid (seed 1 trusted)
4264        sign_aum(&mut a1, &[7]); // untrusted (seed 7 not in state)
4265        assert_eq!(
4266            VerifiedAumChain::verify(&[g, a1]).unwrap_err(),
4267            TkaError::UntrustedKey,
4268            "a single untrusted signature rejects the AUM even alongside a valid one"
4269        );
4270    }
4271
4272    /// A genesis `Checkpoint` self-certifies against the keys it embeds (Go
4273    /// `aumVerify(bootstrap, *bootstrap.State, true)`): the checkpoint's signature must verify
4274    /// against a key inside its own `State`. The embedded `State` must itself be Go-valid (≥1
4275    /// disablement value of 32 bytes, ≥1 key) — `static_validate_checkpoint` enforces that.
4276    #[test]
4277    fn verified_chain_genesis_checkpoint_self_certifies() {
4278        let trusted = test_aum_key(1, 1);
4279        let mut g = Aum {
4280            message_kind: AumKind::Checkpoint,
4281            prev_aum_hash: None,
4282            key: None,
4283            key_id: Vec::new(),
4284            state: Some(AumState {
4285                last_aum_hash: None,
4286                // A valid checkpoint needs ≥1 disablement value, each exactly 32 bytes.
4287                disablement_values: Some(alloc::vec![alloc::vec![0xD5u8; DISABLEMENT_LENGTH]]),
4288                keys: Some(alloc::vec![trusted.clone()]),
4289                state_id1: 0,
4290                state_id2: 0,
4291            }),
4292            votes: None,
4293            meta: Vec::new(),
4294            signatures: Vec::new(),
4295        };
4296        // Unsigned → rejected.
4297        assert_eq!(
4298            VerifiedAumChain::verify(&[g.clone()]).unwrap_err(),
4299            TkaError::UnsignedAum
4300        );
4301        // Signed by the key embedded in its own State → accepted.
4302        sign_aum(&mut g, &[1]);
4303        let verified = VerifiedAumChain::verify(&[g.clone()])
4304            .expect("a checkpoint signed by an embedded key self-certifies");
4305        let auth = Authority::from_verified_chain(verified);
4306        assert_eq!(auth.state().keys.len(), 1);
4307        assert_eq!(auth.head(), g.hash());
4308    }
4309
4310    /// A genesis `Checkpoint` whose embedded `State` is malformed is rejected by
4311    /// `static_validate_checkpoint` (Go `staticValidateCheckpoint`), before any signature check.
4312    #[test]
4313    fn verified_chain_rejects_malformed_checkpoint_state() {
4314        let trusted = test_aum_key(1, 1);
4315        let mk = |state: AumState| {
4316            let mut g = Aum {
4317                message_kind: AumKind::Checkpoint,
4318                prev_aum_hash: None,
4319                key: None,
4320                key_id: Vec::new(),
4321                state: Some(state),
4322                votes: None,
4323                meta: Vec::new(),
4324                signatures: Vec::new(),
4325            };
4326            sign_aum(&mut g, &[1]);
4327            g
4328        };
4329        let base = AumState {
4330            last_aum_hash: None,
4331            disablement_values: Some(alloc::vec![alloc::vec![0xD5u8; DISABLEMENT_LENGTH]]),
4332            keys: Some(alloc::vec![trusted.clone()]),
4333            state_id1: 0,
4334            state_id2: 0,
4335        };
4336
4337        // No disablement values → rejected.
4338        let no_disable = AumState {
4339            disablement_values: None,
4340            ..base.clone()
4341        };
4342        assert_eq!(
4343            VerifiedAumChain::verify(&[mk(no_disable)]).unwrap_err(),
4344            TkaError::BadKeyState,
4345            "a checkpoint with no disablement value is rejected"
4346        );
4347
4348        // Disablement value of the wrong length → rejected.
4349        let bad_len = AumState {
4350            disablement_values: Some(alloc::vec![alloc::vec![0u8; 16]]),
4351            ..base.clone()
4352        };
4353        assert_eq!(
4354            VerifiedAumChain::verify(&[mk(bad_len)]).unwrap_err(),
4355            TkaError::BadKeyState,
4356            "a disablement value of the wrong length is rejected"
4357        );
4358
4359        // No keys → rejected.
4360        let no_keys = AumState {
4361            keys: Some(Vec::new()),
4362            ..base.clone()
4363        };
4364        assert_eq!(
4365            VerifiedAumChain::verify(&[mk(no_keys)]).unwrap_err(),
4366            TkaError::BadKeyState,
4367            "a checkpoint with no keys is rejected"
4368        );
4369
4370        // Duplicate keys → rejected.
4371        let dup_keys = AumState {
4372            keys: Some(alloc::vec![trusted.clone(), trusted.clone()]),
4373            ..base.clone()
4374        };
4375        assert_eq!(
4376            VerifiedAumChain::verify(&[mk(dup_keys)]).unwrap_err(),
4377            TkaError::BadKeyState,
4378            "a checkpoint with duplicate key ids is rejected"
4379        );
4380
4381        // F5: a NON-adjacent duplicate ([a, b, a]) is also caught (the prefix-scan dedup checks all
4382        // earlier elements, not just the neighbor).
4383        let nonadjacent_dup = AumState {
4384            keys: Some(alloc::vec![
4385                test_aum_key(1, 1),
4386                test_aum_key(2, 1),
4387                test_aum_key(1, 1)
4388            ]),
4389            ..base.clone()
4390        };
4391        assert_eq!(
4392            VerifiedAumChain::verify(&[mk(nonadjacent_dup)]).unwrap_err(),
4393            TkaError::BadKeyState,
4394            "a non-adjacent duplicate key id is rejected"
4395        );
4396
4397        // F4: more than MAX_KEYS (512) keys → rejected. Use distinct 32-byte public keys (index-
4398        // encoded) so there are no duplicate ids — only the `> MAX_KEYS` cap can be the failure.
4399        let distinct_over_cap: alloc::vec::Vec<AumKey> = (0u32..=(MAX_KEYS as u32))
4400            .map(|i| AumKey {
4401                kind: KeyKind::Ed25519,
4402                votes: 1,
4403                // Distinct 32-byte public keys by encoding the index — no dup, so only the >512 cap trips.
4404                public: {
4405                    let mut p = alloc::vec![0u8; 32];
4406                    p[0..4].copy_from_slice(&i.to_le_bytes());
4407                    p
4408                },
4409                meta: Vec::new(),
4410            })
4411            .collect();
4412        assert_eq!(distinct_over_cap.len(), MAX_KEYS + 1);
4413        let over_keys = AumState {
4414            keys: Some(distinct_over_cap),
4415            ..base.clone()
4416        };
4417        assert_eq!(
4418            VerifiedAumChain::verify(&[mk(over_keys)]).unwrap_err(),
4419            TkaError::BadKeyState,
4420            "a checkpoint with > MAX_KEYS keys is rejected"
4421        );
4422
4423        // F4: more than MAX_DISABLEMENT_VALUES (32) disablement values → rejected (each distinct).
4424        let over_disablements = AumState {
4425            disablement_values: Some(
4426                (0u8..=(MAX_DISABLEMENT_VALUES as u8))
4427                    .map(|i| {
4428                        let mut d = alloc::vec![0u8; DISABLEMENT_LENGTH];
4429                        d[0] = i;
4430                        d
4431                    })
4432                    .collect(),
4433            ),
4434            ..base
4435        };
4436        assert_eq!(
4437            VerifiedAumChain::verify(&[mk(over_disablements)]).unwrap_err(),
4438            TkaError::BadKeyState,
4439            "a checkpoint with > MAX_DISABLEMENT_VALUES disablement values is rejected"
4440        );
4441    }
4442
4443    /// A broken parent link is still caught on the verified path (the structural fold runs after the
4444    /// signature check for non-genesis AUMs).
4445    #[test]
4446    fn verified_chain_rejects_broken_parent_link() {
4447        let g = signed_genesis_add(1, 1);
4448        let mut orphan = child(&g, AumKind::NoOp, None, alloc::vec![9]);
4449        orphan.prev_aum_hash = Some(AumHash([0xAB; 32])); // wrong parent
4450        sign_aum(&mut orphan, &[1]); // validly signed, but mis-linked
4451        assert_eq!(
4452            VerifiedAumChain::verify(&[g, orphan]).unwrap_err(),
4453            TkaError::BadParent,
4454            "a validly-signed but mis-linked AUM is still rejected"
4455        );
4456    }
4457
4458    // ===== Aum::from_cbor — the decode inverse of Aum::serialize (issue #7 chunk 2, tsr-2dr) =====
4459
4460    /// `Aum::from_cbor(aum.serialize())` reconstructs the exact `Aum` for every message kind and
4461    /// optional-field combination. This is the core round-trip contract the sync/bootstrap path
4462    /// relies on: bytes control sends → `Aum` → verify/replay.
4463    #[test]
4464    fn aum_from_cbor_roundtrips_every_shape() {
4465        let cases: alloc::vec::Vec<(&str, Aum)> = alloc::vec![
4466            (
4467                "RemoveKey, genesis (null prev), key_id",
4468                Aum {
4469                    message_kind: AumKind::RemoveKey,
4470                    prev_aum_hash: None,
4471                    key: None,
4472                    key_id: alloc::vec![1, 2],
4473                    state: None,
4474                    votes: None,
4475                    meta: Vec::new(),
4476                    signatures: Vec::new(),
4477                },
4478            ),
4479            (
4480                "UpdateKey with votes + meta (text-keyed map)",
4481                Aum {
4482                    message_kind: AumKind::UpdateKey,
4483                    prev_aum_hash: None,
4484                    key: None,
4485                    key_id: alloc::vec![1, 2],
4486                    state: None,
4487                    votes: Some(2),
4488                    meta: alloc::vec![("a".into(), "b".into())],
4489                    signatures: Vec::new(),
4490                },
4491            ),
4492            (
4493                "AddKey with an embedded Key + non-null prev + signatures",
4494                Aum {
4495                    message_kind: AumKind::AddKey,
4496                    prev_aum_hash: Some(AumHash([0x11; AUM_HASH_LEN])),
4497                    key: Some(AumKey {
4498                        kind: KeyKind::Ed25519,
4499                        votes: 3,
4500                        public: alloc::vec![9, 8, 7],
4501                        meta: alloc::vec![("k".into(), "v".into())],
4502                    }),
4503                    key_id: Vec::new(),
4504                    state: None,
4505                    votes: None,
4506                    meta: Vec::new(),
4507                    signatures: alloc::vec![
4508                        AumSignature {
4509                            key_id: alloc::vec![1],
4510                            signature: Vec::new(), // nil → null on the wire
4511                        },
4512                        AumSignature {
4513                            key_id: alloc::vec![2, 3],
4514                            signature: alloc::vec![4, 5, 6],
4515                        },
4516                    ],
4517                },
4518            ),
4519            (
4520                "Checkpoint with full State (null + empty-array + populated arms)",
4521                Aum {
4522                    message_kind: AumKind::Checkpoint,
4523                    prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
4524                    key: None,
4525                    key_id: Vec::new(),
4526                    state: Some(AumState {
4527                        last_aum_hash: Some(AumHash([0xAB; AUM_HASH_LEN])),
4528                        disablement_values: Some(alloc::vec![alloc::vec![1, 2], alloc::vec![3]]),
4529                        keys: Some(alloc::vec![AumKey {
4530                            kind: KeyKind::Ed25519,
4531                            votes: 1,
4532                            public: alloc::vec![5, 6],
4533                            meta: Vec::new(),
4534                        }]),
4535                        state_id1: 7,
4536                        state_id2: 0, // omitted (omitempty)
4537                    }),
4538                    votes: None,
4539                    meta: Vec::new(),
4540                    signatures: Vec::new(),
4541                },
4542            ),
4543            (
4544                "Checkpoint with nil State arms (null) and empty disablement array",
4545                Aum {
4546                    message_kind: AumKind::Checkpoint,
4547                    prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
4548                    key: None,
4549                    key_id: Vec::new(),
4550                    state: Some(AumState {
4551                        last_aum_hash: None,                  // null
4552                        disablement_values: Some(Vec::new()), // empty array 0x80
4553                        keys: None,                           // null
4554                        state_id1: 0,
4555                        state_id2: 9,
4556                    }),
4557                    votes: None,
4558                    meta: Vec::new(),
4559                    signatures: Vec::new(),
4560                },
4561            ),
4562            (
4563                "NoOp, non-null prev, nothing else",
4564                Aum {
4565                    message_kind: AumKind::NoOp,
4566                    prev_aum_hash: Some(AumHash([0x42; AUM_HASH_LEN])),
4567                    key: None,
4568                    key_id: Vec::new(),
4569                    state: None,
4570                    votes: None,
4571                    meta: Vec::new(),
4572                    signatures: Vec::new(),
4573                },
4574            ),
4575        ];
4576
4577        for (label, aum) in cases {
4578            let bytes = aum.serialize();
4579            let decoded = Aum::from_cbor(&bytes)
4580                .unwrap_or_else(|e| panic!("from_cbor failed for {label:?}: {e}"));
4581            assert_eq!(decoded, aum, "round-trip mismatch for {label:?}");
4582            // And the decoded AUM re-serializes to the identical bytes (canonical-form preserved →
4583            // hash/sig_hash are stable across a decode/encode cycle, which the chain replayer needs).
4584            assert_eq!(
4585                decoded.serialize(),
4586                bytes,
4587                "re-serialize must be byte-identical for {label:?}"
4588            );
4589            assert_eq!(
4590                decoded.hash(),
4591                aum.hash(),
4592                "hash must survive round-trip for {label:?}"
4593            );
4594        }
4595    }
4596
4597    /// Decode the exact frozen Go `TestSerialization` byte vectors (the same literals asserted on the
4598    /// encode side) straight into `Aum`s — proving the decoder consumes real Go-produced bytes, not
4599    /// just our own encoder's output.
4600    #[test]
4601    fn aum_from_cbor_decodes_frozen_go_vectors() {
4602        // RemoveKey: a3 01 02 02 f6 04 42 01 02
4603        let remove_key = Aum::from_cbor(&[0xa3, 0x01, 0x02, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02])
4604            .expect("decode RemoveKey vector");
4605        assert_eq!(remove_key.message_kind, AumKind::RemoveKey);
4606        assert_eq!(remove_key.prev_aum_hash, None);
4607        assert_eq!(remove_key.key_id, alloc::vec![1, 2]);
4608
4609        // UpdateKey: a5 01 04 02 f6 04 42 01 02 06 02 07 a1 61 61 61 62
4610        let update_key = Aum::from_cbor(&[
4611            0xa5, 0x01, 0x04, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02, 0x06, 0x02, 0x07, 0xa1, 0x61,
4612            0x61, 0x61, 0x62,
4613        ])
4614        .expect("decode UpdateKey vector");
4615        assert_eq!(update_key.message_kind, AumKind::UpdateKey);
4616        assert_eq!(update_key.votes, Some(2));
4617        assert_eq!(
4618            update_key.meta,
4619            alloc::vec![(
4620                alloc::string::String::from("a"),
4621                alloc::string::String::from("b")
4622            )],
4623            "the text-keyed Meta map must decode to {{\"a\":\"b\"}}"
4624        );
4625
4626        // Signature: a3 01 01 02 f6 17 81 a2 01 41 01 02 f6
4627        let with_sig = Aum::from_cbor(&[
4628            0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6,
4629        ])
4630        .expect("decode Signature vector");
4631        assert_eq!(with_sig.message_kind, AumKind::AddKey);
4632        assert_eq!(with_sig.signatures.len(), 1);
4633        assert_eq!(with_sig.signatures[0].key_id, alloc::vec![1]);
4634        assert_eq!(
4635            with_sig.signatures[0].signature,
4636            Vec::<u8>::new(),
4637            "the nil Signature (CBOR null) must decode to an empty Vec"
4638        );
4639        // Byte-exact re-encode of every frozen vector.
4640        assert_eq!(
4641            with_sig.serialize(),
4642            alloc::vec![
4643                0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6
4644            ]
4645        );
4646    }
4647
4648    /// The `null` (0xf6) major-7 arm is accepted ONLY for null; other major-7 simple/float values
4649    /// are still rejected (fail-closed), and the `NodeKeySignature` path is unaffected because its
4650    /// `expect_bytes` rejects null where bytes are required.
4651    #[test]
4652    fn decode_value_accepts_only_null_in_major7() {
4653        // Bare null decodes.
4654        assert_eq!(decode_value(&[0xf6], 0).unwrap().0, Value::Null);
4655        // true (0xf5), false (0xf4), undefined (0xf7), a float64 (0xfb …) → rejected.
4656        for bad in [
4657            alloc::vec![0xf5u8],
4658            alloc::vec![0xf4],
4659            alloc::vec![0xf7],
4660            alloc::vec![0xfb, 0x40, 0x09, 0x21, 0xfb, 0x54, 0x44, 0x2d, 0x18],
4661        ] {
4662            assert!(
4663                decode_value(&bad, 0).is_err(),
4664                "major-7 value {bad:02x?} other than null must be rejected"
4665            );
4666        }
4667    }
4668
4669    /// `Aum::from_cbor` fails closed on malformed / adversarial input — never panics, never `Ok` on
4670    /// garbage. Complements the `cbor_decode_smoke` integration test (which targets the signature
4671    /// path) for the AUM path.
4672    #[test]
4673    fn aum_from_cbor_fails_closed() {
4674        // Empty input.
4675        assert!(Aum::from_cbor(&[]).is_err());
4676        // Not a map (a bare uint).
4677        assert!(Aum::from_cbor(&[0x00]).is_err());
4678        // Map missing the non-omitempty prev_aum_hash (only message_kind present): a1 01 03.
4679        assert!(
4680            Aum::from_cbor(&[0xa1, 0x01, 0x03]).is_err(),
4681            "an AUM without key 2 (prev_aum_hash) must be rejected (non-omitempty)"
4682        );
4683        // Unknown field key (99): a2 01 03 18 63 00.
4684        assert!(
4685            Aum::from_cbor(&[0xa2, 0x01, 0x03, 0x18, 0x63, 0x00]).is_err(),
4686            "an unknown AUM field key must be rejected"
4687        );
4688        // Unknown message kind (9): a2 01 09 02 f6.
4689        assert!(
4690            Aum::from_cbor(&[0xa2, 0x01, 0x09, 0x02, 0xf6]).is_err(),
4691            "an unknown message_kind must be rejected"
4692        );
4693        // Trailing byte after a complete AUM: (a2 01 03 02 f6) + 00.
4694        assert!(
4695            Aum::from_cbor(&[0xa2, 0x01, 0x03, 0x02, 0xf6, 0x00]).is_err(),
4696            "trailing bytes after the AUM must be rejected"
4697        );
4698        // prev_aum_hash present but wrong length (31 bytes) → rejected.
4699        let mut short_prev = alloc::vec![0xa2u8, 0x01, 0x03, 0x02, 0x58, 0x1f];
4700        short_prev.extend(core::iter::repeat_n(0u8, 31));
4701        assert!(
4702            Aum::from_cbor(&short_prev).is_err(),
4703            "a prev_aum_hash that is not 32 bytes must be rejected"
4704        );
4705    }
4706
4707    /// A text-keyed map (`Meta`) and an int-keyed map are distinguished on decode, and a mixed-key
4708    /// map is rejected (TKA emits no mixed-key maps).
4709    #[test]
4710    fn decode_map_rejects_mixed_key_types() {
4711        // map(2){ 1: 0, "a": "b" } — int key then text key. a2 01 00 61 61 61 62
4712        assert!(
4713            decode_value(&[0xa2, 0x01, 0x00, 0x61, 0x61, 0x61, 0x62], 0).is_err(),
4714            "a map mixing uint and text keys must be rejected"
4715        );
4716        // A pure text map decodes to TextMap.
4717        let (v, rest) = decode_value(&[0xa1, 0x61, 0x61, 0x61, 0x62], 0).unwrap();
4718        assert!(rest.is_empty());
4719        assert_eq!(
4720            v,
4721            Value::TextMap(alloc::vec![(b"a".to_vec(), Value::Text(b"b".to_vec()))])
4722        );
4723    }
4724
4725    // ===== Review follow-ups (PR #48 review): close decode coverage gaps =====
4726
4727    /// Gap 2 (highest value): decode the authoritative frozen **Go checkpoint** bytes — the most
4728    /// complex AUM shape (null `disablement_values` arm, two nested keys, the second carrying a
4729    /// `Meta`, 32-byte hashes). The encode side asserts these exact bytes
4730    /// (`aum_checkpoint_nil_disablement_matches_go`); here we prove the *decoder* consumes them and
4731    /// round-trips byte-identically (so `hash()` is stable), exercising `AumState::from_value` +
4732    /// nested `AumKey::from_value` against real Go output rather than our own encoder.
4733    #[test]
4734    fn aum_from_cbor_decodes_frozen_go_checkpoint() {
4735        const GO_SERIALIZE: &str = "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f02f60382a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5fa401010203035820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f0ca1616b6176";
4736        const GO_HASH: &str = "cae17cc938c5a954cd4389d83c6afe4d3487edac38b94824bec3312b82f35710";
4737        let bytes = unhex(GO_SERIALIZE);
4738
4739        let aum = Aum::from_cbor(&bytes).expect("decode the frozen Go checkpoint");
4740        assert_eq!(aum.message_kind, AumKind::Checkpoint);
4741        let st = aum.state.as_ref().expect("checkpoint carries a State");
4742        assert_eq!(
4743            st.disablement_values, None,
4744            "nil DisablementValues → the null arm → None"
4745        );
4746        let keys = st.keys.as_ref().expect("State has keys");
4747        assert_eq!(keys.len(), 2, "two nested keys");
4748        assert_eq!(keys[0].votes, 1);
4749        assert_eq!(keys[1].votes, 3);
4750        assert_eq!(
4751            keys[1].meta,
4752            alloc::vec![(
4753                alloc::string::String::from("k"),
4754                alloc::string::String::from("v")
4755            )],
4756            "the second nested key carries Meta {{\"k\":\"v\"}}"
4757        );
4758        // Byte-exact re-encode → the chain-link Hash matches Go's golden hash.
4759        assert_eq!(
4760            aum.serialize(),
4761            bytes,
4762            "re-serialize must be byte-identical to the Go bytes"
4763        );
4764        assert_eq!(
4765            hex(&aum.hash().0),
4766            GO_HASH,
4767            "decoded checkpoint's Hash matches Go golden"
4768        );
4769    }
4770
4771    /// Gap 1: round-trip the field combinations the original cases missed — multi-entry `Meta`
4772    /// (canonical-ordering), both `state_id`s non-zero (key-4/key-5 routing), `votes` at the u32
4773    /// boundary, a both-empty (`null`/`null`) `AumSignature`, and `key`+`key_id`+`signatures`
4774    /// coexisting (key 3/4/23 cross-talk).
4775    #[test]
4776    fn aum_from_cbor_roundtrips_review_gap_shapes() {
4777        let cases: alloc::vec::Vec<(&str, Aum)> = alloc::vec![
4778            (
4779                "multi-entry meta, pre-sorted (serialize() canonicalises key order)",
4780                Aum {
4781                    message_kind: AumKind::UpdateKey,
4782                    prev_aum_hash: None,
4783                    key: None,
4784                    key_id: alloc::vec![1],
4785                    state: None,
4786                    votes: Some(1),
4787                    // Pre-sorted: `serialize()` emits TextMap keys in CTAP2 order, so the decoded
4788                    // meta is sorted; supplying sorted input keeps the `==` round-trip exact.
4789                    meta: alloc::vec![
4790                        ("a".into(), "2".into()),
4791                        ("mid".into(), "3".into()),
4792                        ("zebra".into(), "1".into()),
4793                    ],
4794                    signatures: Vec::new(),
4795                },
4796            ),
4797            (
4798                "both state_ids non-zero (key 4 and key 5 must not be swapped)",
4799                Aum {
4800                    message_kind: AumKind::Checkpoint,
4801                    prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
4802                    key: None,
4803                    key_id: Vec::new(),
4804                    state: Some(AumState {
4805                        last_aum_hash: None,
4806                        disablement_values: None,
4807                        keys: Some(Vec::new()),
4808                        state_id1: 7,
4809                        state_id2: 9,
4810                    }),
4811                    votes: None,
4812                    meta: Vec::new(),
4813                    signatures: Vec::new(),
4814                },
4815            ),
4816            (
4817                "votes at u32::MAX + AddKey with key.votes at u32::MAX and multi-meta",
4818                Aum {
4819                    message_kind: AumKind::AddKey,
4820                    prev_aum_hash: Some(AumHash([0x33; AUM_HASH_LEN])),
4821                    key: Some(AumKey {
4822                        kind: KeyKind::Ed25519,
4823                        votes: u32::MAX,
4824                        public: alloc::vec![1, 2, 3],
4825                        meta: alloc::vec![("a".into(), "x".into()), ("b".into(), "y".into())],
4826                    }),
4827                    key_id: Vec::new(),
4828                    state: None,
4829                    votes: None,
4830                    meta: Vec::new(),
4831                    signatures: Vec::new(),
4832                },
4833            ),
4834            (
4835                "both-empty AumSignature (key_id null AND signature null)",
4836                Aum {
4837                    message_kind: AumKind::AddKey,
4838                    prev_aum_hash: None,
4839                    key: None,
4840                    key_id: Vec::new(),
4841                    state: None,
4842                    votes: None,
4843                    meta: Vec::new(),
4844                    signatures: alloc::vec![AumSignature {
4845                        key_id: Vec::new(),
4846                        signature: Vec::new(),
4847                    }],
4848                },
4849            ),
4850            (
4851                "key + key_id + signatures coexisting (keys 3, 4, 23)",
4852                Aum {
4853                    message_kind: AumKind::AddKey,
4854                    prev_aum_hash: Some(AumHash([0x55; AUM_HASH_LEN])),
4855                    key: Some(AumKey {
4856                        kind: KeyKind::Ed25519,
4857                        votes: 2,
4858                        public: alloc::vec![7, 7, 7],
4859                        meta: Vec::new(),
4860                    }),
4861                    key_id: alloc::vec![9, 9],
4862                    state: None,
4863                    votes: None,
4864                    meta: Vec::new(),
4865                    signatures: alloc::vec![AumSignature {
4866                        key_id: alloc::vec![1],
4867                        signature: alloc::vec![2, 3, 4],
4868                    }],
4869                },
4870            ),
4871            (
4872                "votes = 0 (boundary; Some(0) must survive, distinct from None)",
4873                Aum {
4874                    message_kind: AumKind::UpdateKey,
4875                    prev_aum_hash: None,
4876                    key: None,
4877                    key_id: alloc::vec![1],
4878                    state: None,
4879                    votes: Some(0),
4880                    meta: Vec::new(),
4881                    signatures: Vec::new(),
4882                },
4883            ),
4884        ];
4885        for (label, aum) in cases {
4886            let bytes = aum.serialize();
4887            let decoded = Aum::from_cbor(&bytes)
4888                .unwrap_or_else(|e| panic!("from_cbor failed for {label:?}: {e}"));
4889            assert_eq!(decoded, aum, "round-trip mismatch for {label:?}");
4890            assert_eq!(
4891                decoded.serialize(),
4892                bytes,
4893                "re-serialize differs for {label:?}"
4894            );
4895        }
4896    }
4897
4898    /// Gap 3: additional fail-closed guards on the AUM entry point — truncated map (count > entries),
4899    /// a duplicate key at the AUM level, votes > u32::MAX, an unsupported key kind, and a malformed
4900    /// (non-map) `state` value. Each must `Err`, never panic, never `Ok`.
4901    #[test]
4902    fn aum_from_cbor_fails_closed_review_gaps() {
4903        // Truncated map: header claims 3 pairs, only 2 present then EOF.
4904        assert!(
4905            Aum::from_cbor(&[0xa3, 0x01, 0x03, 0x02, 0xf6]).is_err(),
4906            "a map claiming more pairs than present must be rejected"
4907        );
4908        // Duplicate key at the AUM level: key 1 appears twice (a3 01 03 02 f6 01 04).
4909        assert!(
4910            Aum::from_cbor(&[0xa3, 0x01, 0x03, 0x02, 0xf6, 0x01, 0x04]).is_err(),
4911            "a duplicate AUM map key must be rejected"
4912        );
4913        // votes > u32::MAX: 06 1b 0000_0001_0000_0000 (= 2^32).
4914        assert!(
4915            Aum::from_cbor(&[
4916                0xa3, 0x01, 0x04, 0x02, 0xf6, 0x06, 0x1b, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
4917                0x00,
4918            ])
4919            .is_err(),
4920            "votes above u32::MAX must be rejected (fail-closed narrowing)"
4921        );
4922        // Unsupported key kind: AddKey embedding a key with kind=2. a3 01 01 02 f6 03 a3 01 02 02 01 03 41 09
4923        assert!(
4924            Aum::from_cbor(&[
4925                0xa3, 0x01, 0x01, 0x02, 0xf6, 0x03, 0xa3, 0x01, 0x02, 0x02, 0x01, 0x03, 0x41, 0x09,
4926            ])
4927            .is_err(),
4928            "an unsupported key kind must be rejected (not silently treated as Ed25519)"
4929        );
4930        // Malformed state: key 5 is a uint, not a map. a2 01 05 05 00 — but prev (key 2) missing too;
4931        // use a3 with prev null: a3 01 05 02 f6 05 00.
4932        assert!(
4933            Aum::from_cbor(&[0xa3, 0x01, 0x05, 0x02, 0xf6, 0x05, 0x00]).is_err(),
4934            "a non-map `state` value must be rejected"
4935        );
4936        // A deeply-nested array inside an AUM field must error (shared depth cap), not overflow.
4937        let mut nested = alloc::vec![0xa2u8, 0x01, 0x03, 0x02]; // map(2){1:3, 2: <nested>}
4938        nested.extend(core::iter::repeat_n(0x81u8, MAX_SIG_NESTING_DEPTH + 8)); // array(1) per level
4939        nested.push(0x00); // innermost uint
4940        assert!(
4941            Aum::from_cbor(&nested).is_err(),
4942            "an AUM field nested past the depth cap must be rejected, not overflow the stack"
4943        );
4944    }
4945
4946    /// Gap 5 + Finding L2: a NON-canonical encoding decodes to the SAME `Aum` (and thus the same
4947    /// `hash()`) as its canonical form — pinning the property that makes the lenient decode benign
4948    /// (the verify path re-serializes canonically, so wire-form variation can never forge a hash).
4949    #[test]
4950    fn aum_from_cbor_noncanonical_decodes_to_same_hash() {
4951        // Canonical NoOp with null prev: a2 01 03 02 f6.
4952        let canonical = [0xa2u8, 0x01, 0x03, 0x02, 0xf6];
4953        // Non-canonical variants that must decode to the SAME struct:
4954        //  (a) message_kind via a non-minimal 2-byte int head (0x18 0x03 instead of 0x03):
4955        let noncanon_int = [0xa2u8, 0x01, 0x18, 0x03, 0x02, 0xf6];
4956        //  (b) prev=null via the 2-byte simple-value form (0xf8 0x16 instead of 0xf6):
4957        let noncanon_null = [0xa2u8, 0x01, 0x03, 0x02, 0xf8, 0x16];
4958        //  (c) map keys in DESCENDING order (2 before 1):
4959        let noncanon_order = [0xa2u8, 0x02, 0xf6, 0x01, 0x03];
4960
4961        let base = Aum::from_cbor(&canonical).expect("canonical decodes");
4962        for (label, bytes) in [
4963            ("non-minimal int head", &noncanon_int[..]),
4964            ("2-byte null simple value", &noncanon_null[..]),
4965            ("descending key order", &noncanon_order[..]),
4966        ] {
4967            let got = Aum::from_cbor(bytes)
4968                .unwrap_or_else(|e| panic!("non-canonical ({label}) should still decode: {e}"));
4969            assert_eq!(
4970                got, base,
4971                "non-canonical ({label}) must decode to the same Aum"
4972            );
4973            assert_eq!(
4974                got.hash(),
4975                base.hash(),
4976                "non-canonical ({label}) must hash identically (re-serialized canonically)"
4977            );
4978            // And it normalises: re-serialize equals the canonical bytes.
4979            assert_eq!(
4980                got.serialize(),
4981                canonical,
4982                "non-canonical ({label}) must re-serialize to the canonical form"
4983            );
4984        }
4985    }
4986    // ---- StaticValidate cluster (tsr-uvg): Go `AUM/Key/State.StaticValidate` parity ----
4987
4988    /// `Key::static_validate` — votes must be 1..=4096 (Go `Key.StaticValidate`).
4989    #[test]
4990    fn key_static_validate_votes_range() {
4991        let mut k = test_aum_key(1, 1);
4992        assert!(k.static_validate().is_ok(), "votes=1 ok");
4993        k.votes = 4096;
4994        assert!(k.static_validate().is_ok(), "votes=4096 ok (boundary)");
4995        k.votes = 0;
4996        assert_eq!(
4997            k.static_validate().unwrap_err(),
4998            TkaError::BadKeyState,
4999            "votes=0 rejected"
5000        );
5001        k.votes = 4097;
5002        assert_eq!(
5003            k.static_validate().unwrap_err(),
5004            TkaError::BadKeyState,
5005            "votes>4096 rejected"
5006        );
5007    }
5008
5009    /// `Key::static_validate` — metadata byte total must be ≤ MAX_META_BYTES.
5010    #[test]
5011    fn key_static_validate_meta_size() {
5012        let mut k = test_aum_key(2, 1);
5013        // 256-byte key + 256-byte value = 512 total = exactly MAX_META_BYTES → ok.
5014        k.meta = alloc::vec![(
5015            String::from_utf8(alloc::vec![b'k'; 256]).unwrap(),
5016            String::from_utf8(alloc::vec![b'v'; 256]).unwrap(),
5017        )];
5018        assert!(k.static_validate().is_ok(), "512 meta bytes ok (boundary)");
5019        // One more byte → rejected.
5020        k.meta[0].1.push('x');
5021        assert_eq!(
5022            k.static_validate().unwrap_err(),
5023            TkaError::BadKeyState,
5024            "meta>512 rejected"
5025        );
5026    }
5027
5028    /// `Aum::static_validate` — per-kind field allow-lists (Go `AUM.StaticValidate`).
5029    #[test]
5030    fn aum_static_validate_per_kind_field_allow_lists() {
5031        // AddKey must have a key and nothing else.
5032        let mut a = genesis_add(test_aum_key(1, 1));
5033        assert!(a.static_validate().is_ok());
5034        a.key_id = alloc::vec![1, 2, 3]; // foreign field
5035        assert!(
5036            a.static_validate().is_err(),
5037            "AddKey with a stray KeyID rejected"
5038        );
5039
5040        // RemoveKey must have a key_id and nothing else.
5041        let g = signed_genesis_add(1, 1);
5042        let mut rm = child(
5043            &g,
5044            AumKind::RemoveKey,
5045            None,
5046            test_aum_key(2, 1).public.clone(),
5047        );
5048        assert!(rm.static_validate().is_ok());
5049        rm.votes = Some(3); // foreign field
5050        assert!(
5051            rm.static_validate().is_err(),
5052            "RemoveKey with stray Votes rejected"
5053        );
5054
5055        // UpdateKey must have key_id AND (votes or meta).
5056        let mut up = child(
5057            &g,
5058            AumKind::UpdateKey,
5059            None,
5060            test_aum_key(2, 1).public.clone(),
5061        );
5062        assert!(
5063            up.static_validate().is_err(),
5064            "UpdateKey with neither votes nor meta rejected"
5065        );
5066        up.votes = Some(2);
5067        assert!(up.static_validate().is_ok(), "UpdateKey with votes ok");
5068        up.key = Some(test_aum_key(3, 1)); // foreign field
5069        assert!(
5070            up.static_validate().is_err(),
5071            "UpdateKey with a stray Key rejected"
5072        );
5073
5074        // Checkpoint must have state and nothing else.
5075        let mut cp = child(&g, AumKind::Checkpoint, None, Vec::new());
5076        cp.state = Some(AumState {
5077            last_aum_hash: None,
5078            disablement_values: Some(alloc::vec![alloc::vec![0xD5u8; DISABLEMENT_LENGTH]]),
5079            keys: Some(alloc::vec![test_aum_key(1, 1)]),
5080            state_id1: 0,
5081            state_id2: 0,
5082        });
5083        assert!(cp.static_validate().is_ok());
5084        cp.votes = Some(1); // foreign field
5085        assert!(
5086            cp.static_validate().is_err(),
5087            "Checkpoint with stray Votes rejected"
5088        );
5089    }
5090
5091    /// `Aum::static_validate` — every signature must have a 32-byte key_id and 64-byte signature.
5092    #[test]
5093    fn aum_static_validate_signature_lengths() {
5094        let mut a = genesis_add(test_aum_key(1, 1));
5095        a.signatures = alloc::vec![AumSignature {
5096            key_id: alloc::vec![0u8; 31], // wrong length (should be 32)
5097            signature: alloc::vec![0u8; 64],
5098        }];
5099        assert!(a.static_validate().is_err(), "31-byte keyID rejected");
5100        a.signatures[0].key_id = alloc::vec![0u8; 32];
5101        a.signatures[0].signature = alloc::vec![0u8; 63]; // wrong length (should be 64)
5102        assert!(a.static_validate().is_err(), "63-byte signature rejected");
5103    }
5104
5105    /// The last-key guard (Go `aumVerify`): a `RemoveKey` removing the only remaining trusted key is
5106    /// rejected — otherwise the authority would be left with an empty key set (lock disabled).
5107    #[test]
5108    fn verified_chain_rejects_removing_last_key() {
5109        let g = signed_genesis_add(1, 1); // exactly one trusted key (seed 1)
5110        let mut rm = child(
5111            &g,
5112            AumKind::RemoveKey,
5113            None,
5114            test_aum_key(1, 1).public.clone(),
5115        );
5116        sign_aum(&mut rm, &[1]); // validly signed by the trusted key
5117        assert_eq!(
5118            VerifiedAumChain::verify(&[g, rm]).unwrap_err(),
5119            TkaError::BadKeyState,
5120            "removing the last trusted key must be refused"
5121        );
5122    }
5123
5124    /// Removing a non-last key is fine: with two trusted keys, one can be removed.
5125    #[test]
5126    fn verified_chain_allows_removing_non_last_key() {
5127        let g = signed_genesis_add(1, 1);
5128        let mut add = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
5129        sign_aum(&mut add, &[1]);
5130        let mut rm = child(
5131            &add,
5132            AumKind::RemoveKey,
5133            None,
5134            test_aum_key(2, 1).public.clone(),
5135        );
5136        sign_aum(&mut rm, &[1]);
5137        let verified = VerifiedAumChain::verify(&[g, add, rm]).expect("removing a non-last key ok");
5138        let auth = Authority::from_verified_chain(verified);
5139        assert_eq!(
5140            auth.state().keys.len(),
5141            1,
5142            "back to one key after the remove"
5143        );
5144    }
5145
5146    /// `UpdateKey` is re-validated after mutation (Go re-runs `Key.StaticValidate`): an update that
5147    /// sets votes out of range is rejected.
5148    #[test]
5149    fn verified_chain_rejects_updatekey_to_invalid_votes() {
5150        let g = signed_genesis_add(1, 1);
5151        let mut up = child(
5152            &g,
5153            AumKind::UpdateKey,
5154            None,
5155            test_aum_key(1, 1).public.clone(),
5156        );
5157        up.votes = Some(5000); // > 4096 → invalid after mutation
5158        sign_aum(&mut up, &[1]);
5159        assert_eq!(
5160            VerifiedAumChain::verify(&[g, up]).unwrap_err(),
5161            TkaError::BadKeyState,
5162            "an UpdateKey that sets votes > 4096 is rejected (post-mutation re-validate)"
5163        );
5164    }
5165
5166    // ===== AUM-chain sync: store + SyncOffer + MissingAUMs (issue #7 chunk 2, tsr-5po) =====
5167
5168    /// Build a simple linear chain `genesis(AddKey) -> NoOp -> NoOp -> ...` of `len` AUMs, returning
5169    /// the AUMs in parent→child order. The genesis adds `test_aum_key(1, 1)`.
5170    fn linear_chain(len: usize) -> Vec<Aum> {
5171        assert!(len >= 1);
5172        let mut chain = alloc::vec![genesis_add(test_aum_key(1, 1))];
5173        for _ in 1..len {
5174            let parent = chain.last().unwrap();
5175            chain.push(child(parent, AumKind::NoOp, None, Vec::new()));
5176        }
5177        chain
5178    }
5179
5180    /// An [`Authority`] whose head is the last AUM of `chain` (via the structural `from_chain`; the
5181    /// sync layer is signature-agnostic, so unsigned test chains are fine here).
5182    fn authority_at_head(chain: &[Aum]) -> Authority {
5183        Authority::from_chain(chain).expect("linear test chain replays")
5184    }
5185
5186    #[test]
5187    fn mem_store_indexes_by_hash_and_children() {
5188        let chain = linear_chain(3);
5189        let store = MemAumStore::from_aums(chain.clone());
5190        assert_eq!(store.len(), 3);
5191        // by-hash lookup
5192        assert_eq!(store.aum(&chain[1].hash()).as_ref(), Some(&chain[1]));
5193        assert!(store.aum(&AumHash([0xFF; AUM_HASH_LEN])).is_none());
5194        // child index: genesis has one child (chain[1]); the tail has none.
5195        let kids = store.child_aums(&chain[0].hash());
5196        assert_eq!(kids.len(), 1);
5197        assert_eq!(kids[0], chain[1]);
5198        assert!(store.child_aums(&chain[2].hash()).is_empty());
5199        // insert is idempotent on hash + child edge.
5200        let mut s2 = store.clone();
5201        s2.insert(chain[1].clone());
5202        assert_eq!(s2.len(), 3, "re-insert must not grow the store");
5203        assert_eq!(
5204            s2.child_aums(&chain[0].hash()).len(),
5205            1,
5206            "child edge not duplicated"
5207        );
5208    }
5209
5210    #[test]
5211    fn sync_offer_head_and_oldest_bookend() {
5212        let chain = linear_chain(5);
5213        let store = MemAumStore::from_aums(chain.clone());
5214        let auth = authority_at_head(&chain);
5215        let oldest = chain[0].hash();
5216
5217        let offer = auth.sync_offer(&store, oldest).expect("offer");
5218        assert_eq!(offer.head, chain[4].hash(), "offer head is the chain head");
5219        assert_eq!(
5220            *offer.ancestors.last().unwrap(),
5221            oldest,
5222            "the last ancestor is always the oldest AUM"
5223        );
5224        // Every ancestor is a real hash in the chain.
5225        for a in &offer.ancestors {
5226            assert!(
5227                store.aum(a).is_some(),
5228                "ancestor {a:?} must be in the store"
5229            );
5230        }
5231    }
5232
5233    #[test]
5234    fn sync_offer_truncates_on_a_gap() {
5235        // A store missing an interior AUM: the backward walk breaks early, but `oldest` is still
5236        // appended (matching Go's break-then-append). Drop chain[1] so walking back from head hits
5237        // a gap.
5238        let chain = linear_chain(4);
5239        let store = MemAumStore::from_aums(
5240            chain
5241                .iter()
5242                .enumerate()
5243                .filter(|(i, _)| *i != 1)
5244                .map(|(_, a)| a.clone()),
5245        );
5246        let auth = authority_at_head(&chain);
5247        let offer = auth
5248            .sync_offer(&store, chain[0].hash())
5249            .expect("offer despite gap");
5250        assert_eq!(*offer.ancestors.last().unwrap(), chain[0].hash());
5251    }
5252
5253    #[test]
5254    fn missing_aums_empty_when_up_to_date() {
5255        let chain = linear_chain(4);
5256        let store = MemAumStore::from_aums(chain.clone());
5257        let auth = authority_at_head(&chain);
5258        let oldest = chain[0].hash();
5259        // Peer offers the SAME head → nothing missing.
5260        let peer_offer = auth.sync_offer(&store, oldest).expect("offer");
5261        let missing = auth
5262            .missing_aums(&store, &peer_offer, oldest)
5263            .expect("missing");
5264        assert!(missing.is_empty(), "an up-to-date peer is missing nothing");
5265    }
5266
5267    #[test]
5268    fn missing_aums_head_intersection_sends_the_tail() {
5269        // We are at head chain[4]; the peer is behind at chain[2] (their head is an ancestor of
5270        // ours). We must send them chain[3] and chain[4] (everything after the intersection).
5271        let chain = linear_chain(5);
5272        let store = MemAumStore::from_aums(chain.clone());
5273        let oldest = chain[0].hash();
5274        let us = authority_at_head(&chain); // head = chain[4]
5275
5276        // The peer's offer: head = chain[2], ancestors back to oldest. Build it from a peer authority
5277        // whose head is chain[2] over a store holding the prefix [0..=2].
5278        let peer_prefix: Vec<Aum> = chain[0..=2].to_vec();
5279        let peer_store = MemAumStore::from_aums(peer_prefix.clone());
5280        let peer = authority_at_head(&peer_prefix); // head = chain[2]
5281        let peer_offer = peer.sync_offer(&peer_store, oldest).expect("peer offer");
5282
5283        let missing = us
5284            .missing_aums(&store, &peer_offer, oldest)
5285            .expect("missing");
5286        let missing_hashes: Vec<AumHash> = missing.iter().map(Aum::hash).collect();
5287        assert_eq!(
5288            missing_hashes,
5289            alloc::vec![chain[3].hash(), chain[4].hash()],
5290            "must send exactly the AUMs after the peer's head, in order"
5291        );
5292    }
5293
5294    #[test]
5295    fn missing_aums_excludes_the_intersection_itself() {
5296        // The intersection AUM (the peer's head) must NOT be in the sent set — they already have it.
5297        let chain = linear_chain(4);
5298        let store = MemAumStore::from_aums(chain.clone());
5299        let oldest = chain[0].hash();
5300        let us = authority_at_head(&chain);
5301
5302        let peer_prefix: Vec<Aum> = chain[0..=1].to_vec();
5303        let peer = authority_at_head(&peer_prefix);
5304        let peer_offer = peer
5305            .sync_offer(&MemAumStore::from_aums(peer_prefix.clone()), oldest)
5306            .expect("peer offer");
5307
5308        let missing = us
5309            .missing_aums(&store, &peer_offer, oldest)
5310            .expect("missing");
5311        assert!(
5312            !missing.iter().any(|a| a.hash() == chain[1].hash()),
5313            "the intersection AUM (peer's head) must be excluded"
5314        );
5315        assert_eq!(missing.len(), 2, "only chain[2] and chain[3] are missing");
5316    }
5317
5318    #[test]
5319    fn missing_aums_no_intersection_errors() {
5320        // Two totally unrelated chains (different genesis keys → different hashes everywhere): no
5321        // intersection, so `missing_aums` fails closed rather than mis-rooting.
5322        let ours = linear_chain(3);
5323        let store = MemAumStore::from_aums(ours.clone());
5324        let us = authority_at_head(&ours);
5325
5326        // A foreign chain the peer offers; we hold none of it.
5327        let theirs = {
5328            let mut c = alloc::vec![genesis_add(test_aum_key(9, 1))];
5329            c.push(child(&c[0], AumKind::NoOp, None, Vec::new()));
5330            c
5331        };
5332        let foreign_offer = SyncOffer {
5333            head: theirs[1].hash(),
5334            ancestors: alloc::vec![theirs[1].hash(), theirs[0].hash()],
5335        };
5336        assert!(
5337            us.missing_aums(&store, &foreign_offer, ours[0].hash())
5338                .is_err(),
5339            "no intersection must fail closed, not mis-root"
5340        );
5341    }
5342
5343    #[test]
5344    fn compute_state_at_matches_replay_at_each_point() {
5345        // The state computed at an interior AUM via the store walk must equal a direct linear replay
5346        // of the prefix up to that AUM (the verify-only Authority's state).
5347        let chain = linear_chain(4);
5348        let store = MemAumStore::from_aums(chain.clone());
5349        for i in 0..chain.len() {
5350            let want = chain[i].hash();
5351            let via_store = compute_state_at(&store, MAX_SYNC_ITER, want)
5352                .expect("compute_state_at ok")
5353                .expect("hash present");
5354            let via_replay = Authority::from_chain(&chain[0..=i]).expect("prefix replays");
5355            assert_eq!(
5356                via_store.to_state(),
5357                *via_replay.state(),
5358                "computed state at chain[{i}] must match a direct prefix replay"
5359            );
5360        }
5361    }
5362
5363    #[test]
5364    fn sync_offer_ancestors_are_exponentially_spaced() {
5365        // With a long chain the ancestor sampling thins out (skip 4, then 16, ...), so the count is
5366        // far below the chain length — the whole point of the offer.
5367        let chain = linear_chain(60);
5368        let store = MemAumStore::from_aums(chain.clone());
5369        let auth = authority_at_head(&chain);
5370        let offer = auth.sync_offer(&store, chain[0].hash()).expect("offer");
5371        assert!(
5372            offer.ancestors.len() < 12,
5373            "exponential spacing keeps the ancestor list small (got {})",
5374            offer.ancestors.len()
5375        );
5376        // First sampled ancestor is 4 back from head (i=4 is the first i%4==0 with i>0): chain[56].
5377        assert_eq!(offer.ancestors[0], chain[60 - 1 - 4].hash());
5378        assert_eq!(*offer.ancestors.last().unwrap(), chain[0].hash());
5379    }
5380
5381    #[test]
5382    fn linear_chain_from_returns_ordered_chain() {
5383        // A store built from a linear chain returns it genesis→head in order, regardless of insert
5384        // order, so it round-trips through `VerifiedAumChain`/`from_chain`.
5385        let chain = linear_chain(5);
5386        // Insert in reverse to prove ordering is by chain links, not insert order.
5387        let mut store = MemAumStore::new();
5388        for aum in chain.iter().rev() {
5389            store.insert(aum.clone());
5390        }
5391        let ordered = store.linear_chain_from(chain[0].hash()).expect("walk");
5392        let got: Vec<AumHash> = ordered.iter().map(Aum::hash).collect();
5393        let want: Vec<AumHash> = chain.iter().map(Aum::hash).collect();
5394        assert_eq!(got, want, "linear_chain_from must yield genesis→head order");
5395        // And it replays into the same head a direct from_chain produces.
5396        assert_eq!(
5397            Authority::from_chain(&ordered).unwrap().head(),
5398            chain[4].hash()
5399        );
5400    }
5401
5402    #[test]
5403    fn linear_chain_from_missing_genesis_errors() {
5404        let chain = linear_chain(3);
5405        let store = MemAumStore::from_aums(chain.clone());
5406        // A genesis hash not in the store is BadChain, not a panic.
5407        assert_eq!(
5408            store
5409                .linear_chain_from(AumHash([0xEE; AUM_HASH_LEN]))
5410                .unwrap_err(),
5411            TkaError::BadChain
5412        );
5413    }
5414
5415    #[test]
5416    fn linear_chain_from_single_genesis() {
5417        // A store with only the genesis returns just it (the bootstrap case before any sync).
5418        let g = genesis_add(test_aum_key(1, 1));
5419        let store = MemAumStore::from_aums([g.clone()]);
5420        let ordered = store.linear_chain_from(g.hash()).expect("walk");
5421        assert_eq!(ordered.len(), 1);
5422        assert_eq!(ordered[0].hash(), g.hash());
5423    }
5424
5425    /// Consensus regression (tsr-3x4): at a genuine **weight-decided** fork, `linear_chain_from` must
5426    /// pick the branch with the higher signing weight — the branch a Go node picks (Go folds the real
5427    /// trusted-key state before each `pickNextAUM`). The previous code resolved the fork against an
5428    /// empty (zero-key) `ReplayState`, so every candidate scored weight 0 and the tiebreak collapsed
5429    /// to lowest-hash; on a fork where the lowest-hash branch is NOT the highest-weight branch, that
5430    /// diverged from Go = an accept-direction consensus split. This test constructs exactly that
5431    /// adversarial shape (the low-weight branch has the lower hash) and asserts weight wins.
5432    #[test]
5433    fn linear_chain_from_resolves_fork_by_real_weight_not_empty_state() {
5434        use ed25519_dalek::{Signer, SigningKey};
5435
5436        // Two trusted keys with very different vote weights, established by a checkpoint genesis.
5437        let key_light = test_aum_key(0x10, 1); // votes = 1
5438        let key_heavy = test_aum_key(0x20, 100); // votes = 100
5439        let signer_light = SigningKey::from_bytes(&[0x10; 32]);
5440        let signer_heavy = SigningKey::from_bytes(&[0x20; 32]);
5441
5442        let genesis = Aum {
5443            message_kind: AumKind::Checkpoint,
5444            prev_aum_hash: None,
5445            key: None,
5446            key_id: Vec::new(),
5447            state: Some(AumState {
5448                last_aum_hash: None,
5449                disablement_values: Some(alloc::vec![alloc::vec![0x33u8; DISABLEMENT_LENGTH]]),
5450                keys: Some(alloc::vec![key_light.clone(), key_heavy.clone()]),
5451                state_id1: 0,
5452                state_id2: 0,
5453            }),
5454            votes: None,
5455            meta: Vec::new(),
5456            signatures: Vec::new(),
5457        };
5458        let gh = genesis.hash();
5459
5460        // Build a child NoOp signed by the given key. `salt` perturbs the AUM bytes (via meta) so we
5461        // can search for the hash ordering we need without changing which key signs it.
5462        let child_signed_by = |signer: &SigningKey, key: &AumKey, salt: u8| -> Aum {
5463            let mut aum = Aum {
5464                message_kind: AumKind::NoOp,
5465                prev_aum_hash: Some(gh),
5466                key: None,
5467                key_id: Vec::new(),
5468                state: None,
5469                votes: None,
5470                meta: alloc::vec![("s".to_string(), alloc::format!("{salt}"))],
5471                signatures: Vec::new(),
5472            };
5473            let sh = aum.sig_hash();
5474            aum.signatures = alloc::vec![AumSignature {
5475                key_id: key.id().to_vec(),
5476                signature: signer.sign(&sh).to_bytes().to_vec(),
5477            }];
5478            aum
5479        };
5480
5481        // Find a salt pair where the LIGHT-weight branch has the LOWER hash (the adversarial case:
5482        // lowest-hash != highest-weight). With content-derived hashes this is found by probing salts.
5483        let (light_child, heavy_child) = (0u8..64)
5484            .flat_map(|ls| (0u8..64).map(move |hs| (ls, hs)))
5485            .find_map(|(ls, hs)| {
5486                let light = child_signed_by(&signer_light, &key_light, ls);
5487                let heavy = child_signed_by(&signer_heavy, &key_heavy, hs);
5488                (light.hash().0 < heavy.hash().0).then_some((light, heavy))
5489            })
5490            .expect("a salt pair where the light branch sorts lower must exist");
5491
5492        // Sanity: the adversarial precondition actually holds.
5493        assert!(
5494            light_child.hash().0 < heavy_child.hash().0,
5495            "test setup: light (low-weight) branch must have the lower hash"
5496        );
5497
5498        let store =
5499            MemAumStore::from_aums([genesis.clone(), light_child.clone(), heavy_child.clone()]);
5500        let ordered = store.linear_chain_from(gh).expect("walk");
5501
5502        // The walk must pick the HEAVY (weight-100) branch as the genesis's successor — matching Go —
5503        // NOT the light/low-hash branch the old empty-state code would have chosen.
5504        assert_eq!(ordered.len(), 2, "genesis + the chosen branch head");
5505        assert_eq!(ordered[0].hash(), gh);
5506        assert_eq!(
5507            ordered[1].hash(),
5508            heavy_child.hash(),
5509            "fork must resolve to the higher-WEIGHT branch (Go parity), not the lower-HASH one"
5510        );
5511        assert_ne!(
5512            ordered[1].hash(),
5513            light_child.hash(),
5514            "the lower-hash low-weight branch must NOT be chosen (the pre-fix empty-state bug)"
5515        );
5516    }
5517
5518    /// Consensus regression (tsr-3x4), the state-ACCUMULATION case: the fork is resolved by a key
5519    /// that was added by a **mid-chain `AddKey`**, not by the genesis. This is the scenario that
5520    /// distinguishes a walk that folds *every AUM up to the fork* (correct, Go `advanceByPrimary`)
5521    /// from one that only ever reflects the genesis state — the genesis-only test above would pass
5522    /// even if `state` failed to advance past the genesis, so this fork lives two AUMs deep and its
5523    /// weight winner depends on the intervening `AddKey` having been folded in.
5524    #[test]
5525    fn linear_chain_from_resolves_deep_fork_using_mid_chain_added_key_weight() {
5526        use ed25519_dalek::{Signer, SigningKey};
5527
5528        // Genesis trusts only a bootstrap key (votes 1). A mid-chain AddKey then introduces the
5529        // heavy key (votes 100). The fork below the AddKey is decided by that heavy key's weight —
5530        // so it can only resolve correctly if the AddKey was folded into the walk's state.
5531        let key_boot = test_aum_key(0x40, 1);
5532        let key_heavy = test_aum_key(0x50, 100);
5533        let signer_boot = SigningKey::from_bytes(&[0x40; 32]);
5534        let signer_heavy = SigningKey::from_bytes(&[0x50; 32]);
5535
5536        let genesis = Aum {
5537            message_kind: AumKind::Checkpoint,
5538            prev_aum_hash: None,
5539            key: None,
5540            key_id: Vec::new(),
5541            state: Some(AumState {
5542                last_aum_hash: None,
5543                disablement_values: Some(alloc::vec![alloc::vec![0x44u8; DISABLEMENT_LENGTH]]),
5544                keys: Some(alloc::vec![key_boot.clone()]),
5545                state_id1: 0,
5546                state_id2: 0,
5547            }),
5548            votes: None,
5549            meta: Vec::new(),
5550            signatures: Vec::new(),
5551        };
5552        let gh = genesis.hash();
5553
5554        // Mid-chain AddKey introducing the heavy key, signed by the bootstrap key (the only trusted
5555        // key at this point). prev = genesis.
5556        let mut add_heavy = Aum {
5557            message_kind: AumKind::AddKey,
5558            prev_aum_hash: Some(gh),
5559            key: Some(key_heavy.clone()),
5560            key_id: Vec::new(),
5561            state: None,
5562            votes: None,
5563            meta: Vec::new(),
5564            signatures: Vec::new(),
5565        };
5566        let ah_sh = add_heavy.sig_hash();
5567        add_heavy.signatures = alloc::vec![AumSignature {
5568            key_id: key_boot.id().to_vec(),
5569            signature: signer_boot.sign(&ah_sh).to_bytes().to_vec(),
5570        }];
5571        let ah = add_heavy.hash();
5572
5573        // Two competing children of the AddKey: one signed by the heavy key (weight 100), one by the
5574        // bootstrap key (weight 1). Arrange (via salt) so the LIGHT (boot-signed) branch sorts lower.
5575        let child_signed_by = |signer: &SigningKey, key: &AumKey, salt: u8| -> Aum {
5576            let mut aum = Aum {
5577                message_kind: AumKind::NoOp,
5578                prev_aum_hash: Some(ah),
5579                key: None,
5580                key_id: Vec::new(),
5581                state: None,
5582                votes: None,
5583                meta: alloc::vec![("s".to_string(), alloc::format!("{salt}"))],
5584                signatures: Vec::new(),
5585            };
5586            let sh = aum.sig_hash();
5587            aum.signatures = alloc::vec![AumSignature {
5588                key_id: key.id().to_vec(),
5589                signature: signer.sign(&sh).to_bytes().to_vec(),
5590            }];
5591            aum
5592        };
5593        let (light_child, heavy_child) = (0u8..64)
5594            .flat_map(|ls| (0u8..64).map(move |hs| (ls, hs)))
5595            .find_map(|(ls, hs)| {
5596                let light = child_signed_by(&signer_boot, &key_boot, ls);
5597                let heavy = child_signed_by(&signer_heavy, &key_heavy, hs);
5598                (light.hash().0 < heavy.hash().0).then_some((light, heavy))
5599            })
5600            .expect("a salt pair where the light branch sorts lower must exist");
5601
5602        let store = MemAumStore::from_aums([
5603            genesis.clone(),
5604            add_heavy.clone(),
5605            light_child.clone(),
5606            heavy_child.clone(),
5607        ]);
5608        let ordered = store.linear_chain_from(gh).expect("walk");
5609
5610        // genesis → AddKey → the HEAVY branch (resolved by the mid-chain-added key's weight).
5611        assert_eq!(
5612            ordered.len(),
5613            3,
5614            "genesis + AddKey + the chosen branch head"
5615        );
5616        assert_eq!(ordered[0].hash(), gh);
5617        assert_eq!(ordered[1].hash(), ah, "the AddKey is on the walked chain");
5618        assert_eq!(
5619            ordered[2].hash(),
5620            heavy_child.hash(),
5621            "the deep fork must resolve by the mid-chain-added key's weight (state was accumulated past genesis)"
5622        );
5623    }
5624}