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