Skip to main content

ts_tka/
lib.rs

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