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