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