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/// Iteration cap for the backward head-intersection walk + offer ancestor walk (Go
1648/// `maxSyncHeadIntersectionIter`, `tka/limits.go`). Raised from 400 alongside the switch from an
1649/// exponentially-spaced ancestor sample to "offer every checkpoint": the walk now visits AUMs one
1650/// at a time looking for checkpoints, so it needs more headroom to reach the oldest one.
1651const MAX_SYNC_HEAD_INTERSECTION_ITER: u64 = 1000;
1652/// Iteration cap for forward fast-forward / `computeStateAt` walks (Go `maxSyncIter` /
1653/// `maxScanIterations`, `tka/limits.go`).
1654const MAX_SYNC_ITER: usize = 2000;
1655
1656/// A read-only store of AUMs keyed by hash, plus the parent→children index the forward walk needs
1657/// (Go's `tka.Chonk`, reduced to the methods the sync/offer path actually calls). The client builds
1658/// one of these from the AUMs it has on hand; the sync RPC populates it with what control sends.
1659///
1660/// "Not found" is signalled by `None` from [`aum`](AumStore::aum) (Go's `os.ErrNotExist` sentinel),
1661/// which the walkers treat as a loop terminator, not an error.
1662pub trait AumStore {
1663    /// Fetch the AUM with this hash, or `None` if the store does not hold it.
1664    fn aum(&self, hash: &AumHash) -> Option<Aum>;
1665    /// The AUMs whose `prev_aum_hash` is `hash` — the forward links out of `hash` (Go
1666    /// `Chonk.ChildAUMs`). Order is unspecified; the caller resolves forks deterministically.
1667    fn child_aums(&self, hash: &AumHash) -> Vec<Aum>;
1668}
1669
1670/// An in-memory [`AumStore`]: a hash→AUM map plus a parent-hash→child-hashes index, both built as
1671/// AUMs are inserted. `no_std`-friendly (`BTreeMap`, not `HashMap`). This is the store a client uses
1672/// to stage the AUMs it knows about while computing a [`SyncOffer`] / the AUMs a peer is missing.
1673#[derive(Debug, Clone, Default)]
1674pub struct MemAumStore {
1675    by_hash: alloc::collections::BTreeMap<AumHash, Aum>,
1676    /// parent hash → child hashes (the forward index). A genesis AUM (no parent) contributes no
1677    /// entry here; it is found only via `by_hash`.
1678    children: alloc::collections::BTreeMap<AumHash, Vec<AumHash>>,
1679}
1680
1681impl MemAumStore {
1682    /// A new, empty store.
1683    pub fn new() -> MemAumStore {
1684        MemAumStore::default()
1685    }
1686
1687    /// Insert an AUM, indexing it by its own hash and (if it has a parent) under its parent's child
1688    /// list. Idempotent: re-inserting the same AUM hash replaces it and does not duplicate the child
1689    /// edge. Returns the inserted AUM's hash.
1690    pub fn insert(&mut self, aum: Aum) -> AumHash {
1691        let hash = aum.hash();
1692        if let Some(parent) = aum.prev_aum_hash {
1693            let kids = self.children.entry(parent).or_default();
1694            if !kids.contains(&hash) {
1695                kids.push(hash);
1696            }
1697        }
1698        self.by_hash.insert(hash, aum);
1699        hash
1700    }
1701
1702    /// Build a store from an iterator of AUMs (e.g. a chain or a sync batch).
1703    pub fn from_aums(aums: impl IntoIterator<Item = Aum>) -> MemAumStore {
1704        let mut store = MemAumStore::new();
1705        for aum in aums {
1706            store.insert(aum);
1707        }
1708        store
1709    }
1710
1711    /// Number of AUMs held.
1712    pub fn len(&self) -> usize {
1713        self.by_hash.len()
1714    }
1715
1716    /// Whether the store holds no AUMs.
1717    pub fn is_empty(&self) -> bool {
1718        self.by_hash.is_empty()
1719    }
1720
1721    /// Walk the chain from `oldest` (the genesis) forward to the head, returning the AUMs in
1722    /// parent→child order — the linear form [`VerifiedAumChain::verify`] / [`Authority::from_chain`]
1723    /// expect. At a fork (a parent with more than one child) the deterministic `pick_next_aum` rule
1724    /// chooses the branch, so the result is the active chain (matching how the chain is replayed).
1725    ///
1726    /// Used by the runtime sync driver to turn the AUMs accumulated in the store (genesis +
1727    /// sync-received) into the ordered chain it re-verifies into an [`Authority`]. Bounded by
1728    /// `MAX_SYNC_ITER` so a malformed/cyclic store cannot loop forever.
1729    ///
1730    /// # Errors
1731    /// [`TkaError::BadChain`] if `oldest` is not in the store, or the walk exceeds the iteration cap
1732    /// (a cycle or an over-long chain). Signature/structure are NOT checked here — that is `verify`'s
1733    /// job; this only orders the AUMs.
1734    pub fn linear_chain_from(&self, oldest: AumHash) -> Result<Vec<Aum>, TkaError> {
1735        let mut out = Vec::new();
1736        let Some(mut curs) = self.aum(&oldest) else {
1737            return Err(TkaError::BadChain);
1738        };
1739        // Fold the chain into a live `ReplayState` as we walk, so a fork is resolved against the
1740        // REAL trusted-key weights at the fork point — exactly as Go's `advanceByPrimary` folds
1741        // state before each `pickNextAUM`. Resolving with an empty (zero-key) state would make every
1742        // candidate's weight 0, collapsing the tiebreak to the lowest-hash rule; a genuinely
1743        // weight-decided fork (signed competing branches with different vote totals) would then pick
1744        // a *different* branch than a Go node — an accept-direction consensus split.
1745        //
1746        // This store is UNVERIFIED (signatures/structure are `verify`'s job, not ours), so a fold can
1747        // fail on a malformed AUM. We tolerate that best-effort: stop advancing the weight state but
1748        // keep ordering the chain, because the authoritative `VerifiedAumChain::verify` runs on the
1749        // result and will reject a malformed chain anyway. We never abort the walk on a fold error.
1750        let mut state = ReplayState::default();
1751        for _ in 0..MAX_SYNC_ITER {
1752            // Apply the current AUM before resolving its children (so the weight at a fork below
1753            // includes every AUM up to and including this one). Best-effort: a fold failure on this
1754            // unverified store leaves `state` as-is rather than aborting the ordering walk.
1755            let _ = state.apply_verified_aum(&curs);
1756
1757            out.push(curs.clone());
1758            let children = self.child_aums(&curs.hash());
1759            if children.is_empty() {
1760                return Ok(out);
1761            }
1762            // Deterministic branch choice at a fork (weight → RemoveKey → lowest-hash), now against
1763            // the real replayed state. For a linear chain there is exactly one child and the state is
1764            // irrelevant; at a fork the weight term is correct.
1765            let next = pick_next_aum(&state, &children).clone();
1766            curs = next;
1767        }
1768        Err(TkaError::BadChain) // iteration cap: cycle or over-long chain
1769    }
1770}
1771
1772impl AumStore for MemAumStore {
1773    fn aum(&self, hash: &AumHash) -> Option<Aum> {
1774        self.by_hash.get(hash).cloned()
1775    }
1776
1777    fn child_aums(&self, hash: &AumHash) -> Vec<Aum> {
1778        self.children
1779            .get(hash)
1780            .map(|kids| {
1781                kids.iter()
1782                    .filter_map(|h| self.by_hash.get(h).cloned())
1783                    .collect()
1784            })
1785            .unwrap_or_default()
1786    }
1787}
1788
1789/// A node's view of where its chain is, offered to a peer so the peer can work out what to send (Go
1790/// `tka.SyncOffer`): the current `head` plus every **checkpoint** AUM the node holds between the
1791/// head and the oldest AUM it holds. The last entry is always the oldest-known AUM.
1792#[derive(Debug, Clone, PartialEq, Eq)]
1793pub struct SyncOffer {
1794    /// The node's current chain head.
1795    pub head: AumHash,
1796    /// A subset of the chain's ancestors, newest-first, ending with the oldest-known AUM. Used by a
1797    /// peer to find a "tail intersection" when it doesn't recognise the head. Every entry before the
1798    /// last is a `Checkpoint` AUM (the head itself is included when it is one).
1799    pub ancestors: Vec<AumHash>,
1800}
1801
1802/// The result of comparing two [`SyncOffer`]s (Go `tka.intersection`): where (if anywhere) the two
1803/// chains meet, which tells [`missing_aums`](sync_missing_aums) where to start gathering.
1804#[derive(Debug, Clone, PartialEq, Eq)]
1805struct Intersection {
1806    /// Both heads are equal — nothing to exchange.
1807    up_to_date: bool,
1808    /// The newest common AUM that is the *remote's* head and an ancestor of ours (we have updates
1809    /// building on it to send).
1810    head_intersection: Option<AumHash>,
1811    /// The oldest common AUM, where the chains diverge — a starting point to send from when we don't
1812    /// recognise the remote's head.
1813    tail_intersection: Option<AumHash>,
1814}
1815
1816/// Compute the state at `want_hash` by walking back to a checkpoint or genesis, then forward along
1817/// the taken path (Go `computeStateAt`, `tka/tka.go`). Structural fold only (no signature check) —
1818/// the verify boundary is elsewhere. Returns `None` (not an error) if `want_hash` is not in the
1819/// store, mirroring the `os.ErrNotExist` sentinel Go callers special-case.
1820fn compute_state_at(
1821    storage: &dyn AumStore,
1822    max_iter: usize,
1823    want_hash: AumHash,
1824) -> Result<Option<ReplayState>, TkaError> {
1825    let Some(top) = storage.aum(&want_hash) else {
1826        return Ok(None);
1827    };
1828
1829    // Walk backwards to a starting point: a checkpoint AUM (which carries full state) or a genesis
1830    // AUM (no parent — valid only for NoOp/AddKey/Checkpoint). `path` records every hash on the way
1831    // so the forward pass can follow exactly this branch (which, for a non-primary fork, may differ
1832    // from standard fork resolution).
1833    let mut curs = top;
1834    let mut state = ReplayState::default();
1835    let mut path: alloc::collections::BTreeSet<AumHash> = alloc::collections::BTreeSet::new();
1836    let mut started = false;
1837    for i in 0..=max_iter {
1838        if i == max_iter {
1839            return Err(TkaError::BadChain); // iteration limit exceeded
1840        }
1841        path.insert(curs.hash());
1842
1843        if curs.message_kind == AumKind::Checkpoint {
1844            // A checkpoint encapsulates the state at that point: take its embedded snapshot as the
1845            // starting state, with the cursor set to the checkpoint itself (Go
1846            // `curs.State.cloneForUpdate(&curs)`).
1847            //
1848            // Not `apply_verified_aum` into an empty state: that path is the *genesis* path and
1849            // refuses an AUM that names a parent, so a mid-chain checkpoint — the normal kind —
1850            // would come back `BadParent` and no state could be computed at it at all. Since
1851            // `sync_offer` now offers checkpoints, every tail-intersection candidate lands here.
1852            let snapshot = curs
1853                .state
1854                .as_ref()
1855                .ok_or(TkaError::Decode("checkpoint AUM missing state"))?;
1856            state = ReplayState {
1857                keys: snapshot.keys.clone().unwrap_or_default(),
1858                last_aum_hash: Some(curs.hash()),
1859                state_id: Some((snapshot.state_id1, snapshot.state_id2)),
1860            };
1861            started = true;
1862            break;
1863        }
1864        match curs.prev_aum_hash {
1865            None => {
1866                // Genesis: applies to the empty state. Only NoOp/AddKey reach here (Checkpoint broke
1867                // above); anything else is an invalid genesis. `apply_verified_aum` enforces the
1868                // same kind restriction, so just fold and let it reject a bad genesis.
1869                let mut s = ReplayState::default();
1870                s.apply_verified_aum(&curs)?;
1871                state = s;
1872                started = true;
1873                break;
1874            }
1875            Some(parent) => {
1876                let Some(p) = storage.aum(&parent) else {
1877                    return Err(TkaError::BadParent); // dangling parent link
1878                };
1879                curs = p;
1880            }
1881        }
1882    }
1883    debug_assert!(
1884        started,
1885        "compute_state_at must find a checkpoint or genesis"
1886    );
1887
1888    // Fast-forward from the starting point, following only AUMs on `path` (the custom advancer),
1889    // until we reach `want_hash`. No gather side-effect here — we only want the final state.
1890    let (_, end_state) = fast_forward(
1891        storage,
1892        max_iter,
1893        state,
1894        &mut |_: &Aum, _: &mut ReplayState| Ok(false),
1895        Some(&path),
1896        Some(want_hash),
1897    )?;
1898    Ok(Some(end_state))
1899}
1900
1901/// Fast-forward from `start_state` along the chain (Go `fastForwardWithAdvancer` +
1902/// `advanceByPrimary`). At each step it takes the children of the current AUM and advances by:
1903/// - the child on `path` when `path` is `Some` (the `computeStateAt` custom advancer), or
1904/// - [`pick_next_aum`]'s deterministic fork resolution otherwise (the primary advancer).
1905///
1906/// Stops when `stop_at` (when set) is reached — returning that AUM + the state *before* applying it
1907/// for a gather caller, matching Go's `done(curs, state)` check at the top of the loop — or when
1908/// there are no more children. `gather` is invoked for each visited AUM (Go's `done` callback side
1909/// effect) and its boolean return additionally stops the walk when true.
1910///
1911/// Returns the final AUM reached and the folded state at it. Structural fold only.
1912fn fast_forward(
1913    storage: &dyn AumStore,
1914    max_iter: usize,
1915    start_state: ReplayState,
1916    gather: &mut dyn FnMut(&Aum, &mut ReplayState) -> Result<bool, TkaError>,
1917    path: Option<&alloc::collections::BTreeSet<AumHash>>,
1918    stop_at: Option<AumHash>,
1919) -> Result<(Aum, ReplayState), TkaError> {
1920    let start_hash = start_state
1921        .last_aum_hash
1922        .ok_or(TkaError::Decode("fast_forward from a state with no head"))?;
1923    let mut curs = storage.aum(&start_hash).ok_or(TkaError::BadParent)?;
1924    let mut state = start_state;
1925
1926    for _ in 0..max_iter {
1927        // Done check runs BEFORE advancing (Go checks `done(curs, state)` at loop top): for a
1928        // `stop_at` caller this returns the stop AUM with the state *before* applying it.
1929        if Some(curs.hash()) == stop_at {
1930            return Ok((curs, state));
1931        }
1932        // Side-effect callback (the gather closure for `missing_aums`); a `true` return also stops.
1933        if gather(&curs, &mut state)? {
1934            return Ok((curs, state));
1935        }
1936
1937        let children = storage.child_aums(&curs.hash());
1938        let next = match path {
1939            // `computeStateAt` advancer: follow the unique child that is on the recorded path.
1940            Some(p) => children.into_iter().find(|c| p.contains(&c.hash())),
1941            // Primary advancer: deterministic fork resolution.
1942            None => {
1943                if children.is_empty() {
1944                    None
1945                } else {
1946                    Some(pick_next_aum(&state, &children).clone())
1947                }
1948            }
1949        };
1950        match next {
1951            None => return Ok((curs, state)), // no more children: we are at head
1952            Some(n) => {
1953                state.apply_verified_aum(&n)?;
1954                curs = n;
1955            }
1956        }
1957    }
1958    Err(TkaError::BadChain) // iteration limit exceeded
1959}
1960
1961impl Authority {
1962    /// Build the [`SyncOffer`] this authority would send a peer (Go `Authority.SyncOffer`): its
1963    /// `head` plus **every checkpoint** on the chain back to `oldest`, ending with `oldest`.
1964    /// `oldest` is the oldest AUM the caller holds (Go `a.oldestAncestor.Hash()`); our verify-only
1965    /// [`Authority`] does not track it, so it is passed in — typically the genesis hash of the chain
1966    /// the caller staged in `storage`.
1967    ///
1968    /// Checkpoints, not a sample: a node compacts its chain (keep the last 24 AUMs, keep two weeks,
1969    /// then back to the last checkpoint) and can end up holding only a few dozen AUMs. A sparse
1970    /// sample of *positions* taken from a long chain can then be entirely disjoint from the window a
1971    /// peer kept, leaving the peer unable to find a common ancestor and stuck re-polling with a
1972    /// permanently stale view. Compaction is guaranteed to leave at least one checkpoint behind, so
1973    /// offering the checkpoints is what makes an intersection findable.
1974    ///
1975    /// `storage` must contain the chain from `head` back to `oldest`; a gap simply truncates the
1976    /// ancestor list early (the walk breaks on the first missing parent, exactly like Go).
1977    pub fn sync_offer(
1978        &self,
1979        storage: &dyn AumStore,
1980        oldest: AumHash,
1981    ) -> Result<SyncOffer, TkaError> {
1982        let mut out = SyncOffer {
1983            head: self.head,
1984            ancestors: Vec::with_capacity(6),
1985        };
1986        let mut curs = self.head;
1987        for _ in 0..MAX_SYNC_HEAD_INTERSECTION_ITER {
1988            let Some(parent) = storage.aum(&curs) else {
1989                break; // os.ErrNotExist: stop, don't error
1990            };
1991            // We append `oldest` after the loop, so don't duplicate it.
1992            if parent.hash() == oldest {
1993                break;
1994            }
1995            // Every checkpoint goes in the offer — including the head itself when it is one.
1996            if parent.message_kind == AumKind::Checkpoint {
1997                out.ancestors.push(curs);
1998            }
1999            match parent.prev_aum_hash {
2000                Some(prev) => curs = prev,
2001                None => break, // reached a genesis that isn't `oldest`; nothing earlier to walk
2002            }
2003        }
2004        out.ancestors.push(oldest);
2005        Ok(out)
2006    }
2007
2008    /// Given a peer's [`SyncOffer`], compute the AUMs **they** are missing — the ones to send them so
2009    /// their chain catches up to ours (Go `Authority.MissingAUMs`). `storage` must hold our chain.
2010    /// Returns an empty `Vec` when the peer is already up to date.
2011    ///
2012    /// Mirrors Go: compute our own offer, find the intersection of the two chains, then gather every
2013    /// AUM from the intersection forward to our head (excluding the intersection AUM itself).
2014    pub fn missing_aums(
2015        &self,
2016        storage: &dyn AumStore,
2017        remote_offer: &SyncOffer,
2018        oldest: AumHash,
2019    ) -> Result<Vec<Aum>, TkaError> {
2020        let local_offer = self.sync_offer(storage, oldest)?;
2021        let isect = compute_sync_intersection(storage, &local_offer, remote_offer)?;
2022        if isect.up_to_date {
2023            return Ok(Vec::new());
2024        }
2025        let from = isect
2026            .head_intersection
2027            .or(isect.tail_intersection)
2028            .ok_or(TkaError::BadChain)?; // Go panics "unreachable"; we fail closed instead.
2029
2030        let Some(state) = compute_state_at(storage, MAX_SYNC_ITER, from)? else {
2031            return Err(TkaError::BadParent);
2032        };
2033        let mut out: Vec<Aum> = Vec::with_capacity(12);
2034        fast_forward(
2035            storage,
2036            MAX_SYNC_ITER,
2037            state,
2038            &mut |curs: &Aum, _: &mut ReplayState| -> Result<bool, TkaError> {
2039                // Gather every AUM from the intersection forward, excluding the intersection itself.
2040                if curs.hash() != from {
2041                    out.push(curs.clone());
2042                }
2043                Ok(false) // never stop early; walk to head (no more children)
2044            },
2045            None,
2046            None,
2047        )?;
2048        Ok(out)
2049    }
2050}
2051
2052/// Find where two chains meet (Go `computeSyncIntersection`). See [`Intersection`].
2053fn compute_sync_intersection(
2054    storage: &dyn AumStore,
2055    local_offer: &SyncOffer,
2056    remote_offer: &SyncOffer,
2057) -> Result<Intersection, TkaError> {
2058    // Simple case: identical heads → up to date.
2059    if remote_offer.head == local_offer.head {
2060        return Ok(Intersection {
2061            up_to_date: true,
2062            head_intersection: Some(local_offer.head),
2063            tail_intersection: None,
2064        });
2065    }
2066
2067    // Head intersection: if we hold the remote's head, walk back from our head looking for it. If
2068    // found, their head is an ancestor of ours and we have the AUMs that build on it.
2069    if storage.aum(&remote_offer.head).is_some() {
2070        let mut curs = local_offer.head;
2071        for _ in 0..MAX_SYNC_HEAD_INTERSECTION_ITER {
2072            let Some(parent) = storage.aum(&curs) else {
2073                break; // os.ErrNotExist
2074            };
2075            if parent.hash() == remote_offer.head {
2076                return Ok(Intersection {
2077                    up_to_date: false,
2078                    head_intersection: Some(parent.hash()),
2079                    tail_intersection: None,
2080                });
2081            }
2082            match parent.prev_aum_hash {
2083                Some(prev) => curs = prev,
2084                None => break,
2085            }
2086        }
2087    }
2088
2089    // Tail intersection: we don't recognise their head, but if one of the ancestors they offered is
2090    // on our chain, that's a starting point. Iterate in their order (newest-first) so we pick the
2091    // most-recent shared ancestor and send the fewest AUMs.
2092    for ancestor in &remote_offer.ancestors {
2093        let state = match compute_state_at(storage, MAX_SYNC_ITER, *ancestor)? {
2094            Some(s) => s,
2095            None => continue, // os.ErrNotExist: we don't have this ancestor; try the next
2096        };
2097        let (end, _) = fast_forward(
2098            storage,
2099            MAX_SYNC_ITER,
2100            state,
2101            &mut |_: &Aum, _: &mut ReplayState| Ok(false),
2102            None,
2103            Some(local_offer.head),
2104        )?;
2105        // fast_forward can stop early (no more children) before reaching the target, so re-check.
2106        if end.hash() == local_offer.head {
2107            return Ok(Intersection {
2108                up_to_date: false,
2109                head_intersection: None,
2110                tail_intersection: Some(*ancestor),
2111            });
2112        }
2113    }
2114
2115    Err(TkaError::BadChain) // ErrNoIntersection
2116}
2117
2118/// Verify a standard (RFC 8032, non-cofactored) Ed25519 signature.
2119fn verify_ed25519_std(public: &[u8], msg: &[u8], sig: &[u8]) -> Result<(), TkaError> {
2120    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
2121    let pk: [u8; 32] = public
2122        .try_into()
2123        .map_err(|_| TkaError::Decode("bad pubkey len"))?;
2124    let vk = VerifyingKey::from_bytes(&pk).map_err(|_| TkaError::Decode("bad ed25519 pubkey"))?;
2125    let sig: [u8; 64] = sig
2126        .try_into()
2127        .map_err(|_| TkaError::Decode("bad sig len"))?;
2128    vk.verify(msg, &Signature::from_bytes(&sig))
2129        .map_err(|_| TkaError::BadSignature)
2130}
2131
2132/// Verify a ZIP-215 (cofactored) Ed25519 signature, matching Go `ed25519consensus.Verify`.
2133fn verify_ed25519_zip215(public: &[u8], msg: &[u8], sig: &[u8]) -> Result<(), TkaError> {
2134    let pk: [u8; 32] = public
2135        .try_into()
2136        .map_err(|_| TkaError::Decode("bad pubkey len"))?;
2137    let vk = ed25519_zebra::VerificationKey::try_from(pk)
2138        .map_err(|_| TkaError::Decode("bad ed25519 pubkey"))?;
2139    let sig_bytes: [u8; 64] = sig
2140        .try_into()
2141        .map_err(|_| TkaError::Decode("bad sig len"))?;
2142    let sig = ed25519_zebra::Signature::from(sig_bytes);
2143    vk.verify(&sig, msg).map_err(|_| TkaError::BadSignature)
2144}
2145
2146/// Decode a [`NodeKeySignature`] from canonical CBOR. This is a minimal decoder for the exact map
2147/// shape Go emits (integer keys 1..=6); anything else is rejected (fail-closed).
2148fn decode_node_key_signature(buf: &[u8]) -> Result<NodeKeySignature, TkaError> {
2149    let (val, rest) = decode_value(buf, 0)?;
2150    if !rest.is_empty() {
2151        return Err(TkaError::Decode("trailing bytes after signature"));
2152    }
2153    node_key_signature_from_value(val, 0)
2154}
2155
2156fn node_key_signature_from_value(val: Value, depth: usize) -> Result<NodeKeySignature, TkaError> {
2157    if depth > MAX_SIG_NESTING_DEPTH {
2158        return Err(TkaError::Decode("nested signature too deep"));
2159    }
2160    let Value::IntMap(entries) = val else {
2161        return Err(TkaError::Decode("signature is not an int-keyed map"));
2162    };
2163    let mut sig_kind = None;
2164    let mut pubkey = Vec::new();
2165    let mut key_id = Vec::new();
2166    let mut signature = Vec::new();
2167    let mut nested = None;
2168    let mut wrapping_pubkey = Vec::new();
2169
2170    for (k, v) in entries {
2171        match k {
2172            1 => {
2173                let Value::Uint(n) = v else {
2174                    return Err(TkaError::Decode("sig kind not uint"));
2175                };
2176                sig_kind = Some(
2177                    SigKind::from_u8(
2178                        u8::try_from(n).map_err(|_| TkaError::Decode("sig kind range"))?,
2179                    )
2180                    .ok_or(TkaError::Decode("unknown sig kind"))?,
2181                );
2182            }
2183            2 => pubkey = expect_bytes(v)?,
2184            3 => key_id = expect_bytes(v)?,
2185            4 => signature = expect_bytes(v)?,
2186            5 => {
2187                nested = Some(alloc::boxed::Box::new(node_key_signature_from_value(
2188                    v,
2189                    depth + 1,
2190                )?))
2191            }
2192            6 => wrapping_pubkey = expect_bytes(v)?,
2193            _ => return Err(TkaError::Decode("unknown signature field")),
2194        }
2195    }
2196
2197    Ok(NodeKeySignature {
2198        sig_kind: sig_kind.ok_or(TkaError::Decode("signature missing kind"))?,
2199        pubkey,
2200        key_id,
2201        signature,
2202        nested,
2203        wrapping_pubkey,
2204    })
2205}
2206
2207fn expect_bytes(v: Value) -> Result<Vec<u8>, TkaError> {
2208    match v {
2209        Value::Bytes(b) => Ok(b),
2210        _ => Err(TkaError::Decode("expected byte string")),
2211    }
2212}
2213
2214/// A byte string, or an empty `Vec` for CBOR `null` — the decode inverse of [`bytes_or_null`]. Go's
2215/// `fxamacker/cbor` encodes a nil *non*-`omitempty` `[]byte` field as CBOR null (`0xf6`); on decode
2216/// that round-trips back to an empty `Vec` (the field's zero value). Any other CBOR type is rejected.
2217fn expect_bytes_or_null(v: Value) -> Result<Vec<u8>, TkaError> {
2218    match v {
2219        Value::Bytes(b) => Ok(b),
2220        Value::Null => Ok(Vec::new()),
2221        _ => Err(TkaError::Decode("expected byte string or null")),
2222    }
2223}
2224
2225fn expect_uint(v: Value) -> Result<u64, TkaError> {
2226    match v {
2227        Value::Uint(n) => Ok(n),
2228        _ => Err(TkaError::Decode("expected unsigned integer")),
2229    }
2230}
2231
2232/// Decode a `map[string]string` (`Meta`) from a [`Value::TextMap`]. Values must be text strings.
2233fn meta_from_value(
2234    v: Value,
2235) -> Result<Vec<(alloc::string::String, alloc::string::String)>, TkaError> {
2236    let Value::TextMap(entries) = v else {
2237        return Err(TkaError::Decode("meta is not a text-keyed map"));
2238    };
2239    let mut out = Vec::with_capacity(entries.len());
2240    for (k, val) in entries {
2241        let Value::Text(vbytes) = val else {
2242            return Err(TkaError::Decode("meta value not text"));
2243        };
2244        let key = alloc::string::String::from_utf8(k)
2245            .map_err(|_| TkaError::Decode("meta key not utf-8"))?;
2246        let value = alloc::string::String::from_utf8(vbytes)
2247            .map_err(|_| TkaError::Decode("meta value not utf-8"))?;
2248        out.push((key, value));
2249    }
2250    Ok(out)
2251}
2252
2253/// Decode a 32-byte [`AumHash`] from a CBOR byte string of exactly 32 bytes.
2254fn aum_hash_from_bytes(b: Vec<u8>) -> Result<AumHash, TkaError> {
2255    let arr: [u8; AUM_HASH_LEN] = b
2256        .try_into()
2257        .map_err(|_| TkaError::Decode("AUM hash not 32 bytes"))?;
2258    Ok(AumHash(arr))
2259}
2260
2261impl AumKey {
2262    /// Decode an [`AumKey`] from its CBOR value (Go `tka.Key`; keymap `kind`=1, `votes`=2,
2263    /// `public`=3, `meta`=12). The inverse of [`AumKey::to_cbor`]. Only `Key25519` (kind `1`) is
2264    /// supported (the sole [`KeyKind`] this fork models); any other kind is rejected (fail-closed).
2265    fn from_value(v: Value) -> Result<AumKey, TkaError> {
2266        let Value::IntMap(entries) = v else {
2267            return Err(TkaError::Decode("key is not an int-keyed map"));
2268        };
2269        let mut kind = None;
2270        let mut votes = None;
2271        let mut public = None;
2272        let mut meta = Vec::new();
2273        for (k, val) in entries {
2274            match k {
2275                1 => {
2276                    kind = Some(match expect_uint(val)? {
2277                        1 => KeyKind::Ed25519,
2278                        _ => return Err(TkaError::Decode("unsupported key kind")),
2279                    })
2280                }
2281                2 => {
2282                    votes = Some(
2283                        u32::try_from(expect_uint(val)?)
2284                            .map_err(|_| TkaError::Decode("key votes out of range"))?,
2285                    )
2286                }
2287                3 => public = Some(expect_bytes_or_null(val)?),
2288                12 => meta = meta_from_value(val)?,
2289                _ => return Err(TkaError::Decode("unknown key field")),
2290            }
2291        }
2292        Ok(AumKey {
2293            kind: kind.ok_or(TkaError::Decode("key missing kind"))?,
2294            votes: votes.ok_or(TkaError::Decode("key missing votes"))?,
2295            public: public.ok_or(TkaError::Decode("key missing public"))?,
2296            meta,
2297        })
2298    }
2299}
2300
2301impl AumState {
2302    /// Decode an [`AumState`] from its CBOR value (Go `tka.State`; keymap `last_aum_hash`=1,
2303    /// `disablement_values`=2, `keys`=3, `state_id1`=4, `state_id2`=5). The inverse of
2304    /// [`AumState::to_cbor`]: keys 1/2/3 are non-`omitempty`, so a nil one arrives as CBOR null
2305    /// (`None`); a present array arrives as `Some(vec)` (possibly empty). Keys 4/5 are `omitempty`,
2306    /// defaulting to 0 when absent.
2307    fn from_value(v: Value) -> Result<AumState, TkaError> {
2308        let Value::IntMap(entries) = v else {
2309            return Err(TkaError::Decode("state is not an int-keyed map"));
2310        };
2311        let mut state = AumState::default();
2312        for (k, val) in entries {
2313            match k {
2314                1 => {
2315                    state.last_aum_hash = match val {
2316                        Value::Null => None,
2317                        Value::Bytes(b) => Some(aum_hash_from_bytes(b)?),
2318                        _ => return Err(TkaError::Decode("last_aum_hash not bytes or null")),
2319                    }
2320                }
2321                2 => {
2322                    state.disablement_values = match val {
2323                        Value::Null => None,
2324                        Value::Array(items) => Some(
2325                            items
2326                                .into_iter()
2327                                .map(expect_bytes)
2328                                .collect::<Result<Vec<_>, _>>()?,
2329                        ),
2330                        _ => return Err(TkaError::Decode("disablement_values not array or null")),
2331                    }
2332                }
2333                3 => {
2334                    state.keys = match val {
2335                        Value::Null => None,
2336                        Value::Array(items) => Some(
2337                            items
2338                                .into_iter()
2339                                .map(AumKey::from_value)
2340                                .collect::<Result<Vec<_>, _>>()?,
2341                        ),
2342                        _ => return Err(TkaError::Decode("state keys not array or null")),
2343                    }
2344                }
2345                4 => state.state_id1 = expect_uint(val)?,
2346                5 => state.state_id2 = expect_uint(val)?,
2347                _ => return Err(TkaError::Decode("unknown state field")),
2348            }
2349        }
2350        Ok(state)
2351    }
2352}
2353
2354impl AumSignature {
2355    /// Decode an [`AumSignature`] from its CBOR value (Go `tkatype.Signature`; keymap `key_id`=1,
2356    /// `signature`=2, both non-`omitempty` → a nil one is CBOR null → empty `Vec`).
2357    fn from_value(v: Value) -> Result<AumSignature, TkaError> {
2358        let Value::IntMap(entries) = v else {
2359            return Err(TkaError::Decode("signature is not an int-keyed map"));
2360        };
2361        let mut key_id = None;
2362        let mut signature = None;
2363        for (k, val) in entries {
2364            match k {
2365                1 => key_id = Some(expect_bytes_or_null(val)?),
2366                2 => signature = Some(expect_bytes_or_null(val)?),
2367                _ => return Err(TkaError::Decode("unknown AUM signature field")),
2368            }
2369        }
2370        Ok(AumSignature {
2371            key_id: key_id.ok_or(TkaError::Decode("AUM signature missing key_id"))?,
2372            signature: signature.ok_or(TkaError::Decode("AUM signature missing signature"))?,
2373        })
2374    }
2375}
2376
2377impl Aum {
2378    /// Decode an [`Aum`] from its canonical CBOR serialization (the inverse of [`Aum::serialize`] /
2379    /// `Aum::to_cbor`). This is the acquisition primitive a sync/bootstrap path uses to turn the
2380    /// raw `MarshaledAUM` bytes control sends into an [`Aum`] before it is verified and replayed
2381    /// (Go `tka.AUM` CBOR unmarshal).
2382    ///
2383    /// The keymap (Go `cbor:"…,keyasint"`): `message_kind`=1 and `prev_aum_hash`=2 are
2384    /// non-`omitempty` (a nil prev arrives as CBOR null → `None`); `key`=3, `key_id`=4, `state`=5,
2385    /// `votes`=6, `meta`=7, `signatures`=23 are `omitempty` (absent ⇒ the field's zero value).
2386    ///
2387    /// Fail-closed: a trailing byte after the AUM, an unknown field key, a wrong value type, an
2388    /// unknown `message_kind`, or any malformed CBOR head all return [`TkaError::Decode`]. The
2389    /// decoder does NOT re-canonicalize or validate chain structure — that is the verifier's job; it
2390    /// only reconstructs the struct the bytes describe.
2391    ///
2392    /// # Errors
2393    ///
2394    /// Returns [`TkaError::Decode`] if `buf` is not exactly one canonical-shaped AUM CBOR map.
2395    pub fn from_cbor(buf: &[u8]) -> Result<Aum, TkaError> {
2396        let (val, rest) = decode_value(buf, 0)?;
2397        if !rest.is_empty() {
2398            return Err(TkaError::Decode("trailing bytes after AUM"));
2399        }
2400        let Value::IntMap(entries) = val else {
2401            return Err(TkaError::Decode("AUM is not an int-keyed map"));
2402        };
2403        let mut message_kind = None;
2404        let mut prev_aum_hash = None;
2405        let mut have_prev = false;
2406        let mut key = None;
2407        let mut key_id = Vec::new();
2408        let mut state = None;
2409        let mut votes = None;
2410        let mut meta = Vec::new();
2411        let mut signatures = Vec::new();
2412        for (k, v) in entries {
2413            match k {
2414                1 => {
2415                    message_kind = Some(
2416                        AumKind::from_u8(
2417                            u8::try_from(expect_uint(v)?)
2418                                .map_err(|_| TkaError::Decode("message kind out of range"))?,
2419                        )
2420                        .ok_or(TkaError::Decode("unknown AUM message kind"))?,
2421                    )
2422                }
2423                2 => {
2424                    have_prev = true;
2425                    prev_aum_hash = match v {
2426                        Value::Null => None,
2427                        Value::Bytes(b) => Some(aum_hash_from_bytes(b)?),
2428                        _ => return Err(TkaError::Decode("prev_aum_hash not bytes or null")),
2429                    }
2430                }
2431                3 => key = Some(AumKey::from_value(v)?),
2432                4 => key_id = expect_bytes_or_null(v)?,
2433                5 => state = Some(AumState::from_value(v)?),
2434                6 => {
2435                    votes = Some(
2436                        u32::try_from(expect_uint(v)?)
2437                            .map_err(|_| TkaError::Decode("votes out of range"))?,
2438                    )
2439                }
2440                7 => meta = meta_from_value(v)?,
2441                23 => {
2442                    let Value::Array(items) = v else {
2443                        return Err(TkaError::Decode("signatures not an array"));
2444                    };
2445                    signatures = items
2446                        .into_iter()
2447                        .map(AumSignature::from_value)
2448                        .collect::<Result<Vec<_>, _>>()?;
2449                }
2450                _ => return Err(TkaError::Decode("unknown AUM field")),
2451            }
2452        }
2453        // `message_kind` (1) and `prev_aum_hash` (2) are non-`omitempty`: both keys must be present
2454        // on the wire (the prev *value* may be null, but the key itself is always emitted).
2455        if !have_prev {
2456            return Err(TkaError::Decode("AUM missing prev_aum_hash"));
2457        }
2458        Ok(Aum {
2459            message_kind: message_kind.ok_or(TkaError::Decode("AUM missing message kind"))?,
2460            prev_aum_hash,
2461            key,
2462            key_id,
2463            state,
2464            votes,
2465            meta,
2466            signatures,
2467        })
2468    }
2469}
2470
2471/// Decode one CBOR value (the subset the encoder produces) from `buf`, returning the value and the
2472/// remaining bytes. Minimal — only the major types TKA uses.
2473fn decode_value(buf: &[u8], depth: usize) -> Result<(Value, &[u8]), TkaError> {
2474    // Bound generic CBOR container nesting so a deeply-nested array/map (even a non-signature one,
2475    // e.g. an AUM with nested arrays) cannot overflow the recursive decoder before shape validation
2476    // runs. Shared by the AUM and node-key-signature paths, so the message is kept neutral (the
2477    // signature-specific depth guard with its own message lives in `node_key_signature_from_value`).
2478    if depth > MAX_SIG_NESTING_DEPTH {
2479        return Err(TkaError::Decode("CBOR nesting too deep"));
2480    }
2481    let (major, arg, rest) = decode_head(buf)?;
2482    match major {
2483        0 => Ok((Value::Uint(arg), rest)),
2484        2 => {
2485            // `usize::try_from` rather than `as usize`: on a 32-bit target a `u64` length above
2486            // `usize::MAX` must fail closed, not silently truncate to a smaller in-bounds length.
2487            let len = usize::try_from(arg).map_err(|_| TkaError::Decode("byte string too long"))?;
2488            if rest.len() < len {
2489                return Err(TkaError::Decode("byte string truncated"));
2490            }
2491            Ok((Value::Bytes(rest[..len].to_vec()), &rest[len..]))
2492        }
2493        3 => {
2494            let len = usize::try_from(arg).map_err(|_| TkaError::Decode("text string too long"))?;
2495            if rest.len() < len {
2496                return Err(TkaError::Decode("text string truncated"));
2497            }
2498            Ok((Value::Text(rest[..len].to_vec()), &rest[len..]))
2499        }
2500        4 => {
2501            let mut items = Vec::new();
2502            let mut cur = rest;
2503            for _ in 0..arg {
2504                let (v, next) = decode_value(cur, depth + 1)?;
2505                items.push(v);
2506                cur = next;
2507            }
2508            Ok((Value::Array(items), cur))
2509        }
2510        5 => {
2511            // A CBOR map decodes to either an `IntMap` (unsigned-integer keys — the `keyasint`
2512            // structs: AUM, Key, State, signatures) or a `TextMap` (text-string keys — Go
2513            // `map[string]string` `Meta` fields). The variant is chosen by the FIRST key's major
2514            // type and every key must match it: TKA never emits a mixed-key map, so a key whose
2515            // type differs from the first is rejected (fail-closed). An empty map decodes to an
2516            // empty `IntMap` (matching the prior behavior; an empty `map[string]string` is
2517            // `omitempty`-dropped by Go, so an empty map on the wire is always a struct).
2518            decode_map(rest, arg, depth)
2519        }
2520        // Major type 7: only the `null` simple value (`0xf6`, argument 22) is accepted. Go's
2521        // `fxamacker/cbor` emits CBOR null for a nil *non*-`omitempty` byte/slice/pointer field —
2522        // an AUM's genesis `prev_aum_hash`, `AumSignature.{key_id,signature}`, and an `AumState`'s
2523        // `last_aum_hash`/`disablement_values`/`keys` (see the encoder's `Value::Null` arm). Any
2524        // other major-7 simple value or float (booleans, undefined, `f16`/`f32`/`f64`) is rejected:
2525        // TKA never emits them, so accepting them would only widen the attack surface. The
2526        // `NodeKeySignature` path is unaffected — its `expect_bytes` rejects `Value::Null`, so a
2527        // null where bytes are required still fails closed there.
2528        7 if arg == 22 => Ok((Value::Null, rest)),
2529        // Major types 1 (negative int) and 6 (tag), and any other major-7 value, are unsupported.
2530        _ => Err(TkaError::Decode("unsupported CBOR major type")),
2531    }
2532}
2533
2534/// Decode the `count` key/value pairs of a CBOR map (major type 5) from `buf`, producing either a
2535/// [`Value::IntMap`] (unsigned-integer keys) or a [`Value::TextMap`] (text-string keys). The key
2536/// type is fixed by the first pair; every subsequent key must use the same major type (TKA emits no
2537/// mixed-key maps). An empty map decodes to an empty `IntMap`. Duplicate keys are rejected
2538/// (fail-closed), matching the CTAP2 / Go "no duplicate map keys" rule. Map *key ordering is not
2539/// enforced* on decode: the verify path re-serializes canonically before hashing, so a non-canonical
2540/// input simply produces a different (still self-consistent) struct, never a hash that silently
2541/// matches Go's for different bytes.
2542fn decode_map(buf: &[u8], count: u64, depth: usize) -> Result<(Value, &[u8]), TkaError> {
2543    if count == 0 {
2544        return Ok((Value::IntMap(Vec::new()), buf));
2545    }
2546    // Peek the first key's major type to pick the map variant.
2547    let (first_major, ..) = decode_head(buf)?;
2548    match first_major {
2549        0 => {
2550            let mut entries: Vec<(u64, Value)> = Vec::new();
2551            let mut cur = buf;
2552            for _ in 0..count {
2553                let (k, next) = decode_head(cur).and_then(|(m, a, r)| {
2554                    if m == 0 {
2555                        Ok((a, r))
2556                    } else {
2557                        Err(TkaError::Decode("mixed map key types"))
2558                    }
2559                })?;
2560                let (v, next2) = decode_value(next, depth + 1)?;
2561                entries.push((k, v));
2562                cur = next2;
2563            }
2564            reject_duplicate_keys(entries.iter().map(|(k, _)| *k))?;
2565            Ok((Value::IntMap(entries), cur))
2566        }
2567        3 => {
2568            let mut entries: Vec<(Vec<u8>, Value)> = Vec::new();
2569            let mut cur = buf;
2570            for _ in 0..count {
2571                // Decode the text-string key via the shared value decoder so its length/truncation
2572                // checks apply uniformly, then require it to be `Value::Text`.
2573                let (key_val, next) = decode_value(cur, depth + 1)?;
2574                let Value::Text(k) = key_val else {
2575                    return Err(TkaError::Decode("mixed map key types"));
2576                };
2577                let (v, next2) = decode_value(next, depth + 1)?;
2578                entries.push((k, v));
2579                cur = next2;
2580            }
2581            reject_duplicate_keys(entries.iter().map(|(k, _)| k.clone()))?;
2582            Ok((Value::TextMap(entries), cur))
2583        }
2584        _ => Err(TkaError::Decode("map key not uint or text string")),
2585    }
2586}
2587
2588/// Reject a CBOR map with duplicate keys (CTAP2 / Go forbid them) in `O(n log n)` via a sort, rather
2589/// than the `O(n²)` per-insert linear scan a naive decoder uses. The map element count is
2590/// attacker-controlled (a CBOR head can claim a large count), so the quadratic form is a latent
2591/// super-linear CPU-DoS on a hostile control-plane blob; the sort keeps it linear-ish. Insertion
2592/// order of the map itself is preserved by the caller (the verify path re-serializes canonically
2593/// before hashing, so wire order never reaches a hash).
2594fn reject_duplicate_keys<K: Ord>(keys: impl Iterator<Item = K>) -> Result<(), TkaError> {
2595    let mut ks: Vec<K> = keys.collect();
2596    ks.sort_unstable();
2597    if ks.windows(2).any(|w| w[0] == w[1]) {
2598        return Err(TkaError::Decode("duplicate map key"));
2599    }
2600    Ok(())
2601}
2602
2603/// Decode a CBOR head: returns `(major, argument, rest)`.
2604fn decode_head(buf: &[u8]) -> Result<(u8, u64, &[u8]), TkaError> {
2605    let first = *buf.first().ok_or(TkaError::Decode("empty CBOR"))?;
2606    let major = first >> 5;
2607    let info = first & 0x1f;
2608    let rest = &buf[1..];
2609    let (arg, rest) = match info {
2610        n @ 0..=23 => (n as u64, rest),
2611        24 => {
2612            let b = *rest.first().ok_or(TkaError::Decode("truncated u8"))?;
2613            (b as u64, &rest[1..])
2614        }
2615        25 => {
2616            if rest.len() < 2 {
2617                return Err(TkaError::Decode("truncated u16"));
2618            }
2619            (u16::from_be_bytes([rest[0], rest[1]]) as u64, &rest[2..])
2620        }
2621        26 => {
2622            if rest.len() < 4 {
2623                return Err(TkaError::Decode("truncated u32"));
2624            }
2625            (
2626                u32::from_be_bytes([rest[0], rest[1], rest[2], rest[3]]) as u64,
2627                &rest[4..],
2628            )
2629        }
2630        27 => {
2631            if rest.len() < 8 {
2632                return Err(TkaError::Decode("truncated u64"));
2633            }
2634            let mut b = [0u8; 8];
2635            b.copy_from_slice(&rest[..8]);
2636            (u64::from_be_bytes(b), &rest[8..])
2637        }
2638        _ => return Err(TkaError::Decode("indefinite/reserved CBOR length")),
2639    };
2640    Ok((major, arg, rest))
2641}
2642
2643// ----- RFC 4648 base32 (standard alphabet, no padding) -----
2644
2645const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
2646
2647fn base32_encode_nopad(data: &[u8]) -> String {
2648    let mut out = String::new();
2649    let mut buffer: u32 = 0;
2650    let mut bits: u32 = 0;
2651    for &b in data {
2652        buffer = (buffer << 8) | b as u32;
2653        bits += 8;
2654        while bits >= 5 {
2655            bits -= 5;
2656            let idx = ((buffer >> bits) & 0x1f) as usize;
2657            out.push(BASE32_ALPHABET[idx] as char);
2658        }
2659    }
2660    if bits > 0 {
2661        let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
2662        out.push(BASE32_ALPHABET[idx] as char);
2663    }
2664    out
2665}
2666
2667fn base32_decode_nopad(text: &str) -> Option<Vec<u8>> {
2668    let mut buffer: u32 = 0;
2669    let mut bits: u32 = 0;
2670    let mut out = Vec::new();
2671    for c in text.chars() {
2672        let val = match c {
2673            'A'..='Z' => c as u32 - 'A' as u32,
2674            '2'..='7' => c as u32 - '2' as u32 + 26,
2675            _ => return None,
2676        };
2677        buffer = (buffer << 5) | val;
2678        bits += 5;
2679        if bits >= 8 {
2680            bits -= 8;
2681            out.push((buffer >> bits) as u8);
2682        }
2683    }
2684    Some(out)
2685}
2686
2687#[cfg(test)]
2688mod tests {
2689    use super::*;
2690
2691    #[test]
2692    fn base32_roundtrip_32_bytes() {
2693        let h = AumHash([0xABu8; 32]);
2694        let text = h.to_base32();
2695        let back = AumHash::from_base32(&text).unwrap();
2696        assert_eq!(h, back);
2697    }
2698
2699    #[test]
2700    fn base32_rejects_wrong_length() {
2701        // "AAAA" decodes to fewer than 32 bytes.
2702        assert!(AumHash::from_base32("AAAA").is_none());
2703        // Lowercase / invalid alphabet rejected.
2704        assert!(AumHash::from_base32("aaaa").is_none());
2705    }
2706
2707    #[test]
2708    fn base32_matches_known_vector() {
2709        // RFC 4648 base32 of "foobar" is "MZXW6YTBOI" (with padding "======"); no-pad drops the pad.
2710        assert_eq!(base32_encode_nopad(b"foobar"), "MZXW6YTBOI");
2711        assert_eq!(base32_decode_nopad("MZXW6YTBOI").unwrap(), b"foobar");
2712    }
2713
2714    #[test]
2715    fn credential_signature_cannot_authorize() {
2716        let auth = Authority::from_state(AumHash([0; 32]), State::default());
2717        let sig = NodeKeySignature {
2718            sig_kind: SigKind::Credential,
2719            pubkey: alloc::vec![1, 2, 3],
2720            key_id: alloc::vec![4, 5, 6],
2721            signature: alloc::vec![0; 64],
2722            nested: None,
2723            wrapping_pubkey: Vec::new(),
2724        };
2725        let cbor = sig.to_cbor(true).to_vec();
2726        let err = auth.node_key_authorized(&[1, 2, 3], &cbor).unwrap_err();
2727        assert_eq!(err, TkaError::CredentialCannotAuthorize);
2728    }
2729
2730    #[test]
2731    fn untrusted_key_denied() {
2732        // A direct signature whose key id is not in the (empty) trusted state.
2733        let auth = Authority::from_state(AumHash([0; 32]), State::default());
2734        let sig = NodeKeySignature {
2735            sig_kind: SigKind::Direct,
2736            pubkey: alloc::vec![9; 32],
2737            key_id: alloc::vec![7; 32],
2738            signature: alloc::vec![0; 64],
2739            nested: None,
2740            wrapping_pubkey: Vec::new(),
2741        };
2742        let cbor = sig.to_cbor(true).to_vec();
2743        let err = auth.node_key_authorized(&[9; 32], &cbor).unwrap_err();
2744        assert_eq!(err, TkaError::UntrustedKey);
2745    }
2746
2747    #[test]
2748    fn key_trusted_reflects_state() {
2749        let trusted_id = alloc::vec![5u8; 32];
2750        let auth = Authority::from_state(
2751            AumHash([0; 32]),
2752            State {
2753                keys: alloc::vec![Key {
2754                    kind: KeyKind::Ed25519,
2755                    votes: 1,
2756                    public: trusted_id.clone(),
2757                }],
2758            },
2759        );
2760        assert!(auth.key_trusted(&trusted_id), "the seeded key is trusted");
2761        assert!(
2762            !auth.key_trusted(&[9u8; 32]),
2763            "an unknown key id is not trusted"
2764        );
2765        // An empty-state authority trusts nothing.
2766        assert!(
2767            !Authority::from_state(AumHash([0; 32]), State::default()).key_trusted(&trusted_id)
2768        );
2769    }
2770
2771    #[test]
2772    fn direct_signature_verifies_end_to_end() {
2773        use ed25519_dalek::{Signer, SigningKey};
2774
2775        // A trusted Ed25519 key signs a node key directly.
2776        let signing = SigningKey::from_bytes(&[42u8; 32]);
2777        let trusted_pub = signing.verifying_key().to_bytes().to_vec();
2778        let node_key = alloc::vec![7u8; 32];
2779
2780        let trusted = Key {
2781            kind: KeyKind::Ed25519,
2782            votes: 1,
2783            public: trusted_pub.clone(),
2784        };
2785        let auth = Authority::from_state(
2786            AumHash([0; 32]),
2787            State {
2788                keys: alloc::vec![trusted],
2789            },
2790        );
2791
2792        // Build the signature, compute its sig-hash preimage, sign, then fill in the signature.
2793        let mut sig = NodeKeySignature {
2794            sig_kind: SigKind::Direct,
2795            pubkey: node_key.clone(),
2796            key_id: trusted_pub.clone(),
2797            signature: Vec::new(),
2798            nested: None,
2799            wrapping_pubkey: Vec::new(),
2800        };
2801        let sig_hash = sig.sig_hash();
2802        // NOTE: Go verifies Direct with ZIP-215; a standard ed25519-dalek signature is accepted by
2803        // ZIP-215 verification (ZIP-215 is a superset), so signing with dalek here is valid.
2804        sig.signature = signing.sign(&sig_hash).to_bytes().to_vec();
2805
2806        let cbor = sig.to_cbor(true).to_vec();
2807        assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
2808
2809        // A different node key must NOT be authorized by this signature.
2810        let other = alloc::vec![8u8; 32];
2811        assert_eq!(
2812            auth.node_key_authorized(&other, &cbor).unwrap_err(),
2813            TkaError::NodeKeyMismatch
2814        );
2815    }
2816
2817    /// Round-trips the public [`NodeKeySignature::sign_direct`] through our own verify path
2818    /// ([`Authority::node_key_authorized`]): a Direct signature produced by the signer is accepted for
2819    /// the node key it authorizes, rejected for any other (`NodeKeyMismatch`), and rejected when the
2820    /// signer is not a trusted key (`UntrustedKey`). This is the production-signer ↔ production-verifier
2821    /// drift pin for node-key signatures — the counterpart of `direct_signature_verifies_end_to_end`,
2822    /// which builds the signature by hand; here the signer builds it.
2823    #[test]
2824    fn sign_direct_round_trips_through_node_key_authorized() {
2825        use ed25519_dalek::SigningKey;
2826
2827        let signing = SigningKey::from_bytes(&[42u8; 32]);
2828        let trusted_pub = signing.verifying_key().to_bytes().to_vec();
2829        let node_key = alloc::vec![7u8; 32];
2830
2831        let auth = Authority::from_state(
2832            AumHash([0; 32]),
2833            State {
2834                keys: alloc::vec![Key {
2835                    kind: KeyKind::Ed25519,
2836                    votes: 1,
2837                    public: trusted_pub.clone(),
2838                }],
2839            },
2840        );
2841
2842        // The signer builds the whole signature (Direct kind, pubkey = node_key, key_id = signer pub).
2843        let sig = NodeKeySignature::sign_direct(&node_key, &signing);
2844        assert_eq!(sig.sig_kind, SigKind::Direct);
2845        assert_eq!(sig.pubkey, node_key, "authorizes the given node key");
2846        assert_eq!(
2847            sig.key_id, trusted_pub,
2848            "key_id is the signer's pubkey verbatim"
2849        );
2850        assert_eq!(sig.signature.len(), 64, "ed25519 signature is 64 bytes");
2851        assert!(
2852            sig.nested.is_none() && sig.wrapping_pubkey.is_empty(),
2853            "no rotation fields"
2854        );
2855
2856        let cbor = sig.to_cbor(true).to_vec();
2857        // Accepted for the node key it authorizes (dalek signature verified cofactored under ZIP-215).
2858        assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
2859        // Rejected for a different node key.
2860        assert_eq!(
2861            auth.node_key_authorized(&alloc::vec![8u8; 32], &cbor)
2862                .unwrap_err(),
2863            TkaError::NodeKeyMismatch
2864        );
2865
2866        // Signed by a key the authority does not trust → UntrustedKey.
2867        let untrusted = SigningKey::from_bytes(&[99u8; 32]);
2868        let untrusted_sig = NodeKeySignature::sign_direct(&node_key, &untrusted);
2869        assert_eq!(
2870            auth.node_key_authorized(&node_key, &untrusted_sig.to_cbor(true).to_vec())
2871                .unwrap_err(),
2872            TkaError::UntrustedKey
2873        );
2874
2875        // Fail-closed: flip a signature byte → BadSignature.
2876        let mut tampered = NodeKeySignature::sign_direct(&node_key, &signing);
2877        tampered.signature[0] ^= 0x01;
2878        assert_eq!(
2879            auth.node_key_authorized(&node_key, &tampered.to_cbor(true).to_vec())
2880                .unwrap_err(),
2881            TkaError::BadSignature
2882        );
2883    }
2884
2885    /// [`NodeKeySignature::serialize`] is the public raw-CBOR form a node submits to control (the
2886    /// `/machine/tka/sign` body). It must equal the (private) `to_cbor(true)` serialization and
2887    /// round-trip through the decoder back to an equal struct — i.e. it is the decoder's inverse.
2888    #[test]
2889    fn node_key_signature_serialize_round_trips() {
2890        use ed25519_dalek::SigningKey;
2891
2892        let signing = SigningKey::from_bytes(&[42u8; 32]);
2893        let node_key = alloc::vec![7u8; 32];
2894        let sig = NodeKeySignature::sign_direct(&node_key, &signing);
2895
2896        // `serialize()` == `to_cbor(true).to_vec()` (the with-signature form).
2897        let bytes = sig.serialize();
2898        assert_eq!(bytes, sig.to_cbor(true).to_vec());
2899
2900        // Round-trips through the decoder to an equal struct (serialize is the decoder's inverse).
2901        let decoded = decode_node_key_signature(&bytes).expect("serialize output must decode");
2902        assert_eq!(decoded, sig);
2903
2904        // And the serialized bytes are exactly what a trusted authority accepts for this node key.
2905        let auth = Authority::from_state(
2906            AumHash([0; 32]),
2907            State {
2908                keys: alloc::vec![Key {
2909                    kind: KeyKind::Ed25519,
2910                    votes: 1,
2911                    public: signing.verifying_key().to_bytes().to_vec(),
2912                }],
2913            },
2914        );
2915        assert!(auth.node_key_authorized(&node_key, &bytes).is_ok());
2916    }
2917
2918    #[test]
2919    fn tampered_signature_denied() {
2920        use ed25519_dalek::{Signer, SigningKey};
2921
2922        let signing = SigningKey::from_bytes(&[42u8; 32]);
2923        let trusted_pub = signing.verifying_key().to_bytes().to_vec();
2924        let node_key = alloc::vec![7u8; 32];
2925        let auth = Authority::from_state(
2926            AumHash([0; 32]),
2927            State {
2928                keys: alloc::vec![Key {
2929                    kind: KeyKind::Ed25519,
2930                    votes: 1,
2931                    public: trusted_pub.clone(),
2932                }],
2933            },
2934        );
2935        let mut sig = NodeKeySignature {
2936            sig_kind: SigKind::Direct,
2937            pubkey: node_key.clone(),
2938            key_id: trusted_pub,
2939            signature: Vec::new(),
2940            nested: None,
2941            wrapping_pubkey: Vec::new(),
2942        };
2943        let sig_hash = sig.sig_hash();
2944        let mut sigbytes = signing.sign(&sig_hash).to_bytes();
2945        sigbytes[0] ^= 0xff; // tamper
2946        sig.signature = sigbytes.to_vec();
2947
2948        let cbor = sig.to_cbor(true).to_vec();
2949        assert_eq!(
2950            auth.node_key_authorized(&node_key, &cbor).unwrap_err(),
2951            TkaError::BadSignature
2952        );
2953    }
2954
2955    #[test]
2956    fn head_matches_check() {
2957        let h = AumHash([5u8; 32]);
2958        let auth = Authority::from_state(h, State::default());
2959        assert!(auth.head_matches(&h));
2960        assert!(!auth.head_matches(&AumHash([6u8; 32])));
2961    }
2962
2963    // ----- Fix 1: depth cap on attacker-controlled nesting -----
2964
2965    #[test]
2966    fn deeply_nested_signature_rejected_without_overflow() {
2967        // Wrap a NodeKeySignature inside `nested` far past MAX_SIG_NESTING_DEPTH. This is cheap to
2968        // construct (a few bytes per level) and would overflow an unbounded recursive decoder. The
2969        // decoder must reject it as a Decode error — never panic / stack-overflow.
2970        let mut sig = NodeKeySignature {
2971            sig_kind: SigKind::Direct,
2972            pubkey: alloc::vec![1u8; 32],
2973            key_id: alloc::vec![2u8; 32],
2974            signature: alloc::vec![3u8; 64],
2975            nested: None,
2976            wrapping_pubkey: Vec::new(),
2977        };
2978        for _ in 0..(MAX_SIG_NESTING_DEPTH + 8) {
2979            sig = NodeKeySignature {
2980                sig_kind: SigKind::Rotation,
2981                pubkey: alloc::vec![1u8; 32],
2982                key_id: Vec::new(),
2983                signature: alloc::vec![3u8; 64],
2984                nested: Some(alloc::boxed::Box::new(sig)),
2985                wrapping_pubkey: alloc::vec![1u8; 32],
2986            };
2987        }
2988        let cbor = sig.to_cbor(true).to_vec();
2989        let err = decode_node_key_signature(&cbor).unwrap_err();
2990        // The shared generic-container depth guard in `decode_value` trips first (the CBOR is
2991        // nested past the cap before the signature-shape walk runs), so the neutral message.
2992        assert_eq!(err, TkaError::Decode("CBOR nesting too deep"));
2993    }
2994
2995    // ----- Fix 5: duplicate CBOR map keys rejected -----
2996
2997    #[test]
2998    fn duplicate_map_key_rejected() {
2999        // Hand-craft a CBOR map with key 1 repeated: map(2) { 1:0, 1:1 } => 0xa2 01 00 01 01.
3000        let blob = [0xa2u8, 0x01, 0x00, 0x01, 0x01];
3001        let err = decode_node_key_signature(&blob).unwrap_err();
3002        assert_eq!(err, TkaError::Decode("duplicate map key"));
3003    }
3004
3005    // ----- Fix 3: rotation-chain happy path + ZIP-215/std split -----
3006
3007    // ZIP-215 vs standard ed25519 in TKA, and why this crate carries BOTH verifiers:
3008    //
3009    //   * Direct/Credential signatures are verified with `verify_ed25519_zip215` (ed25519-zebra),
3010    //     matching Go `ed25519consensus.Verify` — the *cofactored* ZIP-215 rule the TKA leaf
3011    //     signatures are produced under. ZIP-215 is a strict superset of RFC 8032: any standard
3012    //     (dalek) signature is accepted by it, which is why the tests below can sign leaves with
3013    //     dalek and still verify under zebra.
3014    //   * The outer rotation WRAP signature is verified with `verify_ed25519_std` (ed25519-dalek),
3015    //     matching Go's plain `ed25519.Verify` for the rotation wrap. Collapsing these two
3016    //     verifiers into one would silently diverge from Go on the wire — hence both deps are kept
3017    //     (see Cargo.toml comment).
3018    #[test]
3019    fn rotation_chain_verifies_end_to_end() {
3020        use ed25519_dalek::{Signer, SigningKey};
3021
3022        // Trusted key signs the inner Direct over the wrapping (pivot) pubkey.
3023        let trusted = SigningKey::from_bytes(&[7u8; 32]);
3024        let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
3025
3026        // The rotation pivot: a fresh keypair whose public key the inner Direct authorizes and
3027        // whose private key signs the outer rotation wrap.
3028        let wrapping = SigningKey::from_bytes(&[9u8; 32]);
3029        let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
3030
3031        let node_key = alloc::vec![5u8; 32];
3032
3033        let auth = Authority::from_state(
3034            AumHash([0; 32]),
3035            State {
3036                keys: alloc::vec![Key {
3037                    kind: KeyKind::Ed25519,
3038                    votes: 1,
3039                    public: trusted_pub.clone(),
3040                }],
3041            },
3042        );
3043
3044        // Inner Direct: trusted key authorizes the wrapping pubkey. Verified ZIP-215, so a dalek
3045        // signature is accepted.
3046        let mut inner = NodeKeySignature {
3047            sig_kind: SigKind::Direct,
3048            pubkey: wrapping_pub.clone(),
3049            key_id: trusted_pub.clone(),
3050            signature: Vec::new(),
3051            nested: None,
3052            wrapping_pubkey: wrapping_pub.clone(),
3053        };
3054        let inner_hash = inner.sig_hash();
3055        inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
3056
3057        // Outer Rotation: signs the node key with the wrapping key (verified STANDARD ed25519).
3058        let mut outer = NodeKeySignature {
3059            sig_kind: SigKind::Rotation,
3060            pubkey: node_key.clone(),
3061            key_id: Vec::new(),
3062            signature: Vec::new(),
3063            nested: Some(alloc::boxed::Box::new(inner)),
3064            wrapping_pubkey: Vec::new(),
3065        };
3066        let outer_hash = outer.sig_hash();
3067        outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
3068
3069        let cbor = outer.to_cbor(true).to_vec();
3070        assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
3071
3072        // A tampered rotation-wrap signature must be rejected by the STANDARD ed25519 verifier.
3073        let mut tampered = outer.clone();
3074        let mut sb = tampered.signature.clone();
3075        sb[0] ^= 0xff;
3076        tampered.signature = sb;
3077        let cbor_bad = tampered.to_cbor(true).to_vec();
3078        assert_eq!(
3079            auth.node_key_authorized(&node_key, &cbor_bad).unwrap_err(),
3080            TkaError::BadSignature
3081        );
3082    }
3083
3084    // ----- tsr-358: nested-Credential `pubkey` is UNUSED (Go parity) -----
3085
3086    /// A rotation wrapping a nested **Credential** must verify regardless of the credential's
3087    /// `pubkey` field — Go's `SigCredential` "certifies an indirection key rather than a node key,
3088    /// so there's no need to check the node key", and `verifySignature` adds NO `nested.pubkey ==
3089    /// wrappingPublic` bind. The pre-fix code rejected the real Go shape (empty credential `pubkey`)
3090    /// and only accepted a fork-invented `pubkey == wrapping_pub` construction — a deny-direction
3091    /// consensus split (legitimate credential-provisioned peers wrongly denied under enforce). This
3092    /// pins the Go behavior: empty `pubkey` accepted, and an arbitrary (ignored) `pubkey` also
3093    /// accepted; security comes purely from the two signatures verifying, not from the field.
3094    #[test]
3095    fn rotation_nested_credential_pubkey_is_unused() {
3096        use ed25519_dalek::{Signer, SigningKey};
3097
3098        let trusted = SigningKey::from_bytes(&[11u8; 32]);
3099        let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
3100        let wrapping = SigningKey::from_bytes(&[13u8; 32]);
3101        let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
3102        let node_key = alloc::vec![6u8; 32];
3103
3104        let auth = Authority::from_state(
3105            AumHash([0; 32]),
3106            State {
3107                keys: alloc::vec![Key {
3108                    kind: KeyKind::Ed25519,
3109                    votes: 1,
3110                    public: trusted_pub.clone(),
3111                }],
3112            },
3113        );
3114
3115        // Build a rotation wrapping a nested Credential whose `pubkey` is `cred_pubkey`. The
3116        // credential is signed by the trusted key over its own sig-hash; the credential's
3117        // `wrapping_pubkey` is the rotation pivot the outer signature is verified against.
3118        let build = |cred_pubkey: Vec<u8>| -> Vec<u8> {
3119            let mut inner = NodeKeySignature {
3120                sig_kind: SigKind::Credential,
3121                pubkey: cred_pubkey,
3122                key_id: trusted_pub.clone(),
3123                signature: Vec::new(),
3124                nested: None,
3125                wrapping_pubkey: wrapping_pub.clone(),
3126            };
3127            let inner_hash = inner.sig_hash();
3128            inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
3129
3130            let mut outer = NodeKeySignature {
3131                sig_kind: SigKind::Rotation,
3132                pubkey: node_key.clone(),
3133                key_id: Vec::new(),
3134                signature: Vec::new(),
3135                nested: Some(alloc::boxed::Box::new(inner)),
3136                wrapping_pubkey: Vec::new(),
3137            };
3138            let outer_hash = outer.sig_hash();
3139            outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
3140            outer.to_cbor(true).to_vec()
3141        };
3142
3143        // The real Go shape: a SigCredential leaves `Pubkey` EMPTY. Must be accepted.
3144        let cbor_empty = build(Vec::new());
3145        assert!(
3146            auth.node_key_authorized(&node_key, &cbor_empty).is_ok(),
3147            "a credential with empty pubkey (the Go shape) must verify"
3148        );
3149
3150        // An arbitrary credential `pubkey` is IGNORED (Go never checks it) — also accepted.
3151        let cbor_arbitrary = build(alloc::vec![0xaau8; 32]);
3152        assert!(
3153            auth.node_key_authorized(&node_key, &cbor_arbitrary).is_ok(),
3154            "a credential's pubkey is unused; an arbitrary value must not change the verdict"
3155        );
3156
3157        // Sanity: tampering the OUTER rotation signature is still rejected (the real security gate).
3158        let mut outer_bad_cbor = {
3159            let mut inner = NodeKeySignature {
3160                sig_kind: SigKind::Credential,
3161                pubkey: Vec::new(),
3162                key_id: trusted_pub.clone(),
3163                signature: Vec::new(),
3164                nested: None,
3165                wrapping_pubkey: wrapping_pub.clone(),
3166            };
3167            let ih = inner.sig_hash();
3168            inner.signature = trusted.sign(&ih).to_bytes().to_vec();
3169            let mut outer = NodeKeySignature {
3170                sig_kind: SigKind::Rotation,
3171                pubkey: node_key.clone(),
3172                key_id: Vec::new(),
3173                signature: Vec::new(),
3174                nested: Some(alloc::boxed::Box::new(inner)),
3175                wrapping_pubkey: Vec::new(),
3176            };
3177            let oh = outer.sig_hash();
3178            let mut sig = wrapping.sign(&oh).to_bytes().to_vec();
3179            sig[0] ^= 0xff;
3180            outer.signature = sig;
3181            outer.to_cbor(true).to_vec()
3182        };
3183        assert_eq!(
3184            auth.node_key_authorized(&node_key, &outer_bad_cbor)
3185                .unwrap_err(),
3186            TkaError::BadSignature,
3187            "a tampered rotation-wrap signature must still be rejected"
3188        );
3189        outer_bad_cbor.clear();
3190    }
3191
3192    /// Multi-level rotation: an intermediate rotation layer omits its own `wrapping_pubkey`, so the
3193    /// outer signature's verify key must be resolved by RECURSING (`wrapping_public`) into the
3194    /// inner-most layer that defines one — Go `NodeKeySignature.wrappingPublic`. The pre-fix code
3195    /// read `nested.wrapping_pubkey` directly and rejected this with "wrapping pubkey wrong length"
3196    /// (the second deny-direction consensus split).
3197    #[test]
3198    fn multi_level_rotation_resolves_wrapping_key_by_recursion() {
3199        use ed25519_dalek::{Signer, SigningKey};
3200
3201        let trusted = SigningKey::from_bytes(&[21u8; 32]);
3202        let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
3203        // The single rotation pivot key, carried only on the INNERMOST signature.
3204        let pivot = SigningKey::from_bytes(&[23u8; 32]);
3205        let pivot_pub = pivot.verifying_key().to_bytes().to_vec();
3206        let node_key = alloc::vec![7u8; 32];
3207
3208        let auth = Authority::from_state(
3209            AumHash([0; 32]),
3210            State {
3211                keys: alloc::vec![Key {
3212                    kind: KeyKind::Ed25519,
3213                    votes: 1,
3214                    public: trusted_pub.clone(),
3215                }],
3216            },
3217        );
3218
3219        // Innermost: a Direct signature by the trusted key, carrying the pivot as its wrapping key.
3220        let mut inner = NodeKeySignature {
3221            sig_kind: SigKind::Direct,
3222            pubkey: pivot_pub.clone(),
3223            key_id: trusted_pub.clone(),
3224            signature: Vec::new(),
3225            nested: None,
3226            wrapping_pubkey: pivot_pub.clone(),
3227        };
3228        let inner_hash = inner.sig_hash();
3229        inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
3230
3231        // Middle rotation: OMITS its own wrapping_pubkey (empty) — wrapping_public must recurse to
3232        // `inner`'s pivot. Its outer-of-inner signature is verified under the pivot key.
3233        let mut middle = NodeKeySignature {
3234            sig_kind: SigKind::Rotation,
3235            pubkey: pivot_pub.clone(),
3236            key_id: Vec::new(),
3237            signature: Vec::new(),
3238            nested: Some(alloc::boxed::Box::new(inner)),
3239            wrapping_pubkey: Vec::new(),
3240        };
3241        let middle_hash = middle.sig_hash();
3242        middle.signature = pivot.sign(&middle_hash).to_bytes().to_vec();
3243
3244        // Outer rotation over `middle`: also resolves its verify key by recursing to the pivot.
3245        let mut outer = NodeKeySignature {
3246            sig_kind: SigKind::Rotation,
3247            pubkey: node_key.clone(),
3248            key_id: Vec::new(),
3249            signature: Vec::new(),
3250            nested: Some(alloc::boxed::Box::new(middle)),
3251            wrapping_pubkey: Vec::new(),
3252        };
3253        let outer_hash = outer.sig_hash();
3254        outer.signature = pivot.sign(&outer_hash).to_bytes().to_vec();
3255
3256        let cbor = outer.to_cbor(true).to_vec();
3257        assert!(
3258            auth.node_key_authorized(&node_key, &cbor).is_ok(),
3259            "a multi-level rotation with an intermediate omitting wrapping_pubkey must verify via recursion"
3260        );
3261    }
3262
3263    // ----- CTAP2-CBOR byte-exactness FROZEN regression vector -----
3264
3265    /// A small hex helper for embedding captured bytes in a failure message.
3266    fn hex(bytes: &[u8]) -> String {
3267        let mut s = String::new();
3268        for b in bytes {
3269            s.push_str(&alloc::format!("{b:02x}"));
3270        }
3271        s
3272    }
3273
3274    /// FROZEN CTAP2-CBOR byte-exactness vector for the wire/signing serialization.
3275    ///
3276    /// The crate docs (and the `cbor` module) state the CTAP2-canonical CBOR encoding is asserted by
3277    /// construction but NOT cross-validated against Go's `fxamacker/cbor` (CTAP2 mode) in this fork.
3278    /// The existing TKA tests build a signature, sign it, and verify round-trip — so they would all
3279    /// still pass if the canonical encoding silently changed (int-map key ordering, smallest-int
3280    /// rule, omitempty), because both sides of the round-trip use the same encoder. That class of
3281    /// change would, however, break wire-compat with a live Go TKA.
3282    ///
3283    /// This pins the EXACT bytes for a fixed `NodeKeySignature` (Direct, deterministic key material):
3284    /// the full `to_cbor(true)` serialization, the `to_cbor(false)` SigHash preimage, the resulting
3285    /// `sig_hash` (BLAKE2s-256 of the preimage), and the `aum_hash` over the full serialization. ANY
3286    /// accidental change to canonical-CBOR encoding or the BLAKE2s digest breaks this test.
3287    ///
3288    /// NOTE: this is a regression-FREEZE vector captured from the current encoder, NOT a Go-sourced
3289    /// cross-vector. It should be replaced with a real `fxamacker/cbor` CTAP2 vector (the same
3290    /// `NodeKeySignature` encoded by a live Go `tka`) once one can be captured.
3291    #[test]
3292    fn node_key_signature_cbor_frozen_vector() {
3293        // Deterministic, fixed key material — NOT random. byte i = i, so the bytes are obvious.
3294        let pubkey: Vec<u8> = (0u8..32).collect();
3295        let key_id: Vec<u8> = (32u8..64).collect();
3296        let signature: Vec<u8> = (64u8..128).collect();
3297
3298        let sig = NodeKeySignature {
3299            sig_kind: SigKind::Direct,
3300            pubkey,
3301            key_id,
3302            signature,
3303            nested: None,
3304            wrapping_pubkey: Vec::new(), // empty -> omitted (omitempty), key 6 must NOT appear
3305        };
3306
3307        // 1. Full serialization (include_signature = true): keys 1,2,3,4 present, 5/6 omitted.
3308        let full = sig.to_cbor(true).to_vec();
3309        const EXPECTED_FULL: &[u8] = &[
3310            0xa4, 0x01, 0x01, 0x02, 0x58, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
3311            0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
3312            0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x03, 0x58, 0x20, 0x20,
3313            0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e,
3314            0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
3315            0x3d, 0x3e, 0x3f, 0x04, 0x58, 0x40, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47,
3316            0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55,
3317            0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, 0x61, 0x62, 0x63,
3318            0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71,
3319            0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f,
3320        ];
3321        assert_eq!(
3322            full,
3323            EXPECTED_FULL,
3324            "full CBOR serialization changed (canonical-CBOR encoding drift). actual: {}",
3325            hex(&full)
3326        );
3327
3328        // 2. SigHash preimage (include_signature = false): key 4 (signature) omitted.
3329        let preimage = sig.to_cbor(false).to_vec();
3330        const EXPECTED_PREIMAGE: &[u8] = &[
3331            0xa3, 0x01, 0x01, 0x02, 0x58, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
3332            0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
3333            0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x03, 0x58, 0x20, 0x20,
3334            0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e,
3335            0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
3336            0x3d, 0x3e, 0x3f,
3337        ];
3338        assert_eq!(
3339            preimage,
3340            EXPECTED_PREIMAGE,
3341            "SigHash preimage CBOR changed. actual: {}",
3342            hex(&preimage)
3343        );
3344
3345        // 3. sig_hash = BLAKE2s-256(preimage) — pinned.
3346        let sig_hash = sig.sig_hash();
3347        const EXPECTED_SIG_HASH: [u8; AUM_HASH_LEN] = [
3348            0x22, 0x6f, 0x9c, 0xbc, 0x63, 0x73, 0x92, 0x75, 0x2e, 0x0e, 0xb1, 0x32, 0x9c, 0xc4,
3349            0x99, 0x07, 0x01, 0x4a, 0xb6, 0x4f, 0x8e, 0x5d, 0x82, 0x85, 0xc2, 0x91, 0x42, 0x62,
3350            0xf6, 0xa6, 0xa8, 0x33,
3351        ];
3352        assert_eq!(
3353            sig_hash,
3354            EXPECTED_SIG_HASH,
3355            "sig_hash (BLAKE2s-256 of preimage) changed. actual: {}",
3356            hex(&sig_hash)
3357        );
3358
3359        // 4. aum_hash over the full serialization — pinned (exercises the public `aum_hash` helper
3360        //    + BLAKE2s digest over a frozen input).
3361        let aum = aum_hash(&full);
3362        const EXPECTED_AUM_HASH: [u8; AUM_HASH_LEN] = [
3363            0xa4, 0x40, 0x71, 0xa3, 0x7a, 0xbf, 0x80, 0x92, 0xd6, 0xff, 0x23, 0x84, 0xb2, 0xb0,
3364            0xa3, 0x50, 0xc7, 0xcb, 0x48, 0x41, 0xed, 0x68, 0x99, 0x62, 0x41, 0x7c, 0xd4, 0x23,
3365            0x68, 0xdc, 0x72, 0x49,
3366        ];
3367        assert_eq!(
3368            aum.0,
3369            EXPECTED_AUM_HASH,
3370            "aum_hash over full serialization changed. actual: {}",
3371            hex(&aum.0)
3372        );
3373    }
3374
3375    // ----- ed25519-speccheck KAT: dual-verifier (dalek std vs zebra ZIP-215) -----
3376
3377    /// Decode an ASCII hex string to bytes. Panics on malformed input (test-only).
3378    fn unhex(s: &str) -> Vec<u8> {
3379        assert!(s.len().is_multiple_of(2), "odd hex length");
3380        let nib = |c: u8| -> u8 {
3381            match c {
3382                b'0'..=b'9' => c - b'0',
3383                b'a'..=b'f' => c - b'a' + 10,
3384                b'A'..=b'F' => c - b'A' + 10,
3385                _ => panic!("bad hex nibble"),
3386            }
3387        };
3388        let b = s.as_bytes();
3389        let mut out = Vec::with_capacity(s.len() / 2);
3390        let mut i = 0;
3391        while i < b.len() {
3392            out.push((nib(b[i]) << 4) | nib(b[i + 1]));
3393            i += 2;
3394        }
3395        out
3396    }
3397
3398    /// The 12 adversarial Ed25519 vectors from `novifinancial/ed25519-speccheck`.
3399    ///
3400    /// Provenance: `cases.json` at commit `65519336fda78a3d016e947df6d82848aca0c9da`
3401    /// (<https://github.com/novifinancial/ed25519-speccheck/blob/main/cases.json>), the canonical
3402    /// generated vectors backing the "Taming the many EdDSAs" paper (IACR 2020/1244, Table 6c).
3403    /// The hex below is copied byte-for-byte from that file; the `message` field is itself hex
3404    /// (the speccheck driver hex-decodes it before verifying), so we decode it the same way.
3405    ///
3406    /// Tuple layout: `(message_hex, pubkey_hex, signature_hex)`.
3407    const SPECCHECK_VECTORS: [(&str, &str, &str); 12] = [
3408        // 0: S = 0; both A and R small-order.
3409        (
3410            "8c93255d71dcab10e8f379c26200f3c7bd5f09d9bc3068d3ef4edeb4853022b6",
3411            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
3412            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a0000000000000000000000000000000000000000000000000000000000000000",
3413        ),
3414        // 1: 0 < S < L; small A only.
3415        (
3416            "9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79",
3417            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
3418            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43a5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
3419        ),
3420        // 2: 0 < S < L; small R only.
3421        (
3422            "aebf3f2601a0c8c5d39cc7d8911642f740b78168218da8471772b35f9d35b9ab",
3423            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
3424            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa8c4bd45aecaca5b24fb97bc10ac27ac8751a7dfe1baff8b953ec9f5833ca260e",
3425        ),
3426        // 3: A and R mixed-order; passes both (unless full-order checked).
3427        (
3428            "9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79",
3429            "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
3430            "9046a64750444938de19f227bb80485e92b83fdb4b6506c160484c016cc1852f87909e14428a7a1d62e9f22f3d3ad7802db02eb2e688b6c52fcd6648a98bd009",
3431        ),
3432        // 4: A and R mixed; passes cofactored, FAILS cofactorless — the cofactored discriminator.
3433        (
3434            "e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c",
3435            "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
3436            "160a1cb0dc9c0258cd0a7d23e94d8fa878bcb1925f2c64246b2dee1796bed5125ec6bc982a269b723e0668e540911a9a6a58921d6925e434ab10aa7940551a09",
3437        ),
3438        // 5: A mixed, R order L; "fails cofactored iff (8h) prereduced".
3439        (
3440            "e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c",
3441            "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
3442            "21122a84e0b5fca4052f5b1235c80a537878b38f3142356b2c2384ebad4668b7e40bc836dac0f71076f9abe3a53f9c03c1ceeeddb658d0030494ace586687405",
3443        ),
3444        // 6: S > L (out of bounds) — malleability vector; std verifier MUST reject.
3445        (
3446            "85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40",
3447            "442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623",
3448            "e96f66be976d82e60150baecff9906684aebb1ef181f67a7189ac78ea23b6c0e547f7690a0e2ddcd04d87dbc3490dc19b3b3052f7ff0538cb68afb369ba3a514",
3449        ),
3450        // 7: S >> L (no canonical serialization with null high bit) — std verifier MUST reject.
3451        (
3452            "85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40",
3453            "442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623",
3454            "8ce5b96c8f26d0ab6c47958c9e68b937104cd36e13c33566acd2fe8d38aa19427e71f98a473474f2f13f06f97c20d58cc3f54b8bd0d272f42b695dd7e89a8c22",
3455        ),
3456        // 8: 0 < S < L; non-canonical R, reduced for hash.
3457        (
3458            "9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41",
3459            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
3460            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff03be9678ac102edcd92b0210bb34d7428d12ffc5df5f37e359941266a4e35f0f",
3461        ),
3462        // 9: 0 < S < L; non-canonical R, NOT reduced for hash.
3463        (
3464            "9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41",
3465            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
3466            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffca8c5b64cd208982aa38d4936621a4775aa233aa0505711d8fdcfdaa943d4908",
3467        ),
3468        // 10: 0 < S < L; non-canonical A, reduced for hash.
3469        (
3470            "e96b7021eb39c1a163b6da4e3093dcd3f21387da4cc4572be588fafae23c155b",
3471            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
3472            "a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
3473        ),
3474        // 11: 0 < S < L; non-canonical A, NOT reduced for hash.
3475        (
3476            "39a591f5321bbe07fd5a23dc2f39d025d74526615746727ceefd6e82ae65c06f",
3477            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
3478            "a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
3479        ),
3480    ];
3481
3482    /// Accept(`true`)/reject(`false`) matrix for [`SPECCHECK_VECTORS`] (vectors 0..=11), the SINGLE
3483    /// source of truth shared by BOTH `ed25519_speccheck_dual_verifier_kat` (which asserts the pinned
3484    /// Rust crates produce it) and `ed25519_dual_verifier_matches_go_verdicts` (which asserts Go's
3485    /// real verifiers produce the same matrix). Sharing one const is deliberate: it makes the
3486    /// "crate-observed" and "Go-pinned" expectations PHYSICALLY THE SAME bytes, so a dependency bump
3487    /// that changes crate behavior cannot be silenced by editing the crate-side array to match the
3488    /// new behavior while leaving the Go-side untouched — there is only one array, and changing it
3489    /// re-asserts BOTH the crates AND Go.
3490    ///
3491    /// REGENERATION CONTRACT — read before touching these arrays. They are pinned to `ed25519-dalek
3492    /// 2.2.0` (std, cofactorless) == Go `crypto/ed25519.Verify`, and `ed25519-zebra 4.2.0` (ZIP-215,
3493    /// cofactored) == Go `github.com/hdevalence/ed25519consensus v0.2.0` (toolchain go1.26.4),
3494    /// cross-validated by the generator at `tests/vectors/gen/zip215`. If a `cargo update` bumps
3495    /// `ed25519-dalek` or `ed25519-zebra` and a verdict on any vector changes (most plausibly the
3496    /// non-canonical cases 8..=11), you MUST RE-RUN that Go generator and confirm Go's verdicts STILL
3497    /// MATCH the new crate behavior before editing these constants — never edit them merely to make
3498    /// the Rust side pass, or the Go-equivalence proof becomes a Rust-vs-itself tautology and a
3499    /// Tailnet-Lock consensus split could ship undetected. The SECURITY-CRITICAL rows are NOT
3500    /// version-tunable regardless: STD must reject the S>=L malleability vectors (6,7), and STD/ZIP215
3501    /// MUST disagree on the cofactored discriminator (vector 4) — asserted separately below.
3502    //                                            0    1    2    3    4    5    6    7    8    9   10   11
3503    const SPECCHECK_STD_ACCEPT: [bool; 12] = [
3504        true, true, true, true, false, false, false, false, false, false, false, true,
3505    ];
3506    const SPECCHECK_ZIP215_ACCEPT: [bool; 12] = [
3507        true, true, true, true, true, true, false, false, false, true, true, true,
3508    ];
3509
3510    /// Known-answer test guarding the dual-verifier split that backs TKA consensus correctness.
3511    ///
3512    /// `verify_ed25519_std` wraps `ed25519-dalek 2.x` (standard RFC-8032-ish, cofactorless) and is
3513    /// used for SigRotation WRAPPING signatures. `verify_ed25519_zip215` wraps `ed25519-zebra 4.x`
3514    /// (ZIP-215 cofactored) and is used for Direct/Credential signatures to match Go
3515    /// `ed25519consensus`. If these two ever collapse to identical behavior, Go wire-compat for
3516    /// Tailnet-Lock silently breaks — this test proves they remain distinct on the adversarial set.
3517    ///
3518    /// The accept/reject matrix is asserted **as actually observed** from the pinned crate versions
3519    /// (`ed25519-dalek 2.2.0`, `ed25519-zebra 4.2.0`). These are newer than the versions tabulated
3520    /// in the "Taming the many EdDSAs" paper (Table 5: dalek 1.0.0-pre.4, zebra 2.1.1), so the
3521    /// non-canonical cases (8–11) may differ from the paper; we lock in current behavior as a
3522    /// regression guard. The SECURITY-CRITICAL invariants are NOT version-tunable: the standard
3523    /// verifier MUST reject the S >= L malleability vectors (6, 7), and the two verifiers MUST
3524    /// disagree on the cofactored discriminator (vector 4). Those are hard, separate assertions.
3525    #[test]
3526    fn ed25519_speccheck_dual_verifier_kat() {
3527        // The accept/reject matrix is the SHARED [`SPECCHECK_STD_ACCEPT`] / [`SPECCHECK_ZIP215_ACCEPT`]
3528        // const — the SAME bytes `ed25519_dual_verifier_matches_go_verdicts` pins to Go. This test
3529        // asserts the pinned Rust crates produce that matrix; that test asserts Go does too. One array,
3530        // so the two proofs can never silently diverge on a dependency bump (see the regeneration
3531        // contract on the const).
3532        const STD_EXPECT: [bool; 12] = SPECCHECK_STD_ACCEPT;
3533        const ZIP215_EXPECT: [bool; 12] = SPECCHECK_ZIP215_ACCEPT;
3534
3535        for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
3536            let msg = unhex(msg_hex);
3537            let pk = unhex(pk_hex);
3538            let sig = unhex(sig_hex);
3539            assert_eq!(pk.len(), 32, "vector {i}: pubkey not 32 bytes");
3540            assert_eq!(sig.len(), 64, "vector {i}: signature not 64 bytes");
3541
3542            let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
3543            let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
3544
3545            assert_eq!(
3546                std_ok, STD_EXPECT[i],
3547                "speccheck vector {i}: verify_ed25519_std accept={std_ok}, expected {}",
3548                STD_EXPECT[i]
3549            );
3550            assert_eq!(
3551                zip_ok, ZIP215_EXPECT[i],
3552                "speccheck vector {i}: verify_ed25519_zip215 accept={zip_ok}, expected {}",
3553                ZIP215_EXPECT[i]
3554            );
3555        }
3556
3557        // SECURITY-CRITICAL invariant (NOT version-tunable): the standard verifier must reject
3558        // signatures whose scalar S is out of range (S >= L). These are vectors 6 and 7 — the
3559        // EdDSA malleability guard. If either ACCEPTS, that is a real security finding.
3560        for &i in &[6usize, 7usize] {
3561            let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[i];
3562            let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
3563            assert!(
3564                verify_ed25519_std(&pk, &msg, &sig).is_err(),
3565                "SECURITY: verify_ed25519_std ACCEPTED S>=L malleability vector {i}"
3566            );
3567        }
3568
3569        // KEY DISCRIMINATOR (vector 4): cofactored (ZIP-215/zebra) accepts, cofactorless
3570        // (standard/dalek) rejects, on the SAME (pk, msg, sig). This proves the dual-verifier
3571        // split is real and not accidentally identical.
3572        {
3573            let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[4];
3574            let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
3575            assert!(
3576                verify_ed25519_zip215(&pk, &msg, &sig).is_ok(),
3577                "vector 4: ZIP-215 (zebra) should ACCEPT the cofactored discriminator"
3578            );
3579            assert!(
3580                verify_ed25519_std(&pk, &msg, &sig).is_err(),
3581                "vector 4: standard (dalek) should REJECT the cofactored discriminator"
3582            );
3583        }
3584    }
3585
3586    // ----- Cross-implementation KATs against real Go `tailscale.com/tka` v1.100.0 -----
3587
3588    /// Cross-implementation Known-Answer-Test: the CTAP2-CBOR serialization and BLAKE2s-256
3589    /// `SigHash` of three `NodeKeySignature` shapes must byte-match the REAL Go
3590    /// `tailscale.com/tka` package, version **v1.100.0** (toolchain **go1.26.4**).
3591    ///
3592    /// Provenance: the golden bytes below were produced by a Go generator that imports the real
3593    /// upstream `tailscale.com/tka` and calls `NodeKeySignature.Serialize()` (full CBOR including
3594    /// the signature field) and `NodeKeySignature.SigHash()` (BLAKE2s-256 of the CBOR with the
3595    /// `Signature` field nil'd). They are authoritative upstream output, NOT this fork's own
3596    /// encoder echoed back — this is the cross-validation the `node_key_signature_cbor_frozen_vector`
3597    /// freeze-test could not provide. The generator lives alongside the speccheck generator under
3598    /// `tests/vectors/gen` (Go module pinned to `tailscale.com v1.100.0`).
3599    ///
3600    /// Three shapes are covered: a `Direct` leaf, a `Credential` leaf (same fields, different
3601    /// `sigKind`), and a `Rotation` wrapping a nested `Direct` (the rotation-chain wire form). The
3602    /// int-map keys are 1=sigKind, 2=pubkey, 3=keyID, 4=signature, 5=nested, 6=wrappingPubkey;
3603    /// empty byte fields are omitted (`omitempty`).
3604    #[test]
3605    fn tka_cbor_matches_go_golden() {
3606        // Common fixed field material (real Go generator inputs).
3607        let pubkey32 = unhex("a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf");
3608        let key_id32 = unhex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f");
3609        let sig64 = unhex(
3610            "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
3611        );
3612        let wrap32 = unhex("101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f");
3613        let rot_sig64 = unhex(
3614            "55565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f9091929394",
3615        );
3616
3617        // GOLDEN 1 — Direct.
3618        {
3619            let sig = NodeKeySignature {
3620                sig_kind: SigKind::Direct,
3621                pubkey: pubkey32.clone(),
3622                key_id: key_id32.clone(),
3623                signature: sig64.clone(),
3624                nested: None,
3625                wrapping_pubkey: Vec::new(),
3626            };
3627            let full = sig.to_cbor(true).to_vec();
3628            let expected_full = unhex(
3629                "a40101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
3630            );
3631            assert_eq!(
3632                full,
3633                expected_full,
3634                "GOLDEN 1 (Direct) full CBOR diverged from Go tka v1.100.0. actual: {}",
3635                hex(&full)
3636            );
3637            let expected_hash =
3638                unhex("7e9653c97d35485b37b9bf942b1861cd2f3cb0663b5bb154f1178cca72101e74");
3639            assert_eq!(
3640                sig.sig_hash().as_slice(),
3641                expected_hash.as_slice(),
3642                "GOLDEN 1 (Direct) sig_hash diverged from Go tka v1.100.0. actual: {}",
3643                hex(&sig.sig_hash())
3644            );
3645        }
3646
3647        // GOLDEN 2 — Credential (same fields as Direct, sigKind=3).
3648        {
3649            let sig = NodeKeySignature {
3650                sig_kind: SigKind::Credential,
3651                pubkey: pubkey32.clone(),
3652                key_id: key_id32.clone(),
3653                signature: sig64.clone(),
3654                nested: None,
3655                wrapping_pubkey: Vec::new(),
3656            };
3657            let full = sig.to_cbor(true).to_vec();
3658            let expected_full = unhex(
3659                "a40103025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
3660            );
3661            assert_eq!(
3662                full,
3663                expected_full,
3664                "GOLDEN 2 (Credential) full CBOR diverged from Go tka v1.100.0. actual: {}",
3665                hex(&full)
3666            );
3667            let expected_hash =
3668                unhex("b6070ea8bc7ae8989ef4293f5031bedaa4a499803ade99f9e2f34dc2898ac03f");
3669            assert_eq!(
3670                sig.sig_hash().as_slice(),
3671                expected_hash.as_slice(),
3672                "GOLDEN 2 (Credential) sig_hash diverged from Go tka v1.100.0. actual: {}",
3673                hex(&sig.sig_hash())
3674            );
3675        }
3676
3677        // GOLDEN 3 — Rotation wrapping a nested Direct.
3678        //
3679        // Decoded from the authoritative Go bytes, the OUTER map is `a4` = 4 entries: keys
3680        // 1=sigKind(Rotation), 2=pubkey(wrap32), 4=signature(rotSig64), 5=nested. The outer has NO
3681        // key 6 (its `wrapping_pubkey` is EMPTY → omitted) and NO key 3 (its `key_id` is EMPTY →
3682        // omitted). The trailing `065820<wrap32>` in the hex belongs to the NESTED Direct map
3683        // (`a5` = 5 entries: keys 1,2,3,4,6), whose `wrapping_pubkey` IS set to wrap32. Constructing
3684        // the structs this way (outer wrapping_pubkey empty, nested wrapping_pubkey=wrap32)
3685        // reproduces the Go bytes exactly.
3686        {
3687            let nested = NodeKeySignature {
3688                sig_kind: SigKind::Direct,
3689                pubkey: pubkey32.clone(),
3690                key_id: key_id32.clone(),
3691                signature: sig64.clone(),
3692                nested: None,
3693                wrapping_pubkey: wrap32.clone(),
3694            };
3695            let sig = NodeKeySignature {
3696                sig_kind: SigKind::Rotation,
3697                pubkey: wrap32.clone(),
3698                key_id: Vec::new(),
3699                signature: rot_sig64.clone(),
3700                nested: Some(alloc::boxed::Box::new(nested)),
3701                wrapping_pubkey: Vec::new(),
3702            };
3703            let full = sig.to_cbor(true).to_vec();
3704            let expected_full = unhex(
3705                "a40102025820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f04584055565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939405a50101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf065820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f",
3706            );
3707            assert_eq!(
3708                full,
3709                expected_full,
3710                "GOLDEN 3 (Rotation) full CBOR diverged from Go tka v1.100.0. actual: {}",
3711                hex(&full)
3712            );
3713            let expected_hash =
3714                unhex("fac0a5a6781bb945369c28a0b3d3eea04e1648b60ec1a990a1ff68a9a566e6a7");
3715            assert_eq!(
3716                sig.sig_hash().as_slice(),
3717                expected_hash.as_slice(),
3718                "GOLDEN 3 (Rotation) sig_hash diverged from Go tka v1.100.0. actual: {}",
3719                hex(&sig.sig_hash())
3720            );
3721        }
3722    }
3723
3724    /// Cross-bind the dual Ed25519 verifier accept/reject matrix to the verdicts produced by the
3725    /// REAL Go implementations on the adversarial speccheck set (see [`SPECCHECK_VECTORS`]).
3726    ///
3727    /// Provenance of the Go verdicts: Go `crypto/ed25519.Verify` (standard, cofactorless) and
3728    /// `github.com/hdevalence/ed25519consensus v0.2.0` (ZIP-215, cofactored), toolchain
3729    /// **go1.26.4**, driven by the generator under `tests/vectors/gen/zip215`. These are the SAME
3730    /// verdicts the pinned Rust crates produce — proving `ed25519-dalek 2.x` == Go-std and
3731    /// `ed25519-zebra 4.x` == Go-`ed25519consensus` on the adversarial set. The arrays below MUST
3732    /// therefore equal `STD_EXPECT` / `ZIP215_EXPECT` asserted in
3733    /// `ed25519_speccheck_dual_verifier_kat`; this test additionally pins them to Go's behavior.
3734    ///
3735    /// NOTE: [`SPECCHECK_VECTORS`] is duplicated (byte-for-byte) in the Go generator at
3736    /// `tests/vectors/gen/zip215/main.go`. Both copies derive from the same upstream
3737    /// `cases.json` commit; if you edit one you MUST edit the other, or this proof would compare
3738    /// inputs the Go verdicts were never computed over.
3739    #[test]
3740    fn ed25519_dual_verifier_matches_go_verdicts() {
3741        // Go's verdicts ARE the shared matrix (the SAME const the crate-observed KAT asserts). The
3742        // bindings below alias the shared const rather than re-listing literals, so the Go-pinning
3743        // and the crate-observed expectation are physically one array — a dependency bump cannot
3744        // change one without the other, forcing the regeneration contract (re-run the zip215
3745        // generator) instead of a silent edit. See the const's doc.
3746        const GO_STD_ACCEPT: [bool; 12] = SPECCHECK_STD_ACCEPT;
3747        const GO_ZIP215_ACCEPT: [bool; 12] = SPECCHECK_ZIP215_ACCEPT;
3748
3749        for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
3750            let msg = unhex(msg_hex);
3751            let pk = unhex(pk_hex);
3752            let sig = unhex(sig_hex);
3753
3754            let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
3755            let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
3756
3757            assert_eq!(
3758                std_ok, GO_STD_ACCEPT[i],
3759                "vector {i}: Rust verify_ed25519_std accept={std_ok} disagrees with Go \
3760                 crypto/ed25519.Verify={}",
3761                GO_STD_ACCEPT[i]
3762            );
3763            assert_eq!(
3764                zip_ok, GO_ZIP215_ACCEPT[i],
3765                "vector {i}: Rust verify_ed25519_zip215 accept={zip_ok} disagrees with Go \
3766                 ed25519consensus.Verify={}",
3767                GO_ZIP215_ACCEPT[i]
3768            );
3769        }
3770    }
3771
3772    /// Byte-exact cross-validation of [`Aum::serialize`] against the literal `[]byte` vectors in Go
3773    /// `tka/aum_test.go` `TestSerialization` (tailscale v1.100.0, fxamacker/cbor v2.9.2 CTAP2 mode).
3774    /// These are the authoritative oracle: if our CTAP2 CBOR diverges from Go by a single byte, the
3775    /// `AUM.Hash` chain links and every signature digest break. Each case reproduces the exact Go
3776    /// `AUM{…}` literal and asserts identical canonical bytes.
3777    #[test]
3778    fn aum_serialize_matches_go_test_serialization_vectors() {
3779        // AddKey: AUM{MessageKind: AUMAddKey, Key: &Key{}}. Go's *zero* Key{} has Kind=0
3780        // (KeyInvalid) and Public=nil, which our `AumKey` (always a valid KeyKind + Vec) cannot
3781        // model — that zero-Key encoding (`03 a3 01 00 02 00 03 f6`) is asserted directly at the
3782        // CBOR layer here, while the AUM keymap around it (map3, kind=AddKey, null prev, Key at
3783        // key 3) is covered by the structural assertions plus the three full vectors below.
3784        let add_key_inner_zero_key = cbor::Value::IntMap(alloc::vec![
3785            (1, cbor::Value::Uint(0)), // Kind = KeyInvalid(0)
3786            (2, cbor::Value::Uint(0)), // Votes = 0
3787            (3, cbor::Value::Null),    // Public = nil -> null
3788        ]);
3789        assert_eq!(
3790            add_key_inner_zero_key.to_vec(),
3791            alloc::vec![0xa3, 0x01, 0x00, 0x02, 0x00, 0x03, 0xf6],
3792            "Go's zero Key{{}} encodes as map(3){{kind=0, votes=0, public=null}}"
3793        );
3794
3795        // RemoveKey: AUM{MessageKind: AUMRemoveKey, KeyID: []byte{1, 2}}
3796        let remove_key = Aum {
3797            message_kind: AumKind::RemoveKey,
3798            prev_aum_hash: None,
3799            key: None,
3800            key_id: alloc::vec![1, 2],
3801            state: None,
3802            votes: None,
3803            meta: Vec::new(),
3804            signatures: Vec::new(),
3805        };
3806        assert_eq!(
3807            remove_key.serialize(),
3808            // a3 (map3) 01 02 (kind=RemoveKey) 02 f6 (prev=null) 04 42 01 02 (KeyID=bytes{1,2})
3809            alloc::vec![0xa3, 0x01, 0x02, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02],
3810            "RemoveKey AUM serialization must match Go TestSerialization byte-for-byte"
3811        );
3812
3813        // UpdateKey: AUM{MessageKind: AUMUpdateKey, Votes: &uint(2), KeyID: []byte{1,2},
3814        //                Meta: map[string]string{"a":"b"}}
3815        let update_key = Aum {
3816            message_kind: AumKind::UpdateKey,
3817            prev_aum_hash: None,
3818            key: None,
3819            key_id: alloc::vec![1, 2],
3820            state: None,
3821            votes: Some(2),
3822            meta: alloc::vec![("a".into(), "b".into())],
3823            signatures: Vec::new(),
3824        };
3825        assert_eq!(
3826            update_key.serialize(),
3827            // a5 (map5) 01 04 (UpdateKey) 02 f6 (prev null) 04 42 01 02 (KeyID) 06 02 (Votes=2)
3828            // 07 a1 61 61 61 62 (Meta = {"a":"b"})  — keys ascending 1,2,4,6,7
3829            alloc::vec![
3830                0xa5, 0x01, 0x04, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02, 0x06, 0x02, 0x07, 0xa1, 0x61,
3831                0x61, 0x61, 0x62
3832            ],
3833            "UpdateKey AUM serialization must match Go TestSerialization byte-for-byte"
3834        );
3835
3836        // Signature: AUM{MessageKind: AUMAddKey, Signatures: []tkatype.Signature{{KeyID: []byte{1}}}}
3837        let with_sig = Aum {
3838            message_kind: AumKind::AddKey,
3839            prev_aum_hash: None,
3840            key: None,
3841            key_id: Vec::new(),
3842            state: None,
3843            votes: None,
3844            meta: Vec::new(),
3845            signatures: alloc::vec![AumSignature {
3846                key_id: alloc::vec![1],
3847                signature: Vec::new(),
3848            }],
3849        };
3850        assert_eq!(
3851            with_sig.serialize(),
3852            // a3 (map3) 01 01 (AddKey) 02 f6 (prev null) 17 (key 23 = Signatures) 81 (array1)
3853            // a2 (map2) 01 41 01 (Signature.KeyID = bytes{1}) 02 f6 (Signature.Signature = null)
3854            alloc::vec![
3855                0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6
3856            ],
3857            "Signature AUM serialization must match Go TestSerialization (key 23 + nil sig = null)"
3858        );
3859
3860        // sig_hash must drop key 23 (Go SigHash nils Signatures → omitempty): the with_sig AUM's
3861        // sig_hash equals the BLAKE2s of the same AUM with no signatures.
3862        let no_sig = Aum {
3863            signatures: Vec::new(),
3864            ..with_sig.clone()
3865        };
3866        assert_eq!(
3867            with_sig.sig_hash(),
3868            blake2s_256(&no_sig.serialize()),
3869            "SigHash preimage must omit key 23 (Signatures), matching Go AUM.SigHash"
3870        );
3871        // And the full Hash differs from the SigHash (signatures are in the chain-link hash).
3872        assert_ne!(
3873            with_sig.hash().0,
3874            with_sig.sig_hash(),
3875            "Hash (incl. signatures) must differ from SigHash (excl.) when signatures are present"
3876        );
3877    }
3878
3879    /// Checkpoint AUM with an embedded `State`: exercises [`AumState`]/[`AumKey`] CBOR (the 32-byte
3880    /// `LastAUMHash` as a definite-length byte string, the `DisablementValues`/`Keys` arrays, and the
3881    /// `Key.Public` at key 3). Mirrors the structure of Go's `TestSerialization` Checkpoint case.
3882    #[test]
3883    fn aum_checkpoint_state_serialization() {
3884        let checkpoint = Aum {
3885            message_kind: AumKind::Checkpoint,
3886            prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
3887            key: None,
3888            key_id: Vec::new(),
3889            state: Some(AumState {
3890                last_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
3891                disablement_values: Some(Vec::new()),
3892                keys: Some(alloc::vec![AumKey {
3893                    kind: KeyKind::Ed25519,
3894                    votes: 1,
3895                    public: alloc::vec![5, 6],
3896                    meta: Vec::new(),
3897                }]),
3898                state_id1: 0,
3899                state_id2: 0,
3900            }),
3901            votes: None,
3902            meta: Vec::new(),
3903            signatures: Vec::new(),
3904        };
3905        let bytes = checkpoint.serialize();
3906        // Spot-check the structurally-load-bearing pieces (full-vector parity is covered by the
3907        // three exact vectors above; here we pin the State/Key encoding shape):
3908        // map3: key1=Checkpoint(5), key2=prev(32-byte bytestring 0x58 0x20 …), key5=State.
3909        assert_eq!(
3910            &bytes[0..3],
3911            &[0xa3, 0x01, 0x05],
3912            "map(3), MessageKind=Checkpoint(5)"
3913        );
3914        assert_eq!(
3915            &bytes[3..6],
3916            &[0x02, 0x58, 0x20],
3917            "key2 prev = 32-byte byte string head"
3918        );
3919        // The embedded State map (key 5) must contain: LastAUMHash (1) as 32-byte bytes, an empty
3920        // DisablementValues array (2 → 0x80), and a Keys array (3 → 0x81 with one Key map).
3921        // Locate the State map head (key 5) after the 32-byte prev hash: 3 + 3 + 32 = offset 38.
3922        assert_eq!(bytes[38], 0x05, "key 5 = State");
3923        // State is a map; its first entry is key1 (LastAUMHash) = 32-byte byte string.
3924        assert_eq!(
3925            &bytes[39..42],
3926            &[0xa3, 0x01, 0x58],
3927            "State map(3), key1 LastAUMHash bytes"
3928        );
3929        // The Key inside Keys carries Public={5,6} at key 3 (…03 42 05 06) and Votes=1 at key 2.
3930        let tail = &bytes[bytes.len() - 4..];
3931        assert_eq!(
3932            tail,
3933            &[0x03, 0x42, 0x05, 0x06],
3934            "Key.Public (key 3) = bytes{{5,6}}"
3935        );
3936        // Round-trips deterministically (hash is stable).
3937        assert_eq!(checkpoint.hash(), checkpoint.hash());
3938    }
3939
3940    // ---- AUM-chain replay (chunk 1B) -----------------------------------------------------------
3941
3942    /// A test trusted key from a seed byte (deterministic public key + given votes).
3943    fn test_aum_key(seed: u8, votes: u32) -> AumKey {
3944        use ed25519_dalek::SigningKey;
3945        let pubk = SigningKey::from_bytes(&[seed; 32])
3946            .verifying_key()
3947            .to_bytes()
3948            .to_vec();
3949        AumKey {
3950            kind: KeyKind::Ed25519,
3951            votes,
3952            public: pubk,
3953            meta: Vec::new(),
3954        }
3955    }
3956
3957    /// A genesis `AUMAddKey` (no parent) adding `key`.
3958    fn genesis_add(key: AumKey) -> Aum {
3959        Aum {
3960            message_kind: AumKind::AddKey,
3961            prev_aum_hash: None,
3962            key: Some(key),
3963            key_id: Vec::new(),
3964            state: None,
3965            votes: None,
3966            meta: Vec::new(),
3967            signatures: Vec::new(),
3968        }
3969    }
3970
3971    /// A child AUM of `parent` of the given kind, optionally carrying a key / key_id.
3972    fn child(parent: &Aum, kind: AumKind, key: Option<AumKey>, key_id: Vec<u8>) -> Aum {
3973        Aum {
3974            message_kind: kind,
3975            prev_aum_hash: Some(parent.hash()),
3976            key,
3977            key_id,
3978            state: None,
3979            votes: None,
3980            meta: Vec::new(),
3981            signatures: Vec::new(),
3982        }
3983    }
3984
3985    /// Linear replay applies each kind: genesis AddKey(k0), AddKey(k1), UpdateKey(k1 votes), then
3986    /// RemoveKey(k0). The final state has only k1 with its updated votes, and head = last AUM hash.
3987    #[test]
3988    fn replay_linear_chain_folds_all_kinds() {
3989        let k0 = test_aum_key(1, 1);
3990        let k1 = test_aum_key(2, 1);
3991
3992        let a0 = genesis_add(k0.clone());
3993        let a1 = child(&a0, AumKind::AddKey, Some(k1.clone()), Vec::new());
3994        let mut a2 = child(&a1, AumKind::UpdateKey, None, k1.public.clone());
3995        a2.votes = Some(5);
3996        let a3 = child(&a2, AumKind::RemoveKey, None, k0.public.clone());
3997
3998        let auth = Authority::from_chain(&[a0, a1, a2, a3.clone()]).unwrap();
3999
4000        // Only k1 remains, with the updated vote weight.
4001        assert_eq!(auth.state().keys.len(), 1, "k0 removed, k1 remains");
4002        let remaining = &auth.state().keys[0];
4003        assert_eq!(remaining.public, k1.public, "k1 is the surviving key");
4004        assert_eq!(remaining.votes, 5, "UpdateKey raised k1's votes to 5");
4005        // Head is the hash of the last applied AUM.
4006        assert_eq!(auth.head(), a3.hash(), "head = last AUM hash");
4007    }
4008
4009    /// Round-trips [`Aum::sign`] through the signature-verifying trust boundary: a genesis `AddKey`
4010    /// AUM self-signed with a raw dalek key (exactly what `Aum::sign` does) is accepted by
4011    /// [`VerifiedAumChain::verify`], whose AUM-leaf check is the cofactored ZIP-215 verifier
4012    /// (`ed25519-zebra`). This pins the relationship the verify path relies on — a standard RFC-8032
4013    /// dalek signature is a valid ZIP-215 signature — but now exercised from OUR signer, so the
4014    /// sign→verify pair cannot silently drift apart. A second, chained `AddKey` signed by the genesis
4015    /// key proves a non-genesis link verifies against the state its parent seeded. Tampering a
4016    /// signature byte fails closed with `BadSignature`; a genesis signed by a key it does not seed is
4017    /// `UntrustedKey`.
4018    ///
4019    /// Goes through `VerifiedAumChain::verify` (the trust boundary), NOT the structural-only
4020    /// `from_chain` — `from_chain` never inspects signatures, so it could not witness `Aum::sign`.
4021    ///
4022    /// The negative assertions here are public-API **smoke-checks**: they prove `Aum::sign`'s output
4023    /// is gated fail-closed by the real verifier. The canonical, exhaustive error-path coverage lives
4024    /// in the dedicated `verified_chain_rejects_*` tests (tampered / forged-untrusted / mis-linked)
4025    /// and in the raw-verifier KATs (`ed25519_speccheck_dual_verifier_kat`, the wycheproof suite);
4026    /// this test is the production-signer↔production-verifier drift pin, not a replacement for those.
4027    #[test]
4028    fn aum_sign_round_trips_through_verified_chain() {
4029        use ed25519_dalek::SigningKey;
4030
4031        // k0 bootstraps the lock: `test_aum_key(1, _)`'s public is the dalek pubkey for seed 1, so
4032        // the matching signing key is `SigningKey::from_bytes(&[1; 32])`.
4033        let k0 = test_aum_key(1, 1);
4034        let sk0 = SigningKey::from_bytes(&[1u8; 32]);
4035        let k1 = test_aum_key(2, 1);
4036        let sk1 = SigningKey::from_bytes(&[2u8; 32]);
4037
4038        // Genesis AddKey(k0), self-signed by k0 — a bootstrapping AddKey is verified against the keys
4039        // it itself seeds, so it must be signed by the key it introduces (see `VerifiedAumChain`).
4040        let mut a0 = genesis_add(k0.clone());
4041        a0.sign(&sk0);
4042        // Child AddKey(k1), signed by the now-trusted k0.
4043        let mut a1 = child(&a0, AumKind::AddKey, Some(k1.clone()), Vec::new());
4044        a1.sign(&sk0);
4045
4046        // The full chain verifies through the trust boundary (dalek signatures accepted by the
4047        // ZIP-215 leaf verifier) and folds to a state trusting both keys.
4048        let chain = VerifiedAumChain::verify(&[a0.clone(), a1.clone()])
4049            .expect("dalek-signed AUM chain must verify under the zebra ZIP-215 leaf verifier");
4050        let auth = Authority::from_verified_chain(chain);
4051        assert_eq!(auth.state().keys.len(), 2, "both k0 and k1 are trusted");
4052        assert_eq!(auth.head(), a1.hash(), "head = last AUM hash");
4053
4054        // A lone self-signed genesis AddKey also round-trips.
4055        VerifiedAumChain::verify(&[a0.clone()]).expect("self-signed genesis AddKey verifies");
4056
4057        // Fail-closed: flip one signature byte → BadSignature, chain rejected.
4058        let mut tampered = a0.clone();
4059        tampered.signatures[0].signature[0] ^= 0x01;
4060        assert_eq!(
4061            VerifiedAumChain::verify(&[tampered]).unwrap_err(),
4062            TkaError::BadSignature,
4063            "a tampered AUM signature must fail the ZIP-215 verify"
4064        );
4065
4066        // A genesis AddKey(k0) signed by k1 (a key it does not seed) is untrusted: the signer's
4067        // key_id is absent from the state the AUM establishes.
4068        let mut wrong_signer = genesis_add(k0.clone());
4069        wrong_signer.sign(&sk1);
4070        assert_eq!(
4071            VerifiedAumChain::verify(&[wrong_signer]).unwrap_err(),
4072            TkaError::UntrustedKey,
4073            "a genesis AddKey signed by a key it does not seed is untrusted"
4074        );
4075
4076        // Pin the two classic adversarial signature SHAPES at the AUM layer (not just at the
4077        // raw-verifier KATs), so a future refactor of `verify_aum_signatures`/`static_validate` can't
4078        // silently weaken the trust boundary. A hand-forged signature with the right key_id but a
4079        // 64-byte all-zero body passes the length gate and fails closed in the ZIP-215 verifier.
4080        let mut zero_sig = genesis_add(k0.clone());
4081        zero_sig.signatures.push(AumSignature {
4082            key_id: k0.public.clone(),
4083            signature: alloc::vec![0u8; 64],
4084        });
4085        assert_eq!(
4086            VerifiedAumChain::verify(&[zero_sig]).unwrap_err(),
4087            TkaError::BadSignature,
4088            "an all-zero 64-byte signature must not verify"
4089        );
4090        // A wrong-length signature is rejected structurally (static_validate) before any crypto.
4091        let mut short_sig = genesis_add(k0.clone());
4092        short_sig.signatures.push(AumSignature {
4093            key_id: k0.public.clone(),
4094            signature: alloc::vec![0u8; 63],
4095        });
4096        assert!(
4097            matches!(
4098                VerifiedAumChain::verify(&[short_sig]).unwrap_err(),
4099                TkaError::Decode(_)
4100            ),
4101            "a malformed (non-64-byte) signature is rejected by static_validate"
4102        );
4103    }
4104
4105    /// A broken chain link (wrong `prev_aum_hash`) is rejected with `BadParent`.
4106    #[test]
4107    fn replay_rejects_broken_parent_link() {
4108        let k0 = test_aum_key(1, 1);
4109        let k1 = test_aum_key(2, 1);
4110        let a0 = genesis_add(k0);
4111        // a1 claims a bogus parent, not a0's hash.
4112        let mut a1 = child(&a0, AumKind::AddKey, Some(k1), Vec::new());
4113        a1.prev_aum_hash = Some(AumHash([0xab; 32]));
4114        assert_eq!(
4115            Authority::from_chain(&[a0, a1]).unwrap_err(),
4116            TkaError::BadParent
4117        );
4118    }
4119
4120    /// AddKey of an already-trusted key, and Remove/Update of an absent key, are rejected.
4121    #[test]
4122    fn replay_rejects_bad_key_state() {
4123        let k0 = test_aum_key(1, 1);
4124        let a0 = genesis_add(k0.clone());
4125        // Duplicate add of k0.
4126        let dup = child(&a0, AumKind::AddKey, Some(k0.clone()), Vec::new());
4127        assert_eq!(
4128            Authority::from_chain(&[a0.clone(), dup]).unwrap_err(),
4129            TkaError::BadKeyState
4130        );
4131        // Remove of a key that was never added.
4132        let absent = test_aum_key(9, 1);
4133        let rm = child(&a0, AumKind::RemoveKey, None, absent.public.clone());
4134        assert_eq!(
4135            Authority::from_chain(&[a0, rm]).unwrap_err(),
4136            TkaError::BadKeyState
4137        );
4138    }
4139
4140    /// An empty chain is rejected.
4141    #[test]
4142    fn replay_empty_chain_is_bad_chain() {
4143        assert_eq!(Authority::from_chain(&[]).unwrap_err(), TkaError::BadChain);
4144    }
4145
4146    /// `weight` sums the votes of distinct trusted signing keys: an unknown signer contributes 0, and
4147    /// a key that signs twice counts once (Go `TestAUMWeight` "Double use" → its votes, not double).
4148    #[test]
4149    fn replay_weight_dedups_and_ignores_unknown() {
4150        let k0 = test_aum_key(1, 2);
4151        let k1 = test_aum_key(2, 3);
4152        let state = ReplayState {
4153            keys: alloc::vec![k0.clone(), k1.clone()],
4154            last_aum_hash: None,
4155            state_id: None,
4156        };
4157
4158        // Empty signatures → 0.
4159        let mut aum = genesis_add(test_aum_key(5, 1));
4160        assert_eq!(state.weight(&aum), 0);
4161
4162        // One known signer (k0, votes 2).
4163        aum.signatures = alloc::vec![AumSignature {
4164            key_id: k0.public.clone(),
4165            signature: Vec::new()
4166        }];
4167        assert_eq!(state.weight(&aum), 2);
4168
4169        // Two distinct known signers → 2 + 3 = 5.
4170        aum.signatures = alloc::vec![
4171            AumSignature {
4172                key_id: k0.public.clone(),
4173                signature: Vec::new()
4174            },
4175            AumSignature {
4176                key_id: k1.public.clone(),
4177                signature: Vec::new()
4178            },
4179        ];
4180        assert_eq!(state.weight(&aum), 5);
4181
4182        // Double-use of k0 → counted once (2), not 4.
4183        aum.signatures = alloc::vec![
4184            AumSignature {
4185                key_id: k0.public.clone(),
4186                signature: Vec::new()
4187            },
4188            AumSignature {
4189                key_id: k0.public.clone(),
4190                signature: Vec::new()
4191            },
4192        ];
4193        assert_eq!(state.weight(&aum), 2, "a key signing twice counts once");
4194
4195        // Unknown signer → 0.
4196        aum.signatures = alloc::vec![AumSignature {
4197            key_id: alloc::vec![0xff; 32],
4198            signature: Vec::new()
4199        }];
4200        assert_eq!(
4201            state.weight(&aum),
4202            0,
4203            "an untrusted signing key contributes no weight"
4204        );
4205    }
4206
4207    /// `pick_next_aum` rule 3 (the deterministic tiebreak): with equal weight (0, no signatures) and
4208    /// neither a RemoveKey, the candidate with the lexicographically-lowest `Hash()` wins —
4209    /// regardless of input order, so two nodes select the same branch.
4210    #[test]
4211    fn pick_next_aum_lowest_hash_tiebreak_is_order_independent() {
4212        let k = test_aum_key(1, 1);
4213        let a0 = genesis_add(k);
4214        // Two distinct NoOp children of a0 (differ by key_id so their hashes differ).
4215        let c1 = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
4216        let c2 = child(&a0, AumKind::NoOp, None, alloc::vec![2]);
4217        let state = ReplayState::default();
4218
4219        let lower = if c1.hash().0 < c2.hash().0 {
4220            c1.hash()
4221        } else {
4222            c2.hash()
4223        };
4224        let ab = [c1.clone(), c2.clone()];
4225        let ba = [c2, c1];
4226        let pick_ab = pick_next_aum(&state, &ab).hash();
4227        let pick_ba = pick_next_aum(&state, &ba).hash();
4228        assert_eq!(pick_ab, lower, "lowest hash wins");
4229        assert_eq!(
4230            pick_ab, pick_ba,
4231            "selection is independent of candidate order"
4232        );
4233    }
4234
4235    /// `pick_next_aum` rule 1 (weight) dominates rule 3 (hash): a signed child with real weight beats
4236    /// an unsigned child even if the unsigned one has a lower hash.
4237    #[test]
4238    fn pick_next_aum_weight_beats_hash() {
4239        use ed25519_dalek::SigningKey;
4240        let signer_seed = 3u8;
4241        let signer_pub = SigningKey::from_bytes(&[signer_seed; 32])
4242            .verifying_key()
4243            .to_bytes()
4244            .to_vec();
4245        let state = ReplayState {
4246            keys: alloc::vec![AumKey {
4247                kind: KeyKind::Ed25519,
4248                votes: 4,
4249                public: signer_pub.clone(),
4250                meta: Vec::new(),
4251            }],
4252            last_aum_hash: None,
4253            state_id: None,
4254        };
4255
4256        let a0 = genesis_add(test_aum_key(1, 1));
4257        let unsigned = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
4258        let mut signed = child(&a0, AumKind::NoOp, None, alloc::vec![2]);
4259        signed.signatures = alloc::vec![AumSignature {
4260            key_id: signer_pub,
4261            signature: Vec::new(),
4262        }];
4263
4264        // The signed child wins on weight (4 > 0) no matter the hash order.
4265        let candidates = [unsigned.clone(), signed.clone()];
4266        let winner = pick_next_aum(&state, &candidates);
4267        assert_eq!(
4268            winner.hash(),
4269            signed.hash(),
4270            "higher weight wins over lower hash"
4271        );
4272    }
4273
4274    /// `from_forked_chain`: a shared genesis, then two competing RemoveKey vs NoOp branches at equal
4275    /// weight — rule 2 prefers the RemoveKey branch. The resulting state reflects the chosen branch.
4276    #[test]
4277    fn forked_chain_prefers_removekey_branch() {
4278        let k0 = test_aum_key(1, 1);
4279        let k1 = test_aum_key(2, 1);
4280        // Genesis adds both keys (two AUMs).
4281        let a0 = genesis_add(k0.clone());
4282        let a1 = child(&a0, AumKind::AddKey, Some(k1.clone()), Vec::new());
4283        // Fork at a1: branch A removes k0; branch B is a NoOp. Equal weight (0 sigs).
4284        let branch_remove = child(&a1, AumKind::RemoveKey, None, k0.public.clone());
4285        let branch_noop = child(&a1, AumKind::NoOp, None, alloc::vec![9]);
4286
4287        let noop_branch = [branch_noop.clone()];
4288        let remove_branch = [branch_remove.clone()];
4289        let auth = Authority::from_forked_chain(&[a0, a1], &[&noop_branch[..], &remove_branch[..]])
4290            .unwrap();
4291
4292        // RemoveKey branch wins → k0 gone, only k1 remains; head = the RemoveKey AUM.
4293        assert_eq!(auth.state().keys.len(), 1);
4294        assert_eq!(auth.state().keys[0].public, k1.public);
4295        assert_eq!(
4296            auth.head(),
4297            branch_remove.hash(),
4298            "active head = RemoveKey branch"
4299        );
4300    }
4301
4302    /// End-to-end: replay a chain to an `Authority`, then verify it authorizes a node key signed by a
4303    /// trusted key — proving the replayed state drives `node_key_authorized` identically to
4304    /// `from_state`. A key removed by the chain no longer authorizes.
4305    #[test]
4306    fn replayed_authority_authorizes_node_end_to_end() {
4307        use ed25519_dalek::{Signer, SigningKey};
4308
4309        let signing = SigningKey::from_bytes(&[77u8; 32]);
4310        let trusted_pub = signing.verifying_key().to_bytes().to_vec();
4311        let trusted = AumKey {
4312            kind: KeyKind::Ed25519,
4313            votes: 1,
4314            public: trusted_pub.clone(),
4315            meta: Vec::new(),
4316        };
4317        // A second key we'll add then remove, to show a removed key can't authorize.
4318        let revoked_signing = SigningKey::from_bytes(&[88u8; 32]);
4319        let revoked_pub = revoked_signing.verifying_key().to_bytes().to_vec();
4320        let revoked = AumKey {
4321            kind: KeyKind::Ed25519,
4322            votes: 1,
4323            public: revoked_pub.clone(),
4324            meta: Vec::new(),
4325        };
4326
4327        let a0 = genesis_add(trusted);
4328        let a1 = child(&a0, AumKind::AddKey, Some(revoked), Vec::new());
4329        let a2 = child(&a1, AumKind::RemoveKey, None, revoked_pub.clone());
4330        let auth = Authority::from_chain(&[a0, a1, a2]).unwrap();
4331
4332        let node_key = alloc::vec![7u8; 32];
4333        // Signature from the still-trusted key authorizes.
4334        let mut sig = NodeKeySignature {
4335            sig_kind: SigKind::Direct,
4336            pubkey: node_key.clone(),
4337            key_id: trusted_pub.clone(),
4338            signature: Vec::new(),
4339            nested: None,
4340            wrapping_pubkey: Vec::new(),
4341        };
4342        sig.signature = signing.sign(&sig.sig_hash()).to_bytes().to_vec();
4343        assert!(
4344            auth.node_key_authorized(&node_key, &sig.to_cbor(true).to_vec())
4345                .is_ok(),
4346            "the replayed authority must authorize a node signed by a still-trusted key"
4347        );
4348
4349        // The same node key signed by the REVOKED key must be rejected (key no longer in state).
4350        let mut bad = NodeKeySignature {
4351            sig_kind: SigKind::Direct,
4352            pubkey: node_key.clone(),
4353            key_id: revoked_pub.clone(),
4354            signature: Vec::new(),
4355            nested: None,
4356            wrapping_pubkey: Vec::new(),
4357        };
4358        bad.signature = revoked_signing.sign(&bad.sig_hash()).to_bytes().to_vec();
4359        assert_eq!(
4360            auth.node_key_authorized(&node_key, &bad.to_cbor(true).to_vec())
4361                .unwrap_err(),
4362            TkaError::UntrustedKey,
4363            "a key the chain removed must not authorize"
4364        );
4365    }
4366
4367    /// Genesis-kind guard (Go `computeStateAt` "invalid genesis update"): a chain whose first AUM is
4368    /// a `RemoveKey`/`UpdateKey` is rejected. (A genesis `NoOp`/`AddKey`/`Checkpoint` is allowed.)
4369    #[test]
4370    fn replay_rejects_invalid_genesis_kind() {
4371        // A bare RemoveKey as genesis: no key to remove → today this is BadKeyState, but the genesis
4372        // guard catches an UpdateKey before the key lookup. Use UpdateKey to exercise the guard arm.
4373        let mut g = genesis_add(test_aum_key(1, 1));
4374        g.message_kind = AumKind::UpdateKey;
4375        g.key = None;
4376        g.key_id = test_aum_key(1, 1).public.clone();
4377        assert_eq!(
4378            Authority::from_chain(&[g]).unwrap_err(),
4379            TkaError::BadChain,
4380            "an UpdateKey cannot be a genesis AUM"
4381        );
4382    }
4383
4384    /// Genesis must carry no parent: a first AUM with a non-None `prev_aum_hash` (i.e. a chain
4385    /// *suffix* mis-supplied as a whole chain) is rejected as `BadParent`, not silently re-rooted.
4386    #[test]
4387    fn replay_rejects_genesis_with_parent() {
4388        let mut g = genesis_add(test_aum_key(1, 1));
4389        g.prev_aum_hash = Some(AumHash([0x11; 32])); // names a parent not in the slice
4390        assert_eq!(
4391            Authority::from_chain(&[g]).unwrap_err(),
4392            TkaError::BadParent,
4393            "a genesis AUM that names a parent must be rejected (not treated as genesis)"
4394        );
4395    }
4396
4397    /// Checkpoint StateID guard (Go "checkpointed state has an incorrect stateID"): a genesis
4398    /// checkpoint seeds the StateID; a later checkpoint with a different StateID is rejected.
4399    #[test]
4400    fn replay_rejects_checkpoint_stateid_mismatch() {
4401        let k = test_aum_key(1, 1);
4402        // Genesis checkpoint seeds StateID (7, 0).
4403        let genesis = Aum {
4404            message_kind: AumKind::Checkpoint,
4405            prev_aum_hash: None,
4406            key: None,
4407            key_id: Vec::new(),
4408            state: Some(AumState {
4409                last_aum_hash: None,
4410                disablement_values: Some(Vec::new()),
4411                keys: Some(alloc::vec![k.clone()]),
4412                state_id1: 7,
4413                state_id2: 0,
4414            }),
4415            votes: None,
4416            meta: Vec::new(),
4417            signatures: Vec::new(),
4418        };
4419        // A second checkpoint, correctly chained, but with a FOREIGN StateID (8, 0).
4420        let bad = Aum {
4421            message_kind: AumKind::Checkpoint,
4422            prev_aum_hash: Some(genesis.hash()),
4423            key: None,
4424            key_id: Vec::new(),
4425            state: Some(AumState {
4426                last_aum_hash: Some(genesis.hash()),
4427                disablement_values: Some(Vec::new()),
4428                keys: Some(alloc::vec![k.clone()]),
4429                state_id1: 8, // ← mismatch
4430                state_id2: 0,
4431            }),
4432            votes: None,
4433            meta: Vec::new(),
4434            signatures: Vec::new(),
4435        };
4436        assert_eq!(
4437            Authority::from_chain(&[genesis.clone(), bad]).unwrap_err(),
4438            TkaError::BadKeyState,
4439            "a checkpoint with a foreign StateID belongs to another authority and must be rejected"
4440        );
4441        // A matching-StateID second checkpoint is accepted.
4442        let ok = Aum {
4443            message_kind: AumKind::Checkpoint,
4444            prev_aum_hash: Some(genesis.hash()),
4445            key: None,
4446            key_id: Vec::new(),
4447            state: Some(AumState {
4448                last_aum_hash: Some(genesis.hash()),
4449                disablement_values: Some(Vec::new()),
4450                keys: Some(alloc::vec![k]),
4451                state_id1: 7,
4452                state_id2: 0,
4453            }),
4454            votes: None,
4455            meta: Vec::new(),
4456            signatures: Vec::new(),
4457        };
4458        assert!(Authority::from_chain(&[genesis, ok]).is_ok());
4459    }
4460
4461    /// `from_forked_chain` rejects a multi-step branch rather than mis-resolving it (Go re-runs
4462    /// pickNextAUM per link; judging a whole branch by its first AUM could diverge).
4463    #[test]
4464    fn forked_chain_rejects_multistep_branch() {
4465        let k0 = test_aum_key(1, 1);
4466        let a0 = genesis_add(k0.clone());
4467        let b1 = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
4468        // A two-AUM branch (b1 → b2): must be rejected as BadChain.
4469        let b2 = child(&b1, AumKind::NoOp, None, alloc::vec![2]);
4470        let single = [child(&a0, AumKind::NoOp, None, alloc::vec![3])];
4471        let multi = [b1, b2];
4472        assert_eq!(
4473            Authority::from_forked_chain(&[a0], &[&single[..], &multi[..]]).unwrap_err(),
4474            TkaError::BadChain,
4475            "a multi-step branch must be rejected, not judged by its first AUM"
4476        );
4477    }
4478
4479    /// Cross-implementation Known-Answer-Test for the **AUM** type: [`Aum::serialize`],
4480    /// [`Aum::hash`] (Go `AUM.Hash`), and [`Aum::sig_hash`] (Go `AUM.SigHash`) must byte-match the
4481    /// REAL `tailscale.com/tka` package, version **v1.100.0** (toolchain **go1.26.3+**).
4482    ///
4483    /// Provenance: every golden below is authoritative upstream output produced by the Go generator
4484    /// at `tests/vectors/gen/tka/main.go` (which imports the real `tailscale.com/tka`, builds one
4485    /// `tka.AUM` per `MessageKind`, and dumps `AUM.Serialize()`/`AUM.Hash()`/`AUM.SigHash()` hex).
4486    /// The same values are committed for provenance at `tests/vectors/tka_aum_hash_golden.json`.
4487    /// This is the missing half of axis-B for AUM: the sibling
4488    /// [`aum_serialize_matches_go_test_serialization_vectors`] test pins Go's *Serialize()* literals
4489    /// from `tka/aum_test.go`, but no Go-produced *AUM.Hash()* digest was pinned until now — so an
4490    /// error in the BLAKE2s-over-canonical-CBOR digest (the value that links the whole chain and is
4491    /// signed) would have gone undetected. Here `hash`/`sig_hash` are pinned to Go directly.
4492    ///
4493    /// Covered kinds: AddKey (genesis, with a real Key25519 + meta), RemoveKey, UpdateKey
4494    /// (votes+meta), a signed AddKey (Signatures at CBOR key 23), and a Checkpoint with a populated
4495    /// `State`. The signed AUM additionally proves `hash() != sig_hash()` — i.e. `Hash()` covers the
4496    /// signatures and `SigHash()` excludes them, exactly as Go's `AUM.SigHash` nils `Signatures`
4497    /// before serializing.
4498    #[test]
4499    fn aum_hash_sighash_matches_go_golden() {
4500        // Deterministic field material — identical to the Go generator's inputs.
4501        let prev = AumHash({
4502            let mut a = [0u8; AUM_HASH_LEN];
4503            let mut i = 0;
4504            while i < AUM_HASH_LEN {
4505                a[i] = 0x20u8.wrapping_add(i as u8);
4506                i += 1;
4507            }
4508            a
4509        });
4510        let key_pub: Vec<u8> = (0..32u16).map(|i| 0x40u8.wrapping_add(i as u8)).collect();
4511        let key_pub2: Vec<u8> = (0..32u16).map(|i| 0x60u8.wrapping_add(i as u8)).collect();
4512        let sig_bytes: Vec<u8> = (0..64u16).map(|i| 0x80u8.wrapping_add(i as u8)).collect();
4513
4514        // Assert one AUM's serialize/hash/sig_hash against the authoritative Go hex.
4515        let check = |label: &str, aum: &Aum, ser_hex: &str, hash_hex: &str, sig_hash_hex: &str| {
4516            assert_eq!(
4517                hex(&aum.serialize()),
4518                ser_hex,
4519                "{label}: Aum::serialize diverged from Go tka v1.100.0"
4520            );
4521            assert_eq!(
4522                hex(&aum.hash().0),
4523                hash_hex,
4524                "{label}: Aum::hash (Go AUM.Hash) diverged from Go tka v1.100.0"
4525            );
4526            assert_eq!(
4527                hex(&aum.sig_hash()),
4528                sig_hash_hex,
4529                "{label}: Aum::sig_hash (Go AUM.SigHash) diverged from Go tka v1.100.0"
4530            );
4531        };
4532
4533        // (a) AddKey genesis (nil prev) with a real Key25519 + meta {"name":"alpha"}.
4534        let add_key = Aum {
4535            message_kind: AumKind::AddKey,
4536            prev_aum_hash: None,
4537            key: Some(AumKey {
4538                kind: KeyKind::Ed25519,
4539                votes: 7,
4540                public: key_pub.clone(),
4541                meta: alloc::vec![("name".into(), "alpha".into())],
4542            }),
4543            key_id: Vec::new(),
4544            state: None,
4545            votes: None,
4546            meta: Vec::new(),
4547            signatures: Vec::new(),
4548        };
4549        check(
4550            "AddKey",
4551            &add_key,
4552            "a3010102f603a401010207035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f0ca1646e616d6565616c706861",
4553            "921ca301077ae2b892ca8c40b3315e5f2a9ccc9ac99eec784e93b323577e1e14",
4554            "921ca301077ae2b892ca8c40b3315e5f2a9ccc9ac99eec784e93b323577e1e14",
4555        );
4556
4557        // (b) RemoveKey with a non-nil prev.
4558        let remove_key = Aum {
4559            message_kind: AumKind::RemoveKey,
4560            prev_aum_hash: Some(prev),
4561            key: None,
4562            key_id: key_pub.clone(),
4563            state: None,
4564            votes: None,
4565            meta: Vec::new(),
4566            signatures: Vec::new(),
4567        };
4568        check(
4569            "RemoveKey",
4570            &remove_key,
4571            "a30102025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f045820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
4572            "46be17c398760d2e649147b06a68e8576ee37c59cf0558046182f48ba20aa912",
4573            "46be17c398760d2e649147b06a68e8576ee37c59cf0558046182f48ba20aa912",
4574        );
4575
4576        // (c) UpdateKey with votes=2 + meta {"role":"ci"}.
4577        let update_key = Aum {
4578            message_kind: AumKind::UpdateKey,
4579            prev_aum_hash: Some(prev),
4580            key: None,
4581            key_id: key_pub.clone(),
4582            state: None,
4583            votes: Some(2),
4584            meta: alloc::vec![("role".into(), "ci".into())],
4585            signatures: Vec::new(),
4586        };
4587        check(
4588            "UpdateKey",
4589            &update_key,
4590            "a50104025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f045820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f060207a164726f6c65626369",
4591            "42d160d81a0922511b4ce60050dba76569f79423b6e721c5040faf414c250e43",
4592            "42d160d81a0922511b4ce60050dba76569f79423b6e721c5040faf414c250e43",
4593        );
4594
4595        // (e) AddKey carrying one Signature (CBOR key 23). hash (incl sigs) MUST differ from
4596        // sig_hash (excl sigs) — the property the whole signing scheme depends on.
4597        let signed = Aum {
4598            message_kind: AumKind::AddKey,
4599            prev_aum_hash: Some(prev),
4600            key: Some(AumKey {
4601                kind: KeyKind::Ed25519,
4602                votes: 1,
4603                public: key_pub.clone(),
4604                meta: Vec::new(),
4605            }),
4606            key_id: Vec::new(),
4607            state: None,
4608            votes: None,
4609            meta: Vec::new(),
4610            signatures: alloc::vec![AumSignature {
4611                key_id: key_pub2.clone(),
4612                signature: sig_bytes.clone(),
4613            }],
4614        };
4615        check(
4616            "AddKey+Signature",
4617            &signed,
4618            "a40101025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f03a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f1781a2015820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f025840808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
4619            "e70332d9a03b205577204f1896bb8dcb7c8f8894cc87a5b5c4d5dabcdf6ef135",
4620            "0a7a0ecdf854ad99e8728a1de89ac23c1f08457132a537a3add9594749a7f536",
4621        );
4622        assert_ne!(
4623            hex(&signed.hash().0),
4624            hex(&signed.sig_hash()),
4625            "Hash() must cover Signatures while SigHash() excludes them (Go AUM.SigHash nils them)"
4626        );
4627
4628        // (f) Checkpoint carrying a State with a POPULATED DisablementValues [{0xaa,0xbb}] + 1 key.
4629        // This is the common real shape and matches Go byte-for-byte (the array encoding is correct).
4630        let checkpoint = Aum {
4631            message_kind: AumKind::Checkpoint,
4632            prev_aum_hash: Some(prev),
4633            key: None,
4634            key_id: Vec::new(),
4635            state: Some(AumState {
4636                last_aum_hash: Some(prev),
4637                disablement_values: Some(alloc::vec![alloc::vec![0xaa, 0xbb]]),
4638                keys: Some(alloc::vec![AumKey {
4639                    kind: KeyKind::Ed25519,
4640                    votes: 1,
4641                    public: key_pub.clone(),
4642                    meta: Vec::new(),
4643                }]),
4644                state_id1: 0,
4645                state_id2: 0,
4646            }),
4647            votes: None,
4648            meta: Vec::new(),
4649            signatures: Vec::new(),
4650        };
4651        check(
4652            "Checkpoint(populated DisablementValues)",
4653            &checkpoint,
4654            "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f028142aabb0381a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
4655            "38c35c51580dcb75212d79c07695c8ec8b399b59ba552ea93f080b126fdaa0ae",
4656            "38c35c51580dcb75212d79c07695c8ec8b399b59ba552ea93f080b126fdaa0ae",
4657        );
4658    }
4659
4660    /// Go-match golden for the nil-`DisablementValues` checkpoint — the case that was a recorded
4661    /// interop bug (Rust forced an empty array `0x80` where Go emits CBOR null `0xf6`) and is now
4662    /// FIXED by making `AumState.{disablement_values,keys}` `Option` (None = Go nil = `0xf6`).
4663    ///
4664    /// [`disablement_value`] (Go `tka.DisablementKDF`, Argon2i) byte-for-byte vs the authoritative Go
4665    /// goldens emitted from the real `tailscale.com/tka.DisablementKDF` (v1.100.0). The same values are
4666    /// committed for provenance at `tests/vectors/tka_disablement_golden.json`. A divergence means a
4667    /// lock this node creates via `tka_init` could never be disabled (the operator's secret would
4668    /// hash to a value not in the stored `DisablementValues`) — so this is the load-bearing parity
4669    /// guard for the disablement KDF. Notably proves we use Argon2**i** (RustCrypto defaults to
4670    /// Argon2id, which would produce entirely different digests).
4671    #[test]
4672    fn disablement_value_matches_go_golden() {
4673        let check = |label: &str, secret: &[u8], want_hex: &str| {
4674            assert_eq!(
4675                hex(&disablement_value(secret)),
4676                want_hex,
4677                "{label}: disablement_value diverged from Go tka.DisablementKDF v1.100.0"
4678            );
4679        };
4680        // Goldens straight from `tka.DisablementKDF` (tests/vectors/gen/tka → tka_disablement_golden.json).
4681        check(
4682            "all-0xA5 (tka test-helper secret)",
4683            &[0xA5u8; 32],
4684            "c3fea8a0d70ede2555990ca60d70a8a03cbe627d2c9f3cb0e2ba7093d0884e2f",
4685        );
4686        check(
4687            "all-zero 32B",
4688            &[0u8; 32],
4689            "f56df7e85d257a51c0aa17d2600502182359a1224b892ff4667002a7bc71aa56",
4690        );
4691        check(
4692            "all-0xFF 32B",
4693            &[0xFFu8; 32],
4694            "fe74d82e0971202e69143984381f1834f0f3364e61e239a7d935c218e321811f",
4695        );
4696        // Short secret — the KDF accepts any length (Go feeds 32B but imposes no bound).
4697        check(
4698            "short secret \"hello\"",
4699            b"hello",
4700            "61d3beb9f247d9bc31a677599a919e67332af3cced412722256d6f1cf2adbf8d",
4701        );
4702        // The derived value is exactly DISABLEMENT_LENGTH (32) bytes — the checkpoint validator's
4703        // per-value length requirement is satisfied by construction.
4704        assert_eq!(disablement_value(b"x").len(), DISABLEMENT_LENGTH);
4705    }
4706
4707    /// [`Aum::new_genesis_checkpoint`] builds a valid genesis Checkpoint that, signed by a key it
4708    /// contains (the "lock yourself in" case `tka_init` uses), self-certifies through the trust
4709    /// boundary [`VerifiedAumChain::verify`] — and folds to an authority trusting that key. Also
4710    /// pins the structural guards: empty keys / a wrong-length disablement value are rejected by the
4711    /// builder (it static_validates before returning), and a genesis signed by a key it does NOT
4712    /// contain is UntrustedKey.
4713    #[test]
4714    fn genesis_checkpoint_builds_signs_and_self_certifies() {
4715        use ed25519_dalek::SigningKey;
4716
4717        let sk = SigningKey::from_bytes(&[1u8; 32]);
4718        let pubk = sk.verifying_key().to_bytes().to_vec();
4719        let key = AumKey {
4720            kind: KeyKind::Ed25519,
4721            votes: 1,
4722            public: pubk.clone(),
4723            meta: Vec::new(),
4724        };
4725        let dval = disablement_value(b"the-operator-disablement-secret").to_vec();
4726
4727        // Build + self-sign the genesis with the key it establishes.
4728        let mut genesis =
4729            Aum::new_genesis_checkpoint(alloc::vec![key.clone()], alloc::vec![dval.clone()])
4730                .expect("a 1-key, 1-disablement-value checkpoint is valid");
4731        assert_eq!(genesis.message_kind, AumKind::Checkpoint);
4732        assert!(genesis.prev_aum_hash.is_none(), "genesis has no parent");
4733        // Parity guard for the secret-vs-hash distinction tka_init relies on: the genesis stores the
4734        // Argon2i DISABLEMENT VALUE (the hash), never the raw secret. A swap (storing the raw secret)
4735        // would make the lock undisablable; this pins it. The stored value must equal
4736        // disablement_value(secret) AND differ from the raw secret bytes.
4737        let secret = b"the-operator-disablement-secret";
4738        let stored = genesis
4739            .state
4740            .as_ref()
4741            .unwrap()
4742            .disablement_values
4743            .as_ref()
4744            .unwrap();
4745        assert_eq!(stored.len(), 1);
4746        assert_eq!(
4747            stored[0],
4748            disablement_value(secret).to_vec(),
4749            "genesis must store the Argon2i disablement VALUE (hash), not the raw secret"
4750        );
4751        assert_ne!(
4752            stored[0].as_slice(),
4753            secret.as_slice(),
4754            "the stored value must be the hash, never the raw secret"
4755        );
4756        genesis.sign(&sk);
4757
4758        // Self-certifies through the trust boundary (genesis Checkpoint is verified against the keys
4759        // it embeds), folding to an authority that trusts the key.
4760        let chain = VerifiedAumChain::verify(&[genesis.clone()])
4761            .expect("a genesis checkpoint self-signed by an embedded key must verify");
4762        let auth = Authority::from_verified_chain(chain);
4763        assert_eq!(auth.head(), genesis.hash());
4764        assert!(auth.key_trusted(&pubk), "the seeded key is trusted");
4765
4766        // A genesis signed by a key it does NOT establish is untrusted.
4767        let other = SigningKey::from_bytes(&[2u8; 32]);
4768        let mut wrong = Aum::new_genesis_checkpoint(alloc::vec![key], alloc::vec![dval]).unwrap();
4769        wrong.sign(&other);
4770        assert_eq!(
4771            VerifiedAumChain::verify(&[wrong]).unwrap_err(),
4772            TkaError::UntrustedKey
4773        );
4774
4775        // Builder rejects structurally-invalid genesis up front (static_validate).
4776        assert!(
4777            Aum::new_genesis_checkpoint(Vec::new(), alloc::vec![disablement_value(b"s").to_vec()])
4778                .is_err(),
4779            "a checkpoint with no trusted keys is rejected"
4780        );
4781        assert!(
4782            Aum::new_genesis_checkpoint(
4783                alloc::vec![AumKey {
4784                    kind: KeyKind::Ed25519,
4785                    votes: 1,
4786                    public: alloc::vec![9u8; 32],
4787                    meta: Vec::new(),
4788                }],
4789                alloc::vec![alloc::vec![0u8; 31]] // 31 bytes ≠ DISABLEMENT_LENGTH
4790            )
4791            .is_err(),
4792            "a wrong-length disablement value is rejected"
4793        );
4794    }
4795
4796    /// When an `AUMCheckpoint`'s embedded `State` has a **nil** `DisablementValues` (Go's zero value,
4797    /// the overwhelmingly common case), Go's `fxamacker/cbor` CTAP2 encoder emits the field as
4798    /// **CBOR null `0xf6`**; a populated slice encodes as an array (proven by the populated case in
4799    /// [`aum_hash_sighash_matches_go_golden`]). This test pins the Go bytes + Hash for the nil case
4800    /// and asserts the Rust output now byte-matches — guarding the fix against regression.
4801    #[test]
4802    fn aum_checkpoint_nil_disablement_matches_go() {
4803        let prev = AumHash({
4804            let mut a = [0u8; AUM_HASH_LEN];
4805            let mut i = 0;
4806            while i < AUM_HASH_LEN {
4807                a[i] = 0x20u8.wrapping_add(i as u8);
4808                i += 1;
4809            }
4810            a
4811        });
4812        let key_pub: Vec<u8> = (0..32u16).map(|i| 0x40u8.wrapping_add(i as u8)).collect();
4813        let key_pub2: Vec<u8> = (0..32u16).map(|i| 0x60u8.wrapping_add(i as u8)).collect();
4814
4815        // Checkpoint with a State whose DisablementValues is EMPTY (== Go nil zero value), 2 keys.
4816        let checkpoint = Aum {
4817            message_kind: AumKind::Checkpoint,
4818            prev_aum_hash: Some(prev),
4819            key: None,
4820            key_id: Vec::new(),
4821            state: Some(AumState {
4822                last_aum_hash: Some(prev),
4823                disablement_values: Some(Vec::new()),
4824                keys: Some(alloc::vec![
4825                    AumKey {
4826                        kind: KeyKind::Ed25519,
4827                        votes: 1,
4828                        public: key_pub.clone(),
4829                        meta: Vec::new(),
4830                    },
4831                    AumKey {
4832                        kind: KeyKind::Ed25519,
4833                        votes: 3,
4834                        public: key_pub2.clone(),
4835                        meta: alloc::vec![("k".into(), "v".into())],
4836                    },
4837                ]),
4838                state_id1: 0,
4839                state_id2: 0,
4840            }),
4841            votes: None,
4842            meta: Vec::new(),
4843            signatures: Vec::new(),
4844        };
4845
4846        // Authoritative Go bytes (generator case "checkpoint: State w/ nil DisablementValues"):
4847        // the State map is `…02 f6 03 82 …` — a NIL DisablementValues encodes as CBOR null (0xf6).
4848        // FIXED: `AumState.disablement_values` is now `Option`, so the nil case (`None`) is
4849        // representable and encodes as null, byte-matching Go. (Was a recorded interop bug where the
4850        // `Vec` type forced an empty array `0x80` and diverged the checkpoint Hash from Go.)
4851        const GO_SERIALIZE: &str = "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f02f60382a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5fa401010203035820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f0ca1616b6176";
4852        const GO_HASH: &str = "cae17cc938c5a954cd4389d83c6afe4d3487edac38b94824bec3312b82f35710";
4853
4854        // Re-point the checkpoint's State to a genuinely-nil DisablementValues (`None`), which is the
4855        // case the Go golden above was generated from.
4856        let checkpoint = {
4857            let mut c = checkpoint;
4858            if let Some(state) = c.state.as_mut() {
4859                state.disablement_values = None;
4860            }
4861            c
4862        };
4863
4864        assert_eq!(
4865            hex(&checkpoint.serialize()),
4866            GO_SERIALIZE,
4867            "nil DisablementValues must encode as CBOR null (0xf6), byte-matching Go"
4868        );
4869        assert_eq!(
4870            hex(&checkpoint.hash().0),
4871            GO_HASH,
4872            "with the nil-vs-empty fix, the checkpoint chain-link Hash matches Go"
4873        );
4874    }
4875
4876    // =======================================================================================
4877    // MUST-1: AUM signature verification (`VerifiedAumChain` / Go `aumVerify`). The trust
4878    // boundary for a control-supplied chain — an AUM may advance the trusted-key state only if
4879    // every signature on it verifies against a key already trusted at its parent.
4880    // =======================================================================================
4881
4882    /// The signing key whose public key `test_aum_key(seed, _)` derives — so a key trusted via
4883    /// `test_aum_key(seed, v)` can be made to actually sign an AUM.
4884    fn signer_for(seed: u8) -> ed25519_dalek::SigningKey {
4885        ed25519_dalek::SigningKey::from_bytes(&[seed; 32])
4886    }
4887
4888    /// Sign `aum` with each `(seed)` signer, appending a real `AumSignature` over `aum.sig_hash()`.
4889    /// The signer's public key is `test_aum_key(seed, _).public`, so signing with `seed` produces a
4890    /// signature that a state trusting `test_aum_key(seed, _)` will accept.
4891    fn sign_aum(aum: &mut Aum, seeds: &[u8]) {
4892        // Delegate to the production `Aum::sign` so this helper and the public signer can never
4893        // drift. `Aum::sign` recomputes `sig_hash()` per call, which is identical for every signer
4894        // because the preimage omits ALL signatures (CBOR key 23) — appending one signer's signature
4895        // never perturbs the next signer's preimage. Behaviourally identical to the old inline loop.
4896        for &seed in seeds {
4897            aum.sign(&signer_for(seed));
4898        }
4899    }
4900
4901    /// A genesis `AddKey` that adds `test_aum_key(seed, votes)` and is self-signed by that very key
4902    /// — the bootstrapping shape (Go verifies a genesis against the keys it itself establishes).
4903    fn signed_genesis_add(seed: u8, votes: u32) -> Aum {
4904        let mut g = genesis_add(test_aum_key(seed, votes));
4905        sign_aum(&mut g, &[seed]);
4906        g
4907    }
4908
4909    /// Happy path: a self-signed genesis followed by a child signed by the trusted genesis key
4910    /// verifies, and `from_verified_chain` yields the same state as the structural `from_chain`.
4911    #[test]
4912    fn verified_chain_accepts_properly_signed_chain() {
4913        let g = signed_genesis_add(1, 1);
4914        // Child adds a second key, signed by the trusted key from the genesis (seed 1).
4915        let mut a1 = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4916        sign_aum(&mut a1, &[1]);
4917
4918        let chain = [g.clone(), a1.clone()];
4919        let verified = VerifiedAumChain::verify(&chain).expect("a properly signed chain verifies");
4920        let auth = Authority::from_verified_chain(verified);
4921
4922        assert_eq!(auth.head(), a1.hash(), "head = last AUM");
4923        assert_eq!(auth.state().keys.len(), 2, "both keys trusted");
4924        // The verified-path state must equal the structural-path state for an authentic chain.
4925        let structural = Authority::from_chain(&chain).unwrap();
4926        assert_eq!(auth.state(), structural.state());
4927        assert_eq!(auth.head(), structural.head());
4928    }
4929
4930    /// An unsigned AUM (no signatures at all) is rejected — Go `aumVerify` "unsigned AUM". This
4931    /// holds even for the genesis.
4932    #[test]
4933    fn verified_chain_rejects_unsigned_aum() {
4934        // Unsigned genesis.
4935        let g = genesis_add(test_aum_key(1, 1));
4936        assert_eq!(
4937            VerifiedAumChain::verify(core::slice::from_ref(&g)).unwrap_err(),
4938            TkaError::UnsignedAum,
4939            "an unsigned genesis must be rejected"
4940        );
4941
4942        // Signed genesis, but an unsigned child.
4943        let sg = signed_genesis_add(1, 1);
4944        let a1 = child(&sg, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4945        assert_eq!(
4946            VerifiedAumChain::verify(&[sg, a1]).unwrap_err(),
4947            TkaError::UnsignedAum,
4948            "an unsigned non-genesis AUM must be rejected"
4949        );
4950    }
4951
4952    /// THE headline security property: a malicious control plane inserts an `AddKey` that adds the
4953    /// attacker's own key, signed by the attacker (a key NOT trusted in the current state). MUST-1
4954    /// rejects it as `UntrustedKey` — so the forged key never reaches a live `Authority`. Without
4955    /// the signature gate, `from_chain` would happily fold it (demonstrated) — which is exactly the
4956    /// tailnet-lock-defeating forgery the type-enforced `VerifiedAumChain` prevents.
4957    #[test]
4958    fn verified_chain_rejects_forged_addkey_from_untrusted_signer() {
4959        let g = signed_genesis_add(1, 1); // only key seed=1 is trusted
4960        // Attacker forges an AddKey inserting their own key (seed 9), signed by seed 9 (untrusted).
4961        let mut forged = child(&g, AumKind::AddKey, Some(test_aum_key(9, 99)), Vec::new());
4962        sign_aum(&mut forged, &[9]);
4963
4964        assert_eq!(
4965            VerifiedAumChain::verify(&[g.clone(), forged.clone()]).unwrap_err(),
4966            TkaError::UntrustedKey,
4967            "an AddKey signed only by an untrusted key must be rejected"
4968        );
4969        // Contrast: the structural-only `from_chain` (NOT a trust boundary) DOES fold the forgery,
4970        // proving why the type-enforced verified path is necessary.
4971        let structural = Authority::from_chain(&[g, forged]).unwrap();
4972        assert_eq!(
4973            structural.state().keys.len(),
4974            2,
4975            "structural from_chain folds the forged key — exactly why it is not a trust boundary"
4976        );
4977    }
4978
4979    /// A signature whose `key_id` IS trusted but whose bytes were produced over different content
4980    /// (here: signed by the wrong private key but labelled with the trusted key's id) fails the
4981    /// cryptographic check → `BadSignature`.
4982    #[test]
4983    fn verified_chain_rejects_tampered_signature() {
4984        let g = signed_genesis_add(1, 1);
4985        let mut a1 = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4986        // Label the signature with the trusted key's id (seed 1) but sign with the WRONG key.
4987        use ed25519_dalek::Signer;
4988        let wrong = signer_for(42);
4989        a1.signatures.push(AumSignature {
4990            key_id: signer_for(1).verifying_key().to_bytes().to_vec(),
4991            signature: wrong.sign(&a1.sig_hash()).to_bytes().to_vec(),
4992        });
4993        assert_eq!(
4994            VerifiedAumChain::verify(&[g, a1]).unwrap_err(),
4995            TkaError::BadSignature,
4996            "a signature that doesn't verify under the named trusted key is rejected"
4997        );
4998    }
4999
5000    /// Every signature must verify (Go loops over all, failing on the first bad one): a child with
5001    /// one valid trusted signature AND one bad/untrusted signature is still rejected.
5002    #[test]
5003    fn verified_chain_requires_all_signatures_valid() {
5004        let g = signed_genesis_add(1, 1);
5005        let mut a1 = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
5006        // First a valid signature by the trusted key, then a second by an untrusted key.
5007        sign_aum(&mut a1, &[1]); // valid (seed 1 trusted)
5008        sign_aum(&mut a1, &[7]); // untrusted (seed 7 not in state)
5009        assert_eq!(
5010            VerifiedAumChain::verify(&[g, a1]).unwrap_err(),
5011            TkaError::UntrustedKey,
5012            "a single untrusted signature rejects the AUM even alongside a valid one"
5013        );
5014    }
5015
5016    /// A genesis `Checkpoint` self-certifies against the keys it embeds (Go
5017    /// `aumVerify(bootstrap, *bootstrap.State, true)`): the checkpoint's signature must verify
5018    /// against a key inside its own `State`. The embedded `State` must itself be Go-valid (≥1
5019    /// disablement value of 32 bytes, ≥1 key) — `static_validate_checkpoint` enforces that.
5020    #[test]
5021    fn verified_chain_genesis_checkpoint_self_certifies() {
5022        let trusted = test_aum_key(1, 1);
5023        let mut g = Aum {
5024            message_kind: AumKind::Checkpoint,
5025            prev_aum_hash: None,
5026            key: None,
5027            key_id: Vec::new(),
5028            state: Some(AumState {
5029                last_aum_hash: None,
5030                // A valid checkpoint needs ≥1 disablement value, each exactly 32 bytes.
5031                disablement_values: Some(alloc::vec![alloc::vec![0xD5u8; DISABLEMENT_LENGTH]]),
5032                keys: Some(alloc::vec![trusted.clone()]),
5033                state_id1: 0,
5034                state_id2: 0,
5035            }),
5036            votes: None,
5037            meta: Vec::new(),
5038            signatures: Vec::new(),
5039        };
5040        // Unsigned → rejected.
5041        assert_eq!(
5042            VerifiedAumChain::verify(&[g.clone()]).unwrap_err(),
5043            TkaError::UnsignedAum
5044        );
5045        // Signed by the key embedded in its own State → accepted.
5046        sign_aum(&mut g, &[1]);
5047        let verified = VerifiedAumChain::verify(&[g.clone()])
5048            .expect("a checkpoint signed by an embedded key self-certifies");
5049        let auth = Authority::from_verified_chain(verified);
5050        assert_eq!(auth.state().keys.len(), 1);
5051        assert_eq!(auth.head(), g.hash());
5052    }
5053
5054    /// A genesis `Checkpoint` whose embedded `State` is malformed is rejected by
5055    /// `static_validate_checkpoint` (Go `staticValidateCheckpoint`), before any signature check.
5056    #[test]
5057    fn verified_chain_rejects_malformed_checkpoint_state() {
5058        let trusted = test_aum_key(1, 1);
5059        let mk = |state: AumState| {
5060            let mut g = Aum {
5061                message_kind: AumKind::Checkpoint,
5062                prev_aum_hash: None,
5063                key: None,
5064                key_id: Vec::new(),
5065                state: Some(state),
5066                votes: None,
5067                meta: Vec::new(),
5068                signatures: Vec::new(),
5069            };
5070            sign_aum(&mut g, &[1]);
5071            g
5072        };
5073        let base = AumState {
5074            last_aum_hash: None,
5075            disablement_values: Some(alloc::vec![alloc::vec![0xD5u8; DISABLEMENT_LENGTH]]),
5076            keys: Some(alloc::vec![trusted.clone()]),
5077            state_id1: 0,
5078            state_id2: 0,
5079        };
5080
5081        // No disablement values → rejected.
5082        let no_disable = AumState {
5083            disablement_values: None,
5084            ..base.clone()
5085        };
5086        assert_eq!(
5087            VerifiedAumChain::verify(&[mk(no_disable)]).unwrap_err(),
5088            TkaError::BadKeyState,
5089            "a checkpoint with no disablement value is rejected"
5090        );
5091
5092        // Disablement value of the wrong length → rejected.
5093        let bad_len = AumState {
5094            disablement_values: Some(alloc::vec![alloc::vec![0u8; 16]]),
5095            ..base.clone()
5096        };
5097        assert_eq!(
5098            VerifiedAumChain::verify(&[mk(bad_len)]).unwrap_err(),
5099            TkaError::BadKeyState,
5100            "a disablement value of the wrong length is rejected"
5101        );
5102
5103        // No keys → rejected.
5104        let no_keys = AumState {
5105            keys: Some(Vec::new()),
5106            ..base.clone()
5107        };
5108        assert_eq!(
5109            VerifiedAumChain::verify(&[mk(no_keys)]).unwrap_err(),
5110            TkaError::BadKeyState,
5111            "a checkpoint with no keys is rejected"
5112        );
5113
5114        // Duplicate keys → rejected.
5115        let dup_keys = AumState {
5116            keys: Some(alloc::vec![trusted.clone(), trusted.clone()]),
5117            ..base.clone()
5118        };
5119        assert_eq!(
5120            VerifiedAumChain::verify(&[mk(dup_keys)]).unwrap_err(),
5121            TkaError::BadKeyState,
5122            "a checkpoint with duplicate key ids is rejected"
5123        );
5124
5125        // F5: a NON-adjacent duplicate ([a, b, a]) is also caught (the prefix-scan dedup checks all
5126        // earlier elements, not just the neighbor).
5127        let nonadjacent_dup = AumState {
5128            keys: Some(alloc::vec![
5129                test_aum_key(1, 1),
5130                test_aum_key(2, 1),
5131                test_aum_key(1, 1)
5132            ]),
5133            ..base.clone()
5134        };
5135        assert_eq!(
5136            VerifiedAumChain::verify(&[mk(nonadjacent_dup)]).unwrap_err(),
5137            TkaError::BadKeyState,
5138            "a non-adjacent duplicate key id is rejected"
5139        );
5140
5141        // F4: more than MAX_KEYS (512) keys → rejected. Use distinct 32-byte public keys (index-
5142        // encoded) so there are no duplicate ids — only the `> MAX_KEYS` cap can be the failure.
5143        let distinct_over_cap: alloc::vec::Vec<AumKey> = (0u32..=(MAX_KEYS as u32))
5144            .map(|i| AumKey {
5145                kind: KeyKind::Ed25519,
5146                votes: 1,
5147                // Distinct 32-byte public keys by encoding the index — no dup, so only the >512 cap trips.
5148                public: {
5149                    let mut p = alloc::vec![0u8; 32];
5150                    p[0..4].copy_from_slice(&i.to_le_bytes());
5151                    p
5152                },
5153                meta: Vec::new(),
5154            })
5155            .collect();
5156        assert_eq!(distinct_over_cap.len(), MAX_KEYS + 1);
5157        let over_keys = AumState {
5158            keys: Some(distinct_over_cap),
5159            ..base.clone()
5160        };
5161        assert_eq!(
5162            VerifiedAumChain::verify(&[mk(over_keys)]).unwrap_err(),
5163            TkaError::BadKeyState,
5164            "a checkpoint with > MAX_KEYS keys is rejected"
5165        );
5166
5167        // F4: more than MAX_DISABLEMENT_VALUES (32) disablement values → rejected (each distinct).
5168        let over_disablements = AumState {
5169            disablement_values: Some(
5170                (0u8..=(MAX_DISABLEMENT_VALUES as u8))
5171                    .map(|i| {
5172                        let mut d = alloc::vec![0u8; DISABLEMENT_LENGTH];
5173                        d[0] = i;
5174                        d
5175                    })
5176                    .collect(),
5177            ),
5178            ..base
5179        };
5180        assert_eq!(
5181            VerifiedAumChain::verify(&[mk(over_disablements)]).unwrap_err(),
5182            TkaError::BadKeyState,
5183            "a checkpoint with > MAX_DISABLEMENT_VALUES disablement values is rejected"
5184        );
5185    }
5186
5187    /// A broken parent link is still caught on the verified path (the structural fold runs after the
5188    /// signature check for non-genesis AUMs).
5189    #[test]
5190    fn verified_chain_rejects_broken_parent_link() {
5191        let g = signed_genesis_add(1, 1);
5192        let mut orphan = child(&g, AumKind::NoOp, None, alloc::vec![9]);
5193        orphan.prev_aum_hash = Some(AumHash([0xAB; 32])); // wrong parent
5194        sign_aum(&mut orphan, &[1]); // validly signed, but mis-linked
5195        assert_eq!(
5196            VerifiedAumChain::verify(&[g, orphan]).unwrap_err(),
5197            TkaError::BadParent,
5198            "a validly-signed but mis-linked AUM is still rejected"
5199        );
5200    }
5201
5202    // ===== Aum::from_cbor — the decode inverse of Aum::serialize (issue #7 chunk 2, tsr-2dr) =====
5203
5204    /// `Aum::from_cbor(aum.serialize())` reconstructs the exact `Aum` for every message kind and
5205    /// optional-field combination. This is the core round-trip contract the sync/bootstrap path
5206    /// relies on: bytes control sends → `Aum` → verify/replay.
5207    #[test]
5208    fn aum_from_cbor_roundtrips_every_shape() {
5209        let cases: alloc::vec::Vec<(&str, Aum)> = alloc::vec![
5210            (
5211                "RemoveKey, genesis (null prev), key_id",
5212                Aum {
5213                    message_kind: AumKind::RemoveKey,
5214                    prev_aum_hash: None,
5215                    key: None,
5216                    key_id: alloc::vec![1, 2],
5217                    state: None,
5218                    votes: None,
5219                    meta: Vec::new(),
5220                    signatures: Vec::new(),
5221                },
5222            ),
5223            (
5224                "UpdateKey with votes + meta (text-keyed map)",
5225                Aum {
5226                    message_kind: AumKind::UpdateKey,
5227                    prev_aum_hash: None,
5228                    key: None,
5229                    key_id: alloc::vec![1, 2],
5230                    state: None,
5231                    votes: Some(2),
5232                    meta: alloc::vec![("a".into(), "b".into())],
5233                    signatures: Vec::new(),
5234                },
5235            ),
5236            (
5237                "AddKey with an embedded Key + non-null prev + signatures",
5238                Aum {
5239                    message_kind: AumKind::AddKey,
5240                    prev_aum_hash: Some(AumHash([0x11; AUM_HASH_LEN])),
5241                    key: Some(AumKey {
5242                        kind: KeyKind::Ed25519,
5243                        votes: 3,
5244                        public: alloc::vec![9, 8, 7],
5245                        meta: alloc::vec![("k".into(), "v".into())],
5246                    }),
5247                    key_id: Vec::new(),
5248                    state: None,
5249                    votes: None,
5250                    meta: Vec::new(),
5251                    signatures: alloc::vec![
5252                        AumSignature {
5253                            key_id: alloc::vec![1],
5254                            signature: Vec::new(), // nil → null on the wire
5255                        },
5256                        AumSignature {
5257                            key_id: alloc::vec![2, 3],
5258                            signature: alloc::vec![4, 5, 6],
5259                        },
5260                    ],
5261                },
5262            ),
5263            (
5264                "Checkpoint with full State (null + empty-array + populated arms)",
5265                Aum {
5266                    message_kind: AumKind::Checkpoint,
5267                    prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
5268                    key: None,
5269                    key_id: Vec::new(),
5270                    state: Some(AumState {
5271                        last_aum_hash: Some(AumHash([0xAB; AUM_HASH_LEN])),
5272                        disablement_values: Some(alloc::vec![alloc::vec![1, 2], alloc::vec![3]]),
5273                        keys: Some(alloc::vec![AumKey {
5274                            kind: KeyKind::Ed25519,
5275                            votes: 1,
5276                            public: alloc::vec![5, 6],
5277                            meta: Vec::new(),
5278                        }]),
5279                        state_id1: 7,
5280                        state_id2: 0, // omitted (omitempty)
5281                    }),
5282                    votes: None,
5283                    meta: Vec::new(),
5284                    signatures: Vec::new(),
5285                },
5286            ),
5287            (
5288                "Checkpoint with nil State arms (null) and empty disablement array",
5289                Aum {
5290                    message_kind: AumKind::Checkpoint,
5291                    prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
5292                    key: None,
5293                    key_id: Vec::new(),
5294                    state: Some(AumState {
5295                        last_aum_hash: None,                  // null
5296                        disablement_values: Some(Vec::new()), // empty array 0x80
5297                        keys: None,                           // null
5298                        state_id1: 0,
5299                        state_id2: 9,
5300                    }),
5301                    votes: None,
5302                    meta: Vec::new(),
5303                    signatures: Vec::new(),
5304                },
5305            ),
5306            (
5307                "NoOp, non-null prev, nothing else",
5308                Aum {
5309                    message_kind: AumKind::NoOp,
5310                    prev_aum_hash: Some(AumHash([0x42; AUM_HASH_LEN])),
5311                    key: None,
5312                    key_id: Vec::new(),
5313                    state: None,
5314                    votes: None,
5315                    meta: Vec::new(),
5316                    signatures: Vec::new(),
5317                },
5318            ),
5319        ];
5320
5321        for (label, aum) in cases {
5322            let bytes = aum.serialize();
5323            let decoded = Aum::from_cbor(&bytes)
5324                .unwrap_or_else(|e| panic!("from_cbor failed for {label:?}: {e}"));
5325            assert_eq!(decoded, aum, "round-trip mismatch for {label:?}");
5326            // And the decoded AUM re-serializes to the identical bytes (canonical-form preserved →
5327            // hash/sig_hash are stable across a decode/encode cycle, which the chain replayer needs).
5328            assert_eq!(
5329                decoded.serialize(),
5330                bytes,
5331                "re-serialize must be byte-identical for {label:?}"
5332            );
5333            assert_eq!(
5334                decoded.hash(),
5335                aum.hash(),
5336                "hash must survive round-trip for {label:?}"
5337            );
5338        }
5339    }
5340
5341    /// Decode the exact frozen Go `TestSerialization` byte vectors (the same literals asserted on the
5342    /// encode side) straight into `Aum`s — proving the decoder consumes real Go-produced bytes, not
5343    /// just our own encoder's output.
5344    #[test]
5345    fn aum_from_cbor_decodes_frozen_go_vectors() {
5346        // RemoveKey: a3 01 02 02 f6 04 42 01 02
5347        let remove_key = Aum::from_cbor(&[0xa3, 0x01, 0x02, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02])
5348            .expect("decode RemoveKey vector");
5349        assert_eq!(remove_key.message_kind, AumKind::RemoveKey);
5350        assert_eq!(remove_key.prev_aum_hash, None);
5351        assert_eq!(remove_key.key_id, alloc::vec![1, 2]);
5352
5353        // UpdateKey: a5 01 04 02 f6 04 42 01 02 06 02 07 a1 61 61 61 62
5354        let update_key = Aum::from_cbor(&[
5355            0xa5, 0x01, 0x04, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02, 0x06, 0x02, 0x07, 0xa1, 0x61,
5356            0x61, 0x61, 0x62,
5357        ])
5358        .expect("decode UpdateKey vector");
5359        assert_eq!(update_key.message_kind, AumKind::UpdateKey);
5360        assert_eq!(update_key.votes, Some(2));
5361        assert_eq!(
5362            update_key.meta,
5363            alloc::vec![(
5364                alloc::string::String::from("a"),
5365                alloc::string::String::from("b")
5366            )],
5367            "the text-keyed Meta map must decode to {{\"a\":\"b\"}}"
5368        );
5369
5370        // Signature: a3 01 01 02 f6 17 81 a2 01 41 01 02 f6
5371        let with_sig = Aum::from_cbor(&[
5372            0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6,
5373        ])
5374        .expect("decode Signature vector");
5375        assert_eq!(with_sig.message_kind, AumKind::AddKey);
5376        assert_eq!(with_sig.signatures.len(), 1);
5377        assert_eq!(with_sig.signatures[0].key_id, alloc::vec![1]);
5378        assert_eq!(
5379            with_sig.signatures[0].signature,
5380            Vec::<u8>::new(),
5381            "the nil Signature (CBOR null) must decode to an empty Vec"
5382        );
5383        // Byte-exact re-encode of every frozen vector.
5384        assert_eq!(
5385            with_sig.serialize(),
5386            alloc::vec![
5387                0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6
5388            ]
5389        );
5390    }
5391
5392    /// The `null` (0xf6) major-7 arm is accepted ONLY for null; other major-7 simple/float values
5393    /// are still rejected (fail-closed), and the `NodeKeySignature` path is unaffected because its
5394    /// `expect_bytes` rejects null where bytes are required.
5395    #[test]
5396    fn decode_value_accepts_only_null_in_major7() {
5397        // Bare null decodes.
5398        assert_eq!(decode_value(&[0xf6], 0).unwrap().0, Value::Null);
5399        // true (0xf5), false (0xf4), undefined (0xf7), a float64 (0xfb …) → rejected.
5400        for bad in [
5401            alloc::vec![0xf5u8],
5402            alloc::vec![0xf4],
5403            alloc::vec![0xf7],
5404            alloc::vec![0xfb, 0x40, 0x09, 0x21, 0xfb, 0x54, 0x44, 0x2d, 0x18],
5405        ] {
5406            assert!(
5407                decode_value(&bad, 0).is_err(),
5408                "major-7 value {bad:02x?} other than null must be rejected"
5409            );
5410        }
5411    }
5412
5413    /// `Aum::from_cbor` fails closed on malformed / adversarial input — never panics, never `Ok` on
5414    /// garbage. Complements the `cbor_decode_smoke` integration test (which targets the signature
5415    /// path) for the AUM path.
5416    #[test]
5417    fn aum_from_cbor_fails_closed() {
5418        // Empty input.
5419        assert!(Aum::from_cbor(&[]).is_err());
5420        // Not a map (a bare uint).
5421        assert!(Aum::from_cbor(&[0x00]).is_err());
5422        // Map missing the non-omitempty prev_aum_hash (only message_kind present): a1 01 03.
5423        assert!(
5424            Aum::from_cbor(&[0xa1, 0x01, 0x03]).is_err(),
5425            "an AUM without key 2 (prev_aum_hash) must be rejected (non-omitempty)"
5426        );
5427        // Unknown field key (99): a2 01 03 18 63 00.
5428        assert!(
5429            Aum::from_cbor(&[0xa2, 0x01, 0x03, 0x18, 0x63, 0x00]).is_err(),
5430            "an unknown AUM field key must be rejected"
5431        );
5432        // Unknown message kind (9): a2 01 09 02 f6.
5433        assert!(
5434            Aum::from_cbor(&[0xa2, 0x01, 0x09, 0x02, 0xf6]).is_err(),
5435            "an unknown message_kind must be rejected"
5436        );
5437        // Trailing byte after a complete AUM: (a2 01 03 02 f6) + 00.
5438        assert!(
5439            Aum::from_cbor(&[0xa2, 0x01, 0x03, 0x02, 0xf6, 0x00]).is_err(),
5440            "trailing bytes after the AUM must be rejected"
5441        );
5442        // prev_aum_hash present but wrong length (31 bytes) → rejected.
5443        let mut short_prev = alloc::vec![0xa2u8, 0x01, 0x03, 0x02, 0x58, 0x1f];
5444        short_prev.extend(core::iter::repeat_n(0u8, 31));
5445        assert!(
5446            Aum::from_cbor(&short_prev).is_err(),
5447            "a prev_aum_hash that is not 32 bytes must be rejected"
5448        );
5449    }
5450
5451    /// A text-keyed map (`Meta`) and an int-keyed map are distinguished on decode, and a mixed-key
5452    /// map is rejected (TKA emits no mixed-key maps).
5453    #[test]
5454    fn decode_map_rejects_mixed_key_types() {
5455        // map(2){ 1: 0, "a": "b" } — int key then text key. a2 01 00 61 61 61 62
5456        assert!(
5457            decode_value(&[0xa2, 0x01, 0x00, 0x61, 0x61, 0x61, 0x62], 0).is_err(),
5458            "a map mixing uint and text keys must be rejected"
5459        );
5460        // A pure text map decodes to TextMap.
5461        let (v, rest) = decode_value(&[0xa1, 0x61, 0x61, 0x61, 0x62], 0).unwrap();
5462        assert!(rest.is_empty());
5463        assert_eq!(
5464            v,
5465            Value::TextMap(alloc::vec![(b"a".to_vec(), Value::Text(b"b".to_vec()))])
5466        );
5467    }
5468
5469    // ===== Review follow-ups (PR #48 review): close decode coverage gaps =====
5470
5471    /// Gap 2 (highest value): decode the authoritative frozen **Go checkpoint** bytes — the most
5472    /// complex AUM shape (null `disablement_values` arm, two nested keys, the second carrying a
5473    /// `Meta`, 32-byte hashes). The encode side asserts these exact bytes
5474    /// (`aum_checkpoint_nil_disablement_matches_go`); here we prove the *decoder* consumes them and
5475    /// round-trips byte-identically (so `hash()` is stable), exercising `AumState::from_value` +
5476    /// nested `AumKey::from_value` against real Go output rather than our own encoder.
5477    #[test]
5478    fn aum_from_cbor_decodes_frozen_go_checkpoint() {
5479        const GO_SERIALIZE: &str = "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f02f60382a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5fa401010203035820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f0ca1616b6176";
5480        const GO_HASH: &str = "cae17cc938c5a954cd4389d83c6afe4d3487edac38b94824bec3312b82f35710";
5481        let bytes = unhex(GO_SERIALIZE);
5482
5483        let aum = Aum::from_cbor(&bytes).expect("decode the frozen Go checkpoint");
5484        assert_eq!(aum.message_kind, AumKind::Checkpoint);
5485        let st = aum.state.as_ref().expect("checkpoint carries a State");
5486        assert_eq!(
5487            st.disablement_values, None,
5488            "nil DisablementValues → the null arm → None"
5489        );
5490        let keys = st.keys.as_ref().expect("State has keys");
5491        assert_eq!(keys.len(), 2, "two nested keys");
5492        assert_eq!(keys[0].votes, 1);
5493        assert_eq!(keys[1].votes, 3);
5494        assert_eq!(
5495            keys[1].meta,
5496            alloc::vec![(
5497                alloc::string::String::from("k"),
5498                alloc::string::String::from("v")
5499            )],
5500            "the second nested key carries Meta {{\"k\":\"v\"}}"
5501        );
5502        // Byte-exact re-encode → the chain-link Hash matches Go's golden hash.
5503        assert_eq!(
5504            aum.serialize(),
5505            bytes,
5506            "re-serialize must be byte-identical to the Go bytes"
5507        );
5508        assert_eq!(
5509            hex(&aum.hash().0),
5510            GO_HASH,
5511            "decoded checkpoint's Hash matches Go golden"
5512        );
5513    }
5514
5515    /// Gap 1: round-trip the field combinations the original cases missed — multi-entry `Meta`
5516    /// (canonical-ordering), both `state_id`s non-zero (key-4/key-5 routing), `votes` at the u32
5517    /// boundary, a both-empty (`null`/`null`) `AumSignature`, and `key`+`key_id`+`signatures`
5518    /// coexisting (key 3/4/23 cross-talk).
5519    #[test]
5520    fn aum_from_cbor_roundtrips_review_gap_shapes() {
5521        let cases: alloc::vec::Vec<(&str, Aum)> = alloc::vec![
5522            (
5523                "multi-entry meta, pre-sorted (serialize() canonicalises key order)",
5524                Aum {
5525                    message_kind: AumKind::UpdateKey,
5526                    prev_aum_hash: None,
5527                    key: None,
5528                    key_id: alloc::vec![1],
5529                    state: None,
5530                    votes: Some(1),
5531                    // Pre-sorted: `serialize()` emits TextMap keys in CTAP2 order, so the decoded
5532                    // meta is sorted; supplying sorted input keeps the `==` round-trip exact.
5533                    meta: alloc::vec![
5534                        ("a".into(), "2".into()),
5535                        ("mid".into(), "3".into()),
5536                        ("zebra".into(), "1".into()),
5537                    ],
5538                    signatures: Vec::new(),
5539                },
5540            ),
5541            (
5542                "both state_ids non-zero (key 4 and key 5 must not be swapped)",
5543                Aum {
5544                    message_kind: AumKind::Checkpoint,
5545                    prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
5546                    key: None,
5547                    key_id: Vec::new(),
5548                    state: Some(AumState {
5549                        last_aum_hash: None,
5550                        disablement_values: None,
5551                        keys: Some(Vec::new()),
5552                        state_id1: 7,
5553                        state_id2: 9,
5554                    }),
5555                    votes: None,
5556                    meta: Vec::new(),
5557                    signatures: Vec::new(),
5558                },
5559            ),
5560            (
5561                "votes at u32::MAX + AddKey with key.votes at u32::MAX and multi-meta",
5562                Aum {
5563                    message_kind: AumKind::AddKey,
5564                    prev_aum_hash: Some(AumHash([0x33; AUM_HASH_LEN])),
5565                    key: Some(AumKey {
5566                        kind: KeyKind::Ed25519,
5567                        votes: u32::MAX,
5568                        public: alloc::vec![1, 2, 3],
5569                        meta: alloc::vec![("a".into(), "x".into()), ("b".into(), "y".into())],
5570                    }),
5571                    key_id: Vec::new(),
5572                    state: None,
5573                    votes: None,
5574                    meta: Vec::new(),
5575                    signatures: Vec::new(),
5576                },
5577            ),
5578            (
5579                "both-empty AumSignature (key_id null AND signature null)",
5580                Aum {
5581                    message_kind: AumKind::AddKey,
5582                    prev_aum_hash: None,
5583                    key: None,
5584                    key_id: Vec::new(),
5585                    state: None,
5586                    votes: None,
5587                    meta: Vec::new(),
5588                    signatures: alloc::vec![AumSignature {
5589                        key_id: Vec::new(),
5590                        signature: Vec::new(),
5591                    }],
5592                },
5593            ),
5594            (
5595                "key + key_id + signatures coexisting (keys 3, 4, 23)",
5596                Aum {
5597                    message_kind: AumKind::AddKey,
5598                    prev_aum_hash: Some(AumHash([0x55; AUM_HASH_LEN])),
5599                    key: Some(AumKey {
5600                        kind: KeyKind::Ed25519,
5601                        votes: 2,
5602                        public: alloc::vec![7, 7, 7],
5603                        meta: Vec::new(),
5604                    }),
5605                    key_id: alloc::vec![9, 9],
5606                    state: None,
5607                    votes: None,
5608                    meta: Vec::new(),
5609                    signatures: alloc::vec![AumSignature {
5610                        key_id: alloc::vec![1],
5611                        signature: alloc::vec![2, 3, 4],
5612                    }],
5613                },
5614            ),
5615            (
5616                "votes = 0 (boundary; Some(0) must survive, distinct from None)",
5617                Aum {
5618                    message_kind: AumKind::UpdateKey,
5619                    prev_aum_hash: None,
5620                    key: None,
5621                    key_id: alloc::vec![1],
5622                    state: None,
5623                    votes: Some(0),
5624                    meta: Vec::new(),
5625                    signatures: Vec::new(),
5626                },
5627            ),
5628        ];
5629        for (label, aum) in cases {
5630            let bytes = aum.serialize();
5631            let decoded = Aum::from_cbor(&bytes)
5632                .unwrap_or_else(|e| panic!("from_cbor failed for {label:?}: {e}"));
5633            assert_eq!(decoded, aum, "round-trip mismatch for {label:?}");
5634            assert_eq!(
5635                decoded.serialize(),
5636                bytes,
5637                "re-serialize differs for {label:?}"
5638            );
5639        }
5640    }
5641
5642    /// Gap 3: additional fail-closed guards on the AUM entry point — truncated map (count > entries),
5643    /// a duplicate key at the AUM level, votes > u32::MAX, an unsupported key kind, and a malformed
5644    /// (non-map) `state` value. Each must `Err`, never panic, never `Ok`.
5645    #[test]
5646    fn aum_from_cbor_fails_closed_review_gaps() {
5647        // Truncated map: header claims 3 pairs, only 2 present then EOF.
5648        assert!(
5649            Aum::from_cbor(&[0xa3, 0x01, 0x03, 0x02, 0xf6]).is_err(),
5650            "a map claiming more pairs than present must be rejected"
5651        );
5652        // Duplicate key at the AUM level: key 1 appears twice (a3 01 03 02 f6 01 04).
5653        assert!(
5654            Aum::from_cbor(&[0xa3, 0x01, 0x03, 0x02, 0xf6, 0x01, 0x04]).is_err(),
5655            "a duplicate AUM map key must be rejected"
5656        );
5657        // votes > u32::MAX: 06 1b 0000_0001_0000_0000 (= 2^32).
5658        assert!(
5659            Aum::from_cbor(&[
5660                0xa3, 0x01, 0x04, 0x02, 0xf6, 0x06, 0x1b, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
5661                0x00,
5662            ])
5663            .is_err(),
5664            "votes above u32::MAX must be rejected (fail-closed narrowing)"
5665        );
5666        // Unsupported key kind: AddKey embedding a key with kind=2. a3 01 01 02 f6 03 a3 01 02 02 01 03 41 09
5667        assert!(
5668            Aum::from_cbor(&[
5669                0xa3, 0x01, 0x01, 0x02, 0xf6, 0x03, 0xa3, 0x01, 0x02, 0x02, 0x01, 0x03, 0x41, 0x09,
5670            ])
5671            .is_err(),
5672            "an unsupported key kind must be rejected (not silently treated as Ed25519)"
5673        );
5674        // Malformed state: key 5 is a uint, not a map. a2 01 05 05 00 — but prev (key 2) missing too;
5675        // use a3 with prev null: a3 01 05 02 f6 05 00.
5676        assert!(
5677            Aum::from_cbor(&[0xa3, 0x01, 0x05, 0x02, 0xf6, 0x05, 0x00]).is_err(),
5678            "a non-map `state` value must be rejected"
5679        );
5680        // A deeply-nested array inside an AUM field must error (shared depth cap), not overflow.
5681        let mut nested = alloc::vec![0xa2u8, 0x01, 0x03, 0x02]; // map(2){1:3, 2: <nested>}
5682        nested.extend(core::iter::repeat_n(0x81u8, MAX_SIG_NESTING_DEPTH + 8)); // array(1) per level
5683        nested.push(0x00); // innermost uint
5684        assert!(
5685            Aum::from_cbor(&nested).is_err(),
5686            "an AUM field nested past the depth cap must be rejected, not overflow the stack"
5687        );
5688    }
5689
5690    /// Gap 5 + Finding L2: a NON-canonical encoding decodes to the SAME `Aum` (and thus the same
5691    /// `hash()`) as its canonical form — pinning the property that makes the lenient decode benign
5692    /// (the verify path re-serializes canonically, so wire-form variation can never forge a hash).
5693    #[test]
5694    fn aum_from_cbor_noncanonical_decodes_to_same_hash() {
5695        // Canonical NoOp with null prev: a2 01 03 02 f6.
5696        let canonical = [0xa2u8, 0x01, 0x03, 0x02, 0xf6];
5697        // Non-canonical variants that must decode to the SAME struct:
5698        //  (a) message_kind via a non-minimal 2-byte int head (0x18 0x03 instead of 0x03):
5699        let noncanon_int = [0xa2u8, 0x01, 0x18, 0x03, 0x02, 0xf6];
5700        //  (b) prev=null via the 2-byte simple-value form (0xf8 0x16 instead of 0xf6):
5701        let noncanon_null = [0xa2u8, 0x01, 0x03, 0x02, 0xf8, 0x16];
5702        //  (c) map keys in DESCENDING order (2 before 1):
5703        let noncanon_order = [0xa2u8, 0x02, 0xf6, 0x01, 0x03];
5704
5705        let base = Aum::from_cbor(&canonical).expect("canonical decodes");
5706        for (label, bytes) in [
5707            ("non-minimal int head", &noncanon_int[..]),
5708            ("2-byte null simple value", &noncanon_null[..]),
5709            ("descending key order", &noncanon_order[..]),
5710        ] {
5711            let got = Aum::from_cbor(bytes)
5712                .unwrap_or_else(|e| panic!("non-canonical ({label}) should still decode: {e}"));
5713            assert_eq!(
5714                got, base,
5715                "non-canonical ({label}) must decode to the same Aum"
5716            );
5717            assert_eq!(
5718                got.hash(),
5719                base.hash(),
5720                "non-canonical ({label}) must hash identically (re-serialized canonically)"
5721            );
5722            // And it normalises: re-serialize equals the canonical bytes.
5723            assert_eq!(
5724                got.serialize(),
5725                canonical,
5726                "non-canonical ({label}) must re-serialize to the canonical form"
5727            );
5728        }
5729    }
5730    // ---- StaticValidate cluster (tsr-uvg): Go `AUM/Key/State.StaticValidate` parity ----
5731
5732    /// `Key::static_validate` — votes must be 1..=4096 (Go `Key.StaticValidate`).
5733    #[test]
5734    fn key_static_validate_votes_range() {
5735        let mut k = test_aum_key(1, 1);
5736        assert!(k.static_validate().is_ok(), "votes=1 ok");
5737        k.votes = 4096;
5738        assert!(k.static_validate().is_ok(), "votes=4096 ok (boundary)");
5739        k.votes = 0;
5740        assert_eq!(
5741            k.static_validate().unwrap_err(),
5742            TkaError::BadKeyState,
5743            "votes=0 rejected"
5744        );
5745        k.votes = 4097;
5746        assert_eq!(
5747            k.static_validate().unwrap_err(),
5748            TkaError::BadKeyState,
5749            "votes>4096 rejected"
5750        );
5751    }
5752
5753    /// `Key::static_validate` — metadata byte total must be ≤ MAX_META_BYTES.
5754    #[test]
5755    fn key_static_validate_meta_size() {
5756        let mut k = test_aum_key(2, 1);
5757        // 256-byte key + 256-byte value = 512 total = exactly MAX_META_BYTES → ok.
5758        k.meta = alloc::vec![(
5759            String::from_utf8(alloc::vec![b'k'; 256]).unwrap(),
5760            String::from_utf8(alloc::vec![b'v'; 256]).unwrap(),
5761        )];
5762        assert!(k.static_validate().is_ok(), "512 meta bytes ok (boundary)");
5763        // One more byte → rejected.
5764        k.meta[0].1.push('x');
5765        assert_eq!(
5766            k.static_validate().unwrap_err(),
5767            TkaError::BadKeyState,
5768            "meta>512 rejected"
5769        );
5770    }
5771
5772    /// `Aum::static_validate` — per-kind field allow-lists (Go `AUM.StaticValidate`).
5773    #[test]
5774    fn aum_static_validate_per_kind_field_allow_lists() {
5775        // AddKey must have a key and nothing else.
5776        let mut a = genesis_add(test_aum_key(1, 1));
5777        assert!(a.static_validate().is_ok());
5778        a.key_id = alloc::vec![1, 2, 3]; // foreign field
5779        assert!(
5780            a.static_validate().is_err(),
5781            "AddKey with a stray KeyID rejected"
5782        );
5783
5784        // RemoveKey must have a key_id and nothing else.
5785        let g = signed_genesis_add(1, 1);
5786        let mut rm = child(
5787            &g,
5788            AumKind::RemoveKey,
5789            None,
5790            test_aum_key(2, 1).public.clone(),
5791        );
5792        assert!(rm.static_validate().is_ok());
5793        rm.votes = Some(3); // foreign field
5794        assert!(
5795            rm.static_validate().is_err(),
5796            "RemoveKey with stray Votes rejected"
5797        );
5798
5799        // UpdateKey must have key_id AND (votes or meta).
5800        let mut up = child(
5801            &g,
5802            AumKind::UpdateKey,
5803            None,
5804            test_aum_key(2, 1).public.clone(),
5805        );
5806        assert!(
5807            up.static_validate().is_err(),
5808            "UpdateKey with neither votes nor meta rejected"
5809        );
5810        up.votes = Some(2);
5811        assert!(up.static_validate().is_ok(), "UpdateKey with votes ok");
5812        up.key = Some(test_aum_key(3, 1)); // foreign field
5813        assert!(
5814            up.static_validate().is_err(),
5815            "UpdateKey with a stray Key rejected"
5816        );
5817
5818        // Checkpoint must have state and nothing else.
5819        let mut cp = child(&g, AumKind::Checkpoint, None, Vec::new());
5820        cp.state = Some(AumState {
5821            last_aum_hash: None,
5822            disablement_values: Some(alloc::vec![alloc::vec![0xD5u8; DISABLEMENT_LENGTH]]),
5823            keys: Some(alloc::vec![test_aum_key(1, 1)]),
5824            state_id1: 0,
5825            state_id2: 0,
5826        });
5827        assert!(cp.static_validate().is_ok());
5828        cp.votes = Some(1); // foreign field
5829        assert!(
5830            cp.static_validate().is_err(),
5831            "Checkpoint with stray Votes rejected"
5832        );
5833    }
5834
5835    /// `Aum::static_validate` — every signature must have a 32-byte key_id and 64-byte signature.
5836    #[test]
5837    fn aum_static_validate_signature_lengths() {
5838        let mut a = genesis_add(test_aum_key(1, 1));
5839        a.signatures = alloc::vec![AumSignature {
5840            key_id: alloc::vec![0u8; 31], // wrong length (should be 32)
5841            signature: alloc::vec![0u8; 64],
5842        }];
5843        assert!(a.static_validate().is_err(), "31-byte keyID rejected");
5844        a.signatures[0].key_id = alloc::vec![0u8; 32];
5845        a.signatures[0].signature = alloc::vec![0u8; 63]; // wrong length (should be 64)
5846        assert!(a.static_validate().is_err(), "63-byte signature rejected");
5847    }
5848
5849    /// The last-key guard (Go `aumVerify`): a `RemoveKey` removing the only remaining trusted key is
5850    /// rejected — otherwise the authority would be left with an empty key set (lock disabled).
5851    #[test]
5852    fn verified_chain_rejects_removing_last_key() {
5853        let g = signed_genesis_add(1, 1); // exactly one trusted key (seed 1)
5854        let mut rm = child(
5855            &g,
5856            AumKind::RemoveKey,
5857            None,
5858            test_aum_key(1, 1).public.clone(),
5859        );
5860        sign_aum(&mut rm, &[1]); // validly signed by the trusted key
5861        assert_eq!(
5862            VerifiedAumChain::verify(&[g, rm]).unwrap_err(),
5863            TkaError::BadKeyState,
5864            "removing the last trusted key must be refused"
5865        );
5866    }
5867
5868    /// Removing a non-last key is fine: with two trusted keys, one can be removed.
5869    #[test]
5870    fn verified_chain_allows_removing_non_last_key() {
5871        let g = signed_genesis_add(1, 1);
5872        let mut add = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
5873        sign_aum(&mut add, &[1]);
5874        let mut rm = child(
5875            &add,
5876            AumKind::RemoveKey,
5877            None,
5878            test_aum_key(2, 1).public.clone(),
5879        );
5880        sign_aum(&mut rm, &[1]);
5881        let verified = VerifiedAumChain::verify(&[g, add, rm]).expect("removing a non-last key ok");
5882        let auth = Authority::from_verified_chain(verified);
5883        assert_eq!(
5884            auth.state().keys.len(),
5885            1,
5886            "back to one key after the remove"
5887        );
5888    }
5889
5890    /// `UpdateKey` is re-validated after mutation (Go re-runs `Key.StaticValidate`): an update that
5891    /// sets votes out of range is rejected.
5892    #[test]
5893    fn verified_chain_rejects_updatekey_to_invalid_votes() {
5894        let g = signed_genesis_add(1, 1);
5895        let mut up = child(
5896            &g,
5897            AumKind::UpdateKey,
5898            None,
5899            test_aum_key(1, 1).public.clone(),
5900        );
5901        up.votes = Some(5000); // > 4096 → invalid after mutation
5902        sign_aum(&mut up, &[1]);
5903        assert_eq!(
5904            VerifiedAumChain::verify(&[g, up]).unwrap_err(),
5905            TkaError::BadKeyState,
5906            "an UpdateKey that sets votes > 4096 is rejected (post-mutation re-validate)"
5907        );
5908    }
5909
5910    // ===== AUM-chain sync: store + SyncOffer + MissingAUMs (issue #7 chunk 2, tsr-5po) =====
5911
5912    /// Build a simple linear chain `genesis(AddKey) -> NoOp -> NoOp -> ...` of `len` AUMs, returning
5913    /// the AUMs in parent→child order. The genesis adds `test_aum_key(1, 1)`.
5914    fn linear_chain(len: usize) -> Vec<Aum> {
5915        assert!(len >= 1);
5916        let mut chain = alloc::vec![genesis_add(test_aum_key(1, 1))];
5917        for _ in 1..len {
5918            let parent = chain.last().unwrap();
5919            chain.push(child(parent, AumKind::NoOp, None, Vec::new()));
5920        }
5921        chain
5922    }
5923
5924    /// A `Checkpoint` child of `parent` snapshotting `keys` — the AUM kind `sync_offer` now offers.
5925    /// The embedded state is a valid checkpoint state (one disablement value, a non-empty key set)
5926    /// with the default all-zero StateID, so a chain may carry several of them.
5927    fn checkpoint_child(parent: &Aum, keys: &[AumKey]) -> Aum {
5928        let mut cp = child(parent, AumKind::Checkpoint, None, Vec::new());
5929        cp.state = Some(AumState {
5930            last_aum_hash: None,
5931            disablement_values: Some(alloc::vec![alloc::vec![0xaa; DISABLEMENT_LENGTH]]),
5932            keys: Some(keys.to_vec()),
5933            state_id1: 0,
5934            state_id2: 0,
5935        });
5936        cp
5937    }
5938
5939    /// A linear chain of `len` AUMs: a genesis `AddKey(k1)`, then `NoOp`s, except that every
5940    /// `checkpoint_every`-th index is a `Checkpoint` snapshotting `k1`. Long enough that the old
5941    /// exponential ancestor sample and the checkpoint list are genuinely different sets.
5942    fn checkpointed_chain(len: usize, checkpoint_every: usize) -> Vec<Aum> {
5943        let k1 = test_aum_key(1, 1);
5944        let mut chain = alloc::vec![genesis_add(k1.clone())];
5945        for i in 1..len {
5946            let parent = chain.last().unwrap();
5947            let next = if i % checkpoint_every == 0 {
5948                checkpoint_child(parent, core::slice::from_ref(&k1))
5949            } else {
5950                child(parent, AumKind::NoOp, None, Vec::new())
5951            };
5952            chain.push(next);
5953        }
5954        chain
5955    }
5956
5957    /// An [`Authority`] whose head is the last AUM of `chain` (via the structural `from_chain`; the
5958    /// sync layer is signature-agnostic, so unsigned test chains are fine here).
5959    fn authority_at_head(chain: &[Aum]) -> Authority {
5960        Authority::from_chain(chain).expect("linear test chain replays")
5961    }
5962
5963    #[test]
5964    fn mem_store_indexes_by_hash_and_children() {
5965        let chain = linear_chain(3);
5966        let store = MemAumStore::from_aums(chain.clone());
5967        assert_eq!(store.len(), 3);
5968        // by-hash lookup
5969        assert_eq!(store.aum(&chain[1].hash()).as_ref(), Some(&chain[1]));
5970        assert!(store.aum(&AumHash([0xFF; AUM_HASH_LEN])).is_none());
5971        // child index: genesis has one child (chain[1]); the tail has none.
5972        let kids = store.child_aums(&chain[0].hash());
5973        assert_eq!(kids.len(), 1);
5974        assert_eq!(kids[0], chain[1]);
5975        assert!(store.child_aums(&chain[2].hash()).is_empty());
5976        // insert is idempotent on hash + child edge.
5977        let mut s2 = store.clone();
5978        s2.insert(chain[1].clone());
5979        assert_eq!(s2.len(), 3, "re-insert must not grow the store");
5980        assert_eq!(
5981            s2.child_aums(&chain[0].hash()).len(),
5982            1,
5983            "child edge not duplicated"
5984        );
5985    }
5986
5987    #[test]
5988    fn sync_offer_head_and_oldest_bookend() {
5989        let chain = linear_chain(5);
5990        let store = MemAumStore::from_aums(chain.clone());
5991        let auth = authority_at_head(&chain);
5992        let oldest = chain[0].hash();
5993
5994        let offer = auth.sync_offer(&store, oldest).expect("offer");
5995        assert_eq!(offer.head, chain[4].hash(), "offer head is the chain head");
5996        assert_eq!(
5997            *offer.ancestors.last().unwrap(),
5998            oldest,
5999            "the last ancestor is always the oldest AUM"
6000        );
6001        // Every ancestor is a real hash in the chain.
6002        for a in &offer.ancestors {
6003            assert!(
6004                store.aum(a).is_some(),
6005                "ancestor {a:?} must be in the store"
6006            );
6007        }
6008    }
6009
6010    #[test]
6011    fn sync_offer_truncates_on_a_gap() {
6012        // A store missing an interior AUM: the backward walk breaks early, but `oldest` is still
6013        // appended (matching Go's break-then-append). Drop chain[1] so walking back from head hits
6014        // a gap.
6015        let chain = linear_chain(4);
6016        let store = MemAumStore::from_aums(
6017            chain
6018                .iter()
6019                .enumerate()
6020                .filter(|(i, _)| *i != 1)
6021                .map(|(_, a)| a.clone()),
6022        );
6023        let auth = authority_at_head(&chain);
6024        let offer = auth
6025            .sync_offer(&store, chain[0].hash())
6026            .expect("offer despite gap");
6027        assert_eq!(*offer.ancestors.last().unwrap(), chain[0].hash());
6028    }
6029
6030    #[test]
6031    fn missing_aums_empty_when_up_to_date() {
6032        let chain = linear_chain(4);
6033        let store = MemAumStore::from_aums(chain.clone());
6034        let auth = authority_at_head(&chain);
6035        let oldest = chain[0].hash();
6036        // Peer offers the SAME head → nothing missing.
6037        let peer_offer = auth.sync_offer(&store, oldest).expect("offer");
6038        let missing = auth
6039            .missing_aums(&store, &peer_offer, oldest)
6040            .expect("missing");
6041        assert!(missing.is_empty(), "an up-to-date peer is missing nothing");
6042    }
6043
6044    #[test]
6045    fn missing_aums_head_intersection_sends_the_tail() {
6046        // We are at head chain[4]; the peer is behind at chain[2] (their head is an ancestor of
6047        // ours). We must send them chain[3] and chain[4] (everything after the intersection).
6048        let chain = linear_chain(5);
6049        let store = MemAumStore::from_aums(chain.clone());
6050        let oldest = chain[0].hash();
6051        let us = authority_at_head(&chain); // head = chain[4]
6052
6053        // The peer's offer: head = chain[2], ancestors back to oldest. Build it from a peer authority
6054        // whose head is chain[2] over a store holding the prefix [0..=2].
6055        let peer_prefix: Vec<Aum> = chain[0..=2].to_vec();
6056        let peer_store = MemAumStore::from_aums(peer_prefix.clone());
6057        let peer = authority_at_head(&peer_prefix); // head = chain[2]
6058        let peer_offer = peer.sync_offer(&peer_store, oldest).expect("peer offer");
6059
6060        let missing = us
6061            .missing_aums(&store, &peer_offer, oldest)
6062            .expect("missing");
6063        let missing_hashes: Vec<AumHash> = missing.iter().map(Aum::hash).collect();
6064        assert_eq!(
6065            missing_hashes,
6066            alloc::vec![chain[3].hash(), chain[4].hash()],
6067            "must send exactly the AUMs after the peer's head, in order"
6068        );
6069    }
6070
6071    #[test]
6072    fn missing_aums_excludes_the_intersection_itself() {
6073        // The intersection AUM (the peer's head) must NOT be in the sent set — they already have it.
6074        let chain = linear_chain(4);
6075        let store = MemAumStore::from_aums(chain.clone());
6076        let oldest = chain[0].hash();
6077        let us = authority_at_head(&chain);
6078
6079        let peer_prefix: Vec<Aum> = chain[0..=1].to_vec();
6080        let peer = authority_at_head(&peer_prefix);
6081        let peer_offer = peer
6082            .sync_offer(&MemAumStore::from_aums(peer_prefix.clone()), oldest)
6083            .expect("peer offer");
6084
6085        let missing = us
6086            .missing_aums(&store, &peer_offer, oldest)
6087            .expect("missing");
6088        assert!(
6089            !missing.iter().any(|a| a.hash() == chain[1].hash()),
6090            "the intersection AUM (peer's head) must be excluded"
6091        );
6092        assert_eq!(missing.len(), 2, "only chain[2] and chain[3] are missing");
6093    }
6094
6095    #[test]
6096    fn missing_aums_no_intersection_errors() {
6097        // Two totally unrelated chains (different genesis keys → different hashes everywhere): no
6098        // intersection, so `missing_aums` fails closed rather than mis-rooting.
6099        let ours = linear_chain(3);
6100        let store = MemAumStore::from_aums(ours.clone());
6101        let us = authority_at_head(&ours);
6102
6103        // A foreign chain the peer offers; we hold none of it.
6104        let theirs = {
6105            let mut c = alloc::vec![genesis_add(test_aum_key(9, 1))];
6106            c.push(child(&c[0], AumKind::NoOp, None, Vec::new()));
6107            c
6108        };
6109        let foreign_offer = SyncOffer {
6110            head: theirs[1].hash(),
6111            ancestors: alloc::vec![theirs[1].hash(), theirs[0].hash()],
6112        };
6113        assert!(
6114            us.missing_aums(&store, &foreign_offer, ours[0].hash())
6115                .is_err(),
6116            "no intersection must fail closed, not mis-root"
6117        );
6118    }
6119
6120    #[test]
6121    fn compute_state_at_matches_replay_at_each_point() {
6122        // The state computed at an interior AUM via the store walk must equal a direct linear replay
6123        // of the prefix up to that AUM (the verify-only Authority's state).
6124        let chain = linear_chain(4);
6125        let store = MemAumStore::from_aums(chain.clone());
6126        for i in 0..chain.len() {
6127            let want = chain[i].hash();
6128            let via_store = compute_state_at(&store, MAX_SYNC_ITER, want)
6129                .expect("compute_state_at ok")
6130                .expect("hash present");
6131            let via_replay = Authority::from_chain(&chain[0..=i]).expect("prefix replays");
6132            assert_eq!(
6133                via_store.to_state(),
6134                *via_replay.state(),
6135                "computed state at chain[{i}] must match a direct prefix replay"
6136            );
6137        }
6138    }
6139
6140    #[test]
6141    fn compute_state_at_starts_from_a_mid_chain_checkpoint() {
6142        // A checkpoint that is not the genesis is the normal kind, and it is what `sync_offer` now
6143        // offers, so every tail-intersection candidate is computed from one. The state computed at
6144        // (and after) such a checkpoint must equal a full replay of the prefix — and, unlike a
6145        // genesis fold, must not be refused for naming a parent.
6146        let chain = checkpointed_chain(24, 8); // checkpoints at 8 and 16
6147        assert_eq!(chain[8].message_kind, AumKind::Checkpoint);
6148        let store = MemAumStore::from_aums(chain.clone());
6149        for i in [8usize, 9, 16, 23] {
6150            let via_store = compute_state_at(&store, MAX_SYNC_ITER, chain[i].hash())
6151                .expect("compute_state_at ok")
6152                .expect("hash present");
6153            let via_replay = Authority::from_chain(&chain[0..=i]).expect("prefix replays");
6154            assert_eq!(
6155                via_store.to_state(),
6156                *via_replay.state(),
6157                "computed state at chain[{i}] must match a direct prefix replay"
6158            );
6159        }
6160    }
6161
6162    #[test]
6163    fn sync_offer_ancestors_are_every_checkpoint() {
6164        // The offer carries every Checkpoint between head and oldest, newest-first, and nothing
6165        // else; `oldest` is still the bookend. Checkpoints at 10, 25 and 40 of a 60-AUM chain.
6166        let k1 = test_aum_key(1, 1);
6167        let mut chain = alloc::vec![genesis_add(k1.clone())];
6168        let checkpoints = [10usize, 25, 40];
6169        for i in 1..60 {
6170            let parent = chain.last().unwrap();
6171            let next = if checkpoints.contains(&i) {
6172                checkpoint_child(parent, core::slice::from_ref(&k1))
6173            } else {
6174                child(parent, AumKind::NoOp, None, Vec::new())
6175            };
6176            chain.push(next);
6177        }
6178        let store = MemAumStore::from_aums(chain.clone());
6179        let auth = authority_at_head(&chain);
6180
6181        let offer = auth.sync_offer(&store, chain[0].hash()).expect("offer");
6182        assert_eq!(
6183            offer.ancestors,
6184            alloc::vec![
6185                chain[40].hash(),
6186                chain[25].hash(),
6187                chain[10].hash(),
6188                chain[0].hash(),
6189            ],
6190            "every checkpoint, newest-first, then the oldest bookend — and no NoOp ancestors"
6191        );
6192
6193        // The head itself is offered when the head is a checkpoint (the walk starts at i=0, unlike
6194        // the sample it replaces, which never offered the head).
6195        let head_is_checkpoint = authority_at_head(&chain[0..=40]);
6196        let offer = head_is_checkpoint
6197            .sync_offer(&store, chain[0].hash())
6198            .expect("offer");
6199        assert_eq!(offer.head, chain[40].hash());
6200        assert_eq!(
6201            offer.ancestors[0],
6202            chain[40].hash(),
6203            "head checkpoint offered"
6204        );
6205    }
6206
6207    #[test]
6208    fn missing_aums_intersects_a_chain_compacted_back_to_one_checkpoint() {
6209        // The failure this change exists for. A node compacts down to its last checkpoint plus the
6210        // AUMs after it, so its storage is a small window in the middle of a long chain. The remote
6211        // is far ahead. An offer built from position-sampled ancestors (4, 16, 64, ... AUMs back
6212        // from the remote's head) can be wholly outside that window, and then the node finds no
6213        // common ancestor at all and re-polls forever with a stale view. Offering checkpoints fixes
6214        // it because compaction is guaranteed to leave a checkpoint behind.
6215        let chain = checkpointed_chain(100, 10); // checkpoints at 10, 20, ... 90
6216
6217        // The remote holds everything and is at chain[99].
6218        let remote_store = MemAumStore::from_aums(chain.clone());
6219        let remote = authority_at_head(&chain);
6220        let remote_offer = remote
6221            .sync_offer(&remote_store, chain[0].hash())
6222            .expect("remote offer");
6223
6224        // We forked at the checkpoint chain[80] with one AUM the remote has never seen, and have
6225        // since compacted: storage holds that checkpoint and nothing older.
6226        let ours = child(
6227            &chain[80],
6228            AumKind::AddKey,
6229            Some(test_aum_key(7, 1)),
6230            Vec::new(),
6231        );
6232        let mut full: Vec<Aum> = chain[0..=80].to_vec();
6233        full.push(ours.clone());
6234        let us = authority_at_head(&full);
6235        let store = MemAumStore::from_aums(alloc::vec![chain[80].clone(), ours.clone()]);
6236        let oldest = chain[80].hash();
6237
6238        // The remote's head and its recent ancestors are not in our window at all...
6239        assert!(
6240            store.aum(&remote_offer.head).is_none(),
6241            "remote head unknown"
6242        );
6243        for far in [95usize, 83, 35, 0] {
6244            assert!(
6245                store.aum(&chain[far].hash()).is_none(),
6246                "chain[{far}] — a position an exponential sample would offer — is compacted away"
6247            );
6248        }
6249        // ...but the checkpoint we kept is in the remote's offer, which is the whole point.
6250        assert!(
6251            remote_offer.ancestors.contains(&oldest),
6252            "the remote offers the checkpoint we compacted back to"
6253        );
6254
6255        // So the intersection is findable, and we send the remote exactly the AUM it lacks.
6256        let missing = us
6257            .missing_aums(&store, &remote_offer, oldest)
6258            .expect("a compacted chain still intersects a far-ahead remote offer");
6259        assert_eq!(
6260            missing.iter().map(Aum::hash).collect::<Vec<_>>(),
6261            alloc::vec![ours.hash()],
6262            "gather starts at the checkpoint intersection and excludes it"
6263        );
6264    }
6265
6266    #[test]
6267    fn linear_chain_from_returns_ordered_chain() {
6268        // A store built from a linear chain returns it genesis→head in order, regardless of insert
6269        // order, so it round-trips through `VerifiedAumChain`/`from_chain`.
6270        let chain = linear_chain(5);
6271        // Insert in reverse to prove ordering is by chain links, not insert order.
6272        let mut store = MemAumStore::new();
6273        for aum in chain.iter().rev() {
6274            store.insert(aum.clone());
6275        }
6276        let ordered = store.linear_chain_from(chain[0].hash()).expect("walk");
6277        let got: Vec<AumHash> = ordered.iter().map(Aum::hash).collect();
6278        let want: Vec<AumHash> = chain.iter().map(Aum::hash).collect();
6279        assert_eq!(got, want, "linear_chain_from must yield genesis→head order");
6280        // And it replays into the same head a direct from_chain produces.
6281        assert_eq!(
6282            Authority::from_chain(&ordered).unwrap().head(),
6283            chain[4].hash()
6284        );
6285    }
6286
6287    #[test]
6288    fn linear_chain_from_missing_genesis_errors() {
6289        let chain = linear_chain(3);
6290        let store = MemAumStore::from_aums(chain.clone());
6291        // A genesis hash not in the store is BadChain, not a panic.
6292        assert_eq!(
6293            store
6294                .linear_chain_from(AumHash([0xEE; AUM_HASH_LEN]))
6295                .unwrap_err(),
6296            TkaError::BadChain
6297        );
6298    }
6299
6300    #[test]
6301    fn linear_chain_from_single_genesis() {
6302        // A store with only the genesis returns just it (the bootstrap case before any sync).
6303        let g = genesis_add(test_aum_key(1, 1));
6304        let store = MemAumStore::from_aums([g.clone()]);
6305        let ordered = store.linear_chain_from(g.hash()).expect("walk");
6306        assert_eq!(ordered.len(), 1);
6307        assert_eq!(ordered[0].hash(), g.hash());
6308    }
6309
6310    /// `AumKind::as_str` must return the exact Go `AUMKind.String()` strings (`tka/aum.go`,
6311    /// v1.100.0) — these are the `Change` values rendered by `tailscale lock log`, so a drift would
6312    /// silently produce a non-Go log row. Freeze every variant→string pair.
6313    #[test]
6314    fn aum_kind_as_str_matches_go() {
6315        let cases = [
6316            (AumKind::AddKey, "add-key"),
6317            (AumKind::RemoveKey, "remove-key"),
6318            (AumKind::UpdateKey, "update-key"),
6319            (AumKind::Checkpoint, "checkpoint"),
6320            (AumKind::NoOp, "no-op"),
6321            (AumKind::Invalid, "invalid"),
6322        ];
6323        for (kind, want) in cases {
6324            assert_eq!(kind.as_str(), want, "as_str for {kind:?}");
6325        }
6326    }
6327
6328    /// Consensus regression (tsr-3x4): at a genuine **weight-decided** fork, `linear_chain_from` must
6329    /// pick the branch with the higher signing weight — the branch a Go node picks (Go folds the real
6330    /// trusted-key state before each `pickNextAUM`). The previous code resolved the fork against an
6331    /// empty (zero-key) `ReplayState`, so every candidate scored weight 0 and the tiebreak collapsed
6332    /// to lowest-hash; on a fork where the lowest-hash branch is NOT the highest-weight branch, that
6333    /// diverged from Go = an accept-direction consensus split. This test constructs exactly that
6334    /// adversarial shape (the low-weight branch has the lower hash) and asserts weight wins.
6335    #[test]
6336    fn linear_chain_from_resolves_fork_by_real_weight_not_empty_state() {
6337        use ed25519_dalek::{Signer, SigningKey};
6338
6339        // Two trusted keys with very different vote weights, established by a checkpoint genesis.
6340        let key_light = test_aum_key(0x10, 1); // votes = 1
6341        let key_heavy = test_aum_key(0x20, 100); // votes = 100
6342        let signer_light = SigningKey::from_bytes(&[0x10; 32]);
6343        let signer_heavy = SigningKey::from_bytes(&[0x20; 32]);
6344
6345        let genesis = Aum {
6346            message_kind: AumKind::Checkpoint,
6347            prev_aum_hash: None,
6348            key: None,
6349            key_id: Vec::new(),
6350            state: Some(AumState {
6351                last_aum_hash: None,
6352                disablement_values: Some(alloc::vec![alloc::vec![0x33u8; DISABLEMENT_LENGTH]]),
6353                keys: Some(alloc::vec![key_light.clone(), key_heavy.clone()]),
6354                state_id1: 0,
6355                state_id2: 0,
6356            }),
6357            votes: None,
6358            meta: Vec::new(),
6359            signatures: Vec::new(),
6360        };
6361        let gh = genesis.hash();
6362
6363        // Build a child NoOp signed by the given key. `salt` perturbs the AUM bytes (via meta) so we
6364        // can search for the hash ordering we need without changing which key signs it.
6365        let child_signed_by = |signer: &SigningKey, key: &AumKey, salt: u8| -> Aum {
6366            let mut aum = Aum {
6367                message_kind: AumKind::NoOp,
6368                prev_aum_hash: Some(gh),
6369                key: None,
6370                key_id: Vec::new(),
6371                state: None,
6372                votes: None,
6373                meta: alloc::vec![("s".to_string(), alloc::format!("{salt}"))],
6374                signatures: Vec::new(),
6375            };
6376            let sh = aum.sig_hash();
6377            aum.signatures = alloc::vec![AumSignature {
6378                key_id: key.id().to_vec(),
6379                signature: signer.sign(&sh).to_bytes().to_vec(),
6380            }];
6381            aum
6382        };
6383
6384        // Find a salt pair where the LIGHT-weight branch has the LOWER hash (the adversarial case:
6385        // lowest-hash != highest-weight). With content-derived hashes this is found by probing salts.
6386        let (light_child, heavy_child) = (0u8..64)
6387            .flat_map(|ls| (0u8..64).map(move |hs| (ls, hs)))
6388            .find_map(|(ls, hs)| {
6389                let light = child_signed_by(&signer_light, &key_light, ls);
6390                let heavy = child_signed_by(&signer_heavy, &key_heavy, hs);
6391                (light.hash().0 < heavy.hash().0).then_some((light, heavy))
6392            })
6393            .expect("a salt pair where the light branch sorts lower must exist");
6394
6395        // Sanity: the adversarial precondition actually holds.
6396        assert!(
6397            light_child.hash().0 < heavy_child.hash().0,
6398            "test setup: light (low-weight) branch must have the lower hash"
6399        );
6400
6401        let store =
6402            MemAumStore::from_aums([genesis.clone(), light_child.clone(), heavy_child.clone()]);
6403        let ordered = store.linear_chain_from(gh).expect("walk");
6404
6405        // The walk must pick the HEAVY (weight-100) branch as the genesis's successor — matching Go —
6406        // NOT the light/low-hash branch the old empty-state code would have chosen.
6407        assert_eq!(ordered.len(), 2, "genesis + the chosen branch head");
6408        assert_eq!(ordered[0].hash(), gh);
6409        assert_eq!(
6410            ordered[1].hash(),
6411            heavy_child.hash(),
6412            "fork must resolve to the higher-WEIGHT branch (Go parity), not the lower-HASH one"
6413        );
6414        assert_ne!(
6415            ordered[1].hash(),
6416            light_child.hash(),
6417            "the lower-hash low-weight branch must NOT be chosen (the pre-fix empty-state bug)"
6418        );
6419    }
6420
6421    /// Consensus regression (tsr-3x4), the state-ACCUMULATION case: the fork is resolved by a key
6422    /// that was added by a **mid-chain `AddKey`**, not by the genesis. This is the scenario that
6423    /// distinguishes a walk that folds *every AUM up to the fork* (correct, Go `advanceByPrimary`)
6424    /// from one that only ever reflects the genesis state — the genesis-only test above would pass
6425    /// even if `state` failed to advance past the genesis, so this fork lives two AUMs deep and its
6426    /// weight winner depends on the intervening `AddKey` having been folded in.
6427    #[test]
6428    fn linear_chain_from_resolves_deep_fork_using_mid_chain_added_key_weight() {
6429        use ed25519_dalek::{Signer, SigningKey};
6430
6431        // Genesis trusts only a bootstrap key (votes 1). A mid-chain AddKey then introduces the
6432        // heavy key (votes 100). The fork below the AddKey is decided by that heavy key's weight —
6433        // so it can only resolve correctly if the AddKey was folded into the walk's state.
6434        let key_boot = test_aum_key(0x40, 1);
6435        let key_heavy = test_aum_key(0x50, 100);
6436        let signer_boot = SigningKey::from_bytes(&[0x40; 32]);
6437        let signer_heavy = SigningKey::from_bytes(&[0x50; 32]);
6438
6439        let genesis = Aum {
6440            message_kind: AumKind::Checkpoint,
6441            prev_aum_hash: None,
6442            key: None,
6443            key_id: Vec::new(),
6444            state: Some(AumState {
6445                last_aum_hash: None,
6446                disablement_values: Some(alloc::vec![alloc::vec![0x44u8; DISABLEMENT_LENGTH]]),
6447                keys: Some(alloc::vec![key_boot.clone()]),
6448                state_id1: 0,
6449                state_id2: 0,
6450            }),
6451            votes: None,
6452            meta: Vec::new(),
6453            signatures: Vec::new(),
6454        };
6455        let gh = genesis.hash();
6456
6457        // Mid-chain AddKey introducing the heavy key, signed by the bootstrap key (the only trusted
6458        // key at this point). prev = genesis.
6459        let mut add_heavy = Aum {
6460            message_kind: AumKind::AddKey,
6461            prev_aum_hash: Some(gh),
6462            key: Some(key_heavy.clone()),
6463            key_id: Vec::new(),
6464            state: None,
6465            votes: None,
6466            meta: Vec::new(),
6467            signatures: Vec::new(),
6468        };
6469        let ah_sh = add_heavy.sig_hash();
6470        add_heavy.signatures = alloc::vec![AumSignature {
6471            key_id: key_boot.id().to_vec(),
6472            signature: signer_boot.sign(&ah_sh).to_bytes().to_vec(),
6473        }];
6474        let ah = add_heavy.hash();
6475
6476        // Two competing children of the AddKey: one signed by the heavy key (weight 100), one by the
6477        // bootstrap key (weight 1). Arrange (via salt) so the LIGHT (boot-signed) branch sorts lower.
6478        let child_signed_by = |signer: &SigningKey, key: &AumKey, salt: u8| -> Aum {
6479            let mut aum = Aum {
6480                message_kind: AumKind::NoOp,
6481                prev_aum_hash: Some(ah),
6482                key: None,
6483                key_id: Vec::new(),
6484                state: None,
6485                votes: None,
6486                meta: alloc::vec![("s".to_string(), alloc::format!("{salt}"))],
6487                signatures: Vec::new(),
6488            };
6489            let sh = aum.sig_hash();
6490            aum.signatures = alloc::vec![AumSignature {
6491                key_id: key.id().to_vec(),
6492                signature: signer.sign(&sh).to_bytes().to_vec(),
6493            }];
6494            aum
6495        };
6496        let (light_child, heavy_child) = (0u8..64)
6497            .flat_map(|ls| (0u8..64).map(move |hs| (ls, hs)))
6498            .find_map(|(ls, hs)| {
6499                let light = child_signed_by(&signer_boot, &key_boot, ls);
6500                let heavy = child_signed_by(&signer_heavy, &key_heavy, hs);
6501                (light.hash().0 < heavy.hash().0).then_some((light, heavy))
6502            })
6503            .expect("a salt pair where the light branch sorts lower must exist");
6504
6505        let store = MemAumStore::from_aums([
6506            genesis.clone(),
6507            add_heavy.clone(),
6508            light_child.clone(),
6509            heavy_child.clone(),
6510        ]);
6511        let ordered = store.linear_chain_from(gh).expect("walk");
6512
6513        // genesis → AddKey → the HEAVY branch (resolved by the mid-chain-added key's weight).
6514        assert_eq!(
6515            ordered.len(),
6516            3,
6517            "genesis + AddKey + the chosen branch head"
6518        );
6519        assert_eq!(ordered[0].hash(), gh);
6520        assert_eq!(ordered[1].hash(), ah, "the AddKey is on the walked chain");
6521        assert_eq!(
6522            ordered[2].hash(),
6523            heavy_child.hash(),
6524            "the deep fork must resolve by the mid-chain-added key's weight (state was accumulated past genesis)"
6525        );
6526    }
6527
6528    // ----- tsr-jo1: RotationDetails extraction (Go `NodeKeySignature.rotationDetails`) -----
6529
6530    /// Build a verified rotation chain: a trusted key signs an inner `Direct` over `wrapping_pub`,
6531    /// then `rotations` successive `Rotation` layers each re-wrap, the outermost authorizing
6532    /// `node_key`. Returns `(authority, node_key, signed_cbor, wrapping_pub)`. Every layer's pivot is
6533    /// the same `wrapping` keypair (the simplest chain that still has `prev_node_keys` entries).
6534    #[cfg(test)]
6535    fn rotation_chain(
6536        rotations: usize,
6537        inner_kind: SigKind,
6538    ) -> (Authority, Vec<u8>, Vec<u8>, Vec<u8>) {
6539        use ed25519_dalek::{Signer, SigningKey};
6540
6541        let trusted = SigningKey::from_bytes(&[7u8; 32]);
6542        let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
6543        let wrapping = SigningKey::from_bytes(&[9u8; 32]);
6544        let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
6545        let node_key = alloc::vec![5u8; 32];
6546
6547        let auth = Authority::from_state(
6548            AumHash([0; 32]),
6549            State {
6550                keys: alloc::vec![Key {
6551                    kind: KeyKind::Ed25519,
6552                    votes: 1,
6553                    public: trusted_pub.clone(),
6554                }],
6555            },
6556        );
6557
6558        // Inner signature: the trusted key signs over `wrapping_pub`. A `Credential` inner is
6559        // exercised by `initial_sig_kind` tests; it still verifies (security is the two signatures,
6560        // not the kind).
6561        let mut inner = NodeKeySignature {
6562            sig_kind: inner_kind,
6563            pubkey: wrapping_pub.clone(),
6564            key_id: trusted_pub.clone(),
6565            signature: Vec::new(),
6566            nested: None,
6567            wrapping_pubkey: wrapping_pub.clone(),
6568        };
6569        let inner_hash = inner.sig_hash();
6570        inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
6571
6572        // Successive Rotation layers, each wrapping the previous and signed by the wrapping key.
6573        let mut current = inner;
6574        for _ in 0..rotations {
6575            let mut outer = NodeKeySignature {
6576                sig_kind: SigKind::Rotation,
6577                pubkey: node_key.clone(),
6578                key_id: Vec::new(),
6579                signature: Vec::new(),
6580                nested: Some(alloc::boxed::Box::new(current)),
6581                wrapping_pubkey: Vec::new(),
6582            };
6583            let h = outer.sig_hash();
6584            outer.signature = wrapping.sign(&h).to_bytes().to_vec();
6585            current = outer;
6586        }
6587        let cbor = current.to_cbor(true).to_vec();
6588        (auth, node_key, cbor, wrapping_pub)
6589    }
6590
6591    /// A single-level rotation (Direct inner) yields `Some` details: one prior node key (the inner's
6592    /// `pubkey` = the wrapping pubkey), `initial_sig_kind == Direct`, and the initial wrapping pubkey.
6593    #[test]
6594    fn rotation_details_extracts_prev_node_keys() {
6595        let (auth, node_key, cbor, wrapping_pub) = rotation_chain(1, SigKind::Direct);
6596        let details = auth
6597            .node_key_authorized_with_details(&node_key, &cbor)
6598            .expect("must verify")
6599            .expect("rotation sig yields details");
6600        assert_eq!(details.prev_node_keys, alloc::vec![wrapping_pub.clone()]);
6601        assert_eq!(details.initial_sig_kind, SigKind::Direct);
6602        assert_eq!(details.initial_wrapping_pubkey, wrapping_pub);
6603    }
6604
6605    /// A plain `Direct` signature (no rotation) yields `None` details (and still authorizes).
6606    #[test]
6607    fn rotation_details_none_for_direct() {
6608        use ed25519_dalek::{Signer, SigningKey};
6609        let trusted = SigningKey::from_bytes(&[7u8; 32]);
6610        let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
6611        let node_key = alloc::vec![5u8; 32];
6612        let auth = Authority::from_state(
6613            AumHash([0; 32]),
6614            State {
6615                keys: alloc::vec![Key {
6616                    kind: KeyKind::Ed25519,
6617                    votes: 1,
6618                    public: trusted_pub.clone(),
6619                }],
6620            },
6621        );
6622        let mut direct = NodeKeySignature {
6623            sig_kind: SigKind::Direct,
6624            pubkey: node_key.clone(),
6625            key_id: trusted_pub.clone(),
6626            signature: Vec::new(),
6627            nested: None,
6628            wrapping_pubkey: node_key.clone(),
6629        };
6630        let h = direct.sig_hash();
6631        direct.signature = trusted.sign(&h).to_bytes().to_vec();
6632        let cbor = direct.to_cbor(true).to_vec();
6633        let details = auth
6634            .node_key_authorized_with_details(&node_key, &cbor)
6635            .expect("must verify");
6636        assert_eq!(details, None, "a Direct sig has no rotation details");
6637    }
6638
6639    /// A multi-level rotation collects every nested pubkey in outer→inner order, and the innermost
6640    /// (Direct) signature determines `initial_sig_kind`. Here both rotation layers share the same
6641    /// wrapping pivot, so both prior-key entries equal the wrapping pubkey.
6642    #[test]
6643    fn rotation_details_multi_level_order() {
6644        let (auth, node_key, cbor, wrapping_pub) = rotation_chain(2, SigKind::Direct);
6645        let details = auth
6646            .node_key_authorized_with_details(&node_key, &cbor)
6647            .expect("must verify")
6648            .expect("rotation yields details");
6649        // Two rotation layers ⇒ two nested signatures walked (the inner Rotation's pubkey = node_key
6650        // pivot is its own; the innermost Direct's pubkey = wrapping_pub). Both nested `pubkey`s are
6651        // collected; the innermost is the Direct over `wrapping_pub`.
6652        assert_eq!(
6653            details.prev_node_keys.len(),
6654            2,
6655            "both nested layers contribute a prior key"
6656        );
6657        assert_eq!(details.initial_sig_kind, SigKind::Direct);
6658        assert_eq!(details.initial_wrapping_pubkey, wrapping_pub);
6659    }
6660
6661    /// A `Credential`-rooted rotation chain reports `initial_sig_kind == Credential`, so the netmap
6662    /// rotation filter will exempt it from the clone-uniqueness rule (reusable-authkey carve-out).
6663    #[test]
6664    fn rotation_details_credential_root_kind() {
6665        let (auth, node_key, cbor, _wrapping_pub) = rotation_chain(1, SigKind::Credential);
6666        let details = auth
6667            .node_key_authorized_with_details(&node_key, &cbor)
6668            .expect("must verify")
6669            .expect("rotation yields details");
6670        assert_eq!(
6671            details.initial_sig_kind,
6672            SigKind::Credential,
6673            "a credential-rooted chain is reported so the uniqueness rule can exempt it"
6674        );
6675    }
6676
6677    /// Fail-closed parity with Go `rotationDetails` (which errors when a nested pubkey fails
6678    /// `NodePublic.UnmarshalBinary`): a rotation chain whose nested signature carries a pubkey of the
6679    /// wrong length must be REJECTED, not admitted with a junk prior-key entry. The crafted case is a
6680    /// nested **Credential** layer — the verify path does not bind a credential's `pubkey` to a node
6681    /// key, so without this check a bad-length credential pubkey would slip through (fail-open).
6682    #[test]
6683    fn rotation_details_rejects_malformed_nested_pubkey() {
6684        use ed25519_dalek::{Signer, SigningKey};
6685        let trusted = SigningKey::from_bytes(&[7u8; 32]);
6686        let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
6687        let wrapping = SigningKey::from_bytes(&[9u8; 32]);
6688        let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
6689        let node_key = alloc::vec![5u8; 32];
6690        let auth = Authority::from_state(
6691            AumHash([0; 32]),
6692            State {
6693                keys: alloc::vec![Key {
6694                    kind: KeyKind::Ed25519,
6695                    votes: 1,
6696                    public: trusted_pub.clone(),
6697                }],
6698            },
6699        );
6700
6701        // Inner Credential with a deliberately MALFORMED (3-byte) pubkey. The credential is signed by
6702        // the trusted key over the wrapping pubkey; the verify path does not constrain its `pubkey`.
6703        let mut inner = NodeKeySignature {
6704            sig_kind: SigKind::Credential,
6705            pubkey: alloc::vec![1u8, 2u8, 3u8], // wrong length (not 32)
6706            key_id: trusted_pub.clone(),
6707            signature: Vec::new(),
6708            nested: None,
6709            wrapping_pubkey: wrapping_pub.clone(),
6710        };
6711        let inner_hash = inner.sig_hash();
6712        inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
6713
6714        let mut outer = NodeKeySignature {
6715            sig_kind: SigKind::Rotation,
6716            pubkey: node_key.clone(),
6717            key_id: Vec::new(),
6718            signature: Vec::new(),
6719            nested: Some(alloc::boxed::Box::new(inner)),
6720            wrapping_pubkey: Vec::new(),
6721        };
6722        let outer_hash = outer.sig_hash();
6723        outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
6724        let cbor = outer.to_cbor(true).to_vec();
6725
6726        // The signatures themselves verify, but details extraction rejects the bad-length nested
6727        // pubkey fail-closed (Go drops the peer here).
6728        let err = auth
6729            .node_key_authorized_with_details(&node_key, &cbor)
6730            .unwrap_err();
6731        assert_eq!(err, TkaError::Decode("nested rotation pubkey wrong length"));
6732        // And the bool authorize path rejects identically (no fail-open).
6733        assert!(auth.node_key_authorized(&node_key, &cbor).is_err());
6734    }
6735
6736    /// The verify verdict of `node_key_authorized` is byte-identical to the details path across the
6737    /// good rotation case and the bad (tampered) case — the refactor that made the former a wrapper
6738    /// of the latter must not change a single verdict.
6739    #[test]
6740    fn node_key_authorized_matches_details_path() {
6741        let (auth, node_key, cbor, _wp) = rotation_chain(1, SigKind::Direct);
6742        assert_eq!(
6743            auth.node_key_authorized(&node_key, &cbor).is_ok(),
6744            auth.node_key_authorized_with_details(&node_key, &cbor)
6745                .is_ok(),
6746        );
6747        // Tamper a byte of the CBOR so verification fails; both paths must reject identically.
6748        let mut bad = cbor.clone();
6749        let last = bad.len() - 1;
6750        bad[last] ^= 0xff;
6751        assert_eq!(
6752            auth.node_key_authorized(&node_key, &bad).is_err(),
6753            auth.node_key_authorized_with_details(&node_key, &bad)
6754                .is_err(),
6755        );
6756    }
6757}