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