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