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