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