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/// A BLAKE2s-256 hash of an AUM's canonical serialization. Identifies an AUM and links the chain
52/// (`PrevAUMHash`). Text form is RFC4648 standard base32, no padding (Go `AUMHash.MarshalText`).
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub struct AumHash(pub [u8; AUM_HASH_LEN]);
55
56impl AumHash {
57    /// Decode an `AumHash` from its base32 (no-pad, RFC4648 standard alphabet) text form, as found
58    /// in `TkaInfo.head`. Returns `None` if the text is not exactly 32 decoded bytes.
59    pub fn from_base32(text: &str) -> Option<AumHash> {
60        let decoded = base32_decode_nopad(text)?;
61        if decoded.len() != AUM_HASH_LEN {
62            return None;
63        }
64        let mut h = [0u8; AUM_HASH_LEN];
65        h.copy_from_slice(&decoded);
66        Some(AumHash(h))
67    }
68
69    /// Encode this hash as base32 (no-pad, standard alphabet) — the wire/text form.
70    pub fn to_base32(&self) -> String {
71        base32_encode_nopad(&self.0)
72    }
73}
74
75/// The kind of an AUM (Authority Update Message) (Go `AUMKind`; integer values are wire-stable, do
76/// not reorder).
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78#[repr(u8)]
79pub enum AumKind {
80    /// Invalid / unset (0).
81    Invalid = 0,
82    /// Add a trusted key (1).
83    AddKey = 1,
84    /// Remove a trusted key (2).
85    RemoveKey = 2,
86    /// No-op (3).
87    NoOp = 3,
88    /// Update an existing key's votes/metadata (4).
89    UpdateKey = 4,
90    /// Checkpoint: a full state snapshot (5).
91    Checkpoint = 5,
92}
93
94impl AumKind {
95    /// Decode an [`AumKind`] from its wire integer, or `None` for an unknown kind. Provided for a
96    /// future AUM-chain replayer (the admin-side authority-derivation half), which is out of scope
97    /// for the current client-verify path.
98    pub fn from_u8(n: u8) -> Option<AumKind> {
99        Some(match n {
100            0 => AumKind::Invalid,
101            1 => AumKind::AddKey,
102            2 => AumKind::RemoveKey,
103            3 => AumKind::NoOp,
104            4 => AumKind::UpdateKey,
105            5 => AumKind::Checkpoint,
106            _ => return None,
107        })
108    }
109}
110
111/// The kind of a TKA [`Key`] (Go `KeyKind`).
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum KeyKind {
114    /// Ed25519 trusted key (Go `Key25519` = 1).
115    Ed25519,
116}
117
118/// A trusted TKA key (Go `tka.Key`). Its [`Key::id`] (the 32-byte public key for Ed25519) is what an
119/// [`AumHash`] / [`NodeKeySignature`] references via `KeyID`.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct Key {
122    /// Key algorithm.
123    pub kind: KeyKind,
124    /// Voting weight (Go `Votes`, valid range 1..=4096).
125    pub votes: u32,
126    /// The raw public key bytes (32 for Ed25519).
127    pub public: Vec<u8>,
128}
129
130impl Key {
131    /// The key id: for Ed25519 this is the public key verbatim (Go `Key.ID`).
132    pub fn id(&self) -> &[u8] {
133        &self.public
134    }
135}
136
137/// The kind of a [`NodeKeySignature`] (Go `SigKind`).
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139#[repr(u8)]
140pub enum SigKind {
141    /// Invalid (0).
142    Invalid = 0,
143    /// Directly signs a node key with a trusted key (1).
144    Direct = 1,
145    /// Signs a rotated node key, nesting the prior signature (2).
146    Rotation = 2,
147    /// A credential signature; cannot authorize a node on its own (3).
148    Credential = 3,
149}
150
151impl SigKind {
152    fn from_u8(n: u8) -> Option<SigKind> {
153        Some(match n {
154            0 => SigKind::Invalid,
155            1 => SigKind::Direct,
156            2 => SigKind::Rotation,
157            3 => SigKind::Credential,
158            _ => return None,
159        })
160    }
161}
162
163/// A node-key signature (Go `tka.NodeKeySignature`): proof that a node's key is authorized under the
164/// tailnet-lock authority. Decoded from the CBOR blob a peer presents.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct NodeKeySignature {
167    /// Signature kind.
168    pub sig_kind: SigKind,
169    /// The node public key this signature authorizes (Go `Pubkey`).
170    pub pubkey: Vec<u8>,
171    /// The id of the trusted [`Key`] that signed this (Go `KeyID`).
172    pub key_id: Vec<u8>,
173    /// The Ed25519 signature bytes.
174    pub signature: Vec<u8>,
175    /// For [`SigKind::Rotation`], the nested (prior) signature.
176    pub nested: Option<alloc::boxed::Box<NodeKeySignature>>,
177    /// For rotation, the wrapping public key the nested signature authorized.
178    pub wrapping_pubkey: Vec<u8>,
179}
180
181/// Errors from TKA verification.
182#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
183pub enum TkaError {
184    /// The CBOR blob could not be decoded into the expected shape.
185    #[error("TKA decode error: {0}")]
186    Decode(&'static str),
187    /// A signature failed to verify cryptographically.
188    #[error("TKA signature verification failed")]
189    BadSignature,
190    /// The authorizing key is not trusted in the current authority state.
191    #[error("TKA authorizing key is not trusted")]
192    UntrustedKey,
193    /// A credential signature was presented where a node-authorizing signature was required.
194    #[error("a credential signature cannot authorize a node")]
195    CredentialCannotAuthorize,
196    /// The presented signature does not cover the given node key.
197    #[error("signature does not cover this node key")]
198    NodeKeyMismatch,
199    /// An AUM's `PrevAUMHash` does not match the hash of the state it was applied to (Go
200    /// "parent AUMHash mismatch") — the chain link is broken.
201    #[error("AUM parent hash does not match the current chain head")]
202    BadParent,
203    /// An `AUMAddKey` named a key id that is already trusted, or an `AUMRemoveKey`/`AUMUpdateKey`
204    /// named a key id that is not (Go "key already exists" / `ErrNoSuchKey`).
205    #[error("AUM key-state update is invalid (key already exists, or no such key)")]
206    BadKeyState,
207    /// An AUM chain was empty, did not begin at a genesis (`AUMCheckpoint`/`AUMAddKey` with no
208    /// parent), or otherwise could not be replayed into a state.
209    #[error("AUM chain is empty or has no valid genesis")]
210    BadChain,
211}
212
213impl NodeKeySignature {
214    /// The canonical CBOR serialization of this signature with the `Signature` field nil'd, used as
215    /// the signing-digest preimage (Go `NodeKeySignature.SigHash` zeroes `Signature` then
216    /// serializes).
217    fn sig_hash(&self) -> [u8; AUM_HASH_LEN] {
218        let v = self.to_cbor(/* include_signature = */ false);
219        blake2s_256(&v.to_vec())
220    }
221
222    /// Build the CBOR value for this signature. When `include_signature` is false, the signature
223    /// field (key 4) is omitted (the SigHash preimage).
224    fn to_cbor(&self, include_signature: bool) -> Value {
225        cbor::int_map([
226            (1, Some(Value::Uint(self.sig_kind as u8 as u64))),
227            (2, nonempty_bytes(&self.pubkey)),
228            (3, nonempty_bytes(&self.key_id)),
229            (
230                4,
231                if include_signature {
232                    nonempty_bytes(&self.signature)
233                } else {
234                    None
235                },
236            ),
237            (5, self.nested.as_ref().map(|n| n.to_cbor(true))),
238            (6, nonempty_bytes(&self.wrapping_pubkey)),
239        ])
240    }
241
242    /// The key id that ultimately roots this signature in a trusted key (Go `authorizingKeyID`):
243    /// for a rotation, recurse into the nested signature; otherwise this signature's `key_id`.
244    fn authorizing_key_id(&self) -> Result<&[u8], TkaError> {
245        match self.sig_kind {
246            SigKind::Rotation => self
247                .nested
248                .as_ref()
249                .ok_or(TkaError::Decode("rotation signature missing nested"))?
250                .authorizing_key_id(),
251            SigKind::Direct | SigKind::Credential => Ok(&self.key_id),
252            SigKind::Invalid => Err(TkaError::Decode("invalid signature kind")),
253        }
254    }
255
256    /// Verify this signature authorizes `node_key`, rooted in the trusted `verification_key` (Go
257    /// `NodeKeySignature.verifySignature`).
258    fn verify_signature(&self, node_key: &[u8], verification_key: &Key) -> Result<(), TkaError> {
259        // For non-credential signatures the signed pubkey must equal the node key being authorized.
260        if self.sig_kind != SigKind::Credential && self.pubkey != node_key {
261            return Err(TkaError::NodeKeyMismatch);
262        }
263
264        let sig_hash = self.sig_hash();
265
266        match self.sig_kind {
267            SigKind::Rotation => {
268                let nested = self
269                    .nested
270                    .as_ref()
271                    .ok_or(TkaError::Decode("rotation signature missing nested"))?;
272                // The outer rotation signature is verified with STANDARD ed25519 against the nested
273                // signature's wrapping public key.
274                let verify_pub = &nested.wrapping_pubkey;
275                if verify_pub.len() != 32 {
276                    return Err(TkaError::Decode("wrapping pubkey wrong length"));
277                }
278                verify_ed25519_std(verify_pub, &sig_hash, &self.signature)?;
279                // The nested signature must cover the rotation pivot (`verify_pub`). For a nested
280                // Direct this is enforced inside its own `verify_signature` (the non-credential
281                // `pubkey != node_key` check). A nested Credential SKIPS that check, so bind it
282                // here: the credential must cover exactly the wrapping pubkey it is rotating, or an
283                // attacker could splice an unrelated valid credential into the chain.
284                if nested.sig_kind == SigKind::Credential && nested.pubkey != *verify_pub {
285                    return Err(TkaError::NodeKeyMismatch);
286                }
287                // Then the nested signature must itself be valid, rooting in the trusted key.
288                nested.verify_signature(verify_pub, verification_key)
289            }
290            SigKind::Direct | SigKind::Credential => {
291                if self.nested.is_some() {
292                    return Err(TkaError::Decode("direct/credential signature has nested"));
293                }
294                if verification_key.kind != KeyKind::Ed25519 || verification_key.public.len() != 32
295                {
296                    return Err(TkaError::Decode("verification key not ed25519"));
297                }
298                // Direct/credential signatures verify with ZIP-215 (cofactored) ed25519, matching
299                // Go's `ed25519consensus.Verify`.
300                verify_ed25519_zip215(&verification_key.public, &sig_hash, &self.signature)
301            }
302            SigKind::Invalid => Err(TkaError::Decode("invalid signature kind")),
303        }
304    }
305}
306
307/// The current authority state (Go `tka.State`): the set of trusted keys at a given chain head.
308/// This is the minimal slice a client needs for [`Authority::node_key_authorized`].
309#[derive(Debug, Clone, Default, PartialEq, Eq)]
310pub struct State {
311    /// The trusted keys.
312    pub keys: Vec<Key>,
313}
314
315impl State {
316    /// Find a trusted key by its id (Go `State.GetKey`).
317    pub fn get_key(&self, key_id: &[u8]) -> Option<&Key> {
318        self.keys.iter().find(|k| k.id() == key_id)
319    }
320}
321
322/// A tailnet-lock authority as a client tracks it: the current trusted-key [`State`] and the chain
323/// `head`. Built by replaying the AUM chain (or from a control-provided checkpoint); the client
324/// then uses [`Authority::node_key_authorized`] to decide whether a peer is trusted.
325#[derive(Debug, Clone)]
326pub struct Authority {
327    head: AumHash,
328    state: State,
329}
330
331impl Authority {
332    /// Construct an authority directly from a known `head` and trusted-key `state` (e.g. a
333    /// control-provided checkpoint the client already trusts).
334    pub fn from_state(head: AumHash, state: State) -> Authority {
335        Authority { head, state }
336    }
337
338    /// The current chain head hash (Go `Authority.Head`).
339    pub fn head(&self) -> AumHash {
340        self.head
341    }
342
343    /// The trusted-key state.
344    pub fn state(&self) -> &State {
345        &self.state
346    }
347
348    /// Whether `head` (e.g. decoded from `TkaInfo.head`) matches this authority's head. A client
349    /// that finds a mismatch must resync before trusting verifications.
350    pub fn head_matches(&self, head: &AumHash) -> bool {
351        &self.head == head
352    }
353
354    /// Verify that `node_key` is authorized under the current authority state by the given
355    /// node-key-signature CBOR blob (Go `Authority.NodeKeyAuthorized`).
356    ///
357    /// Fail-closed: a credential-only signature, an untrusted authorizing key, a malformed blob, or
358    /// a bad signature all return `Err`.
359    ///
360    /// # Errors
361    ///
362    /// Returns [`TkaError::Decode`] if `signature_cbor` is malformed,
363    /// [`TkaError::CredentialCannotAuthorize`] for a credential-only signature,
364    /// [`TkaError::UntrustedKey`] if the authorizing key is not in the current state,
365    /// [`TkaError::NodeKeyMismatch`] if the signature does not cover `node_key`, or
366    /// [`TkaError::BadSignature`] if cryptographic verification fails.
367    pub fn node_key_authorized(
368        &self,
369        node_key: &[u8],
370        signature_cbor: &[u8],
371    ) -> Result<(), TkaError> {
372        let sig = decode_node_key_signature(signature_cbor)?;
373        // A credential signature can never authorize a node on its own.
374        if sig.sig_kind == SigKind::Credential {
375            return Err(TkaError::CredentialCannotAuthorize);
376        }
377        let key_id = sig.authorizing_key_id()?;
378        let key = self.state.get_key(key_id).ok_or(TkaError::UntrustedKey)?;
379        sig.verify_signature(node_key, key)
380    }
381}
382
383/// Compute the [`AumHash`] of an AUM given its canonical CBOR serialization. Exposed so a chain
384/// replayer can link AUMs (`PrevAUMHash`) without re-deriving the hash function.
385pub fn aum_hash(canonical_cbor: &[u8]) -> AumHash {
386    AumHash(blake2s_256(canonical_cbor))
387}
388
389/// A trusted-key payload as carried *inside* an [`Aum`] (`AUMAddKey`/`AUMUpdateKey`) or a
390/// checkpoint [`AumState`] — Go `tka.Key`, full wire shape (the verify-path [`Key`] is a leaner
391/// slice that omits `meta`, which the node-key-signature path never needs).
392///
393/// CBOR keymap (Go `cbor:"…,keyasint"`): `kind`=1, `votes`=2, `public`=3, `meta`=**12** (omitempty).
394#[derive(Debug, Clone, PartialEq, Eq)]
395pub struct AumKey {
396    /// Key algorithm (`Key25519` = 1).
397    pub kind: KeyKind,
398    /// Voting weight.
399    pub votes: u32,
400    /// Raw public key bytes (32 for Ed25519); the key id for `Key25519`.
401    pub public: Vec<u8>,
402    /// Optional metadata (Go `map[string]string`); omitted from CBOR when empty.
403    pub meta: Vec<(alloc::string::String, alloc::string::String)>,
404}
405
406impl AumKey {
407    /// The key id (for `Key25519`, the public key verbatim — Go `Key.ID`).
408    pub fn id(&self) -> &[u8] {
409        &self.public
410    }
411
412    /// The leaner verify-path [`Key`] view of this key (drops `meta`, which the node-key-signature
413    /// verification path never reads). Used by the replayer to populate the trusted-key [`State`].
414    pub fn to_key(&self) -> Key {
415        Key {
416            kind: self.kind,
417            votes: self.votes,
418            public: self.public.clone(),
419        }
420    }
421
422    fn kind_u8(&self) -> u8 {
423        match self.kind {
424            KeyKind::Ed25519 => 1,
425        }
426    }
427
428    fn to_cbor(&self) -> Value {
429        cbor::int_map([
430            (1, Some(Value::Uint(self.kind_u8() as u64))),
431            (2, Some(Value::Uint(self.votes as u64))),
432            (3, Some(Value::Bytes(self.public.clone()))),
433            (12, meta_to_cbor(&self.meta)),
434        ])
435    }
436}
437
438/// A full authority-state snapshot as carried in an `AUMCheckpoint` (Go `tka.State`).
439///
440/// CBOR keymap: `last_aum_hash`=1, `disablement_values`=2, `keys`=3, `state_id1`=4 (omitempty),
441/// `state_id2`=5 (omitempty). Keys 1/2/3 are **non-`omitempty`**, and Go's `fxamacker/cbor`
442/// distinguishes a **nil** slice/pointer from an **empty-but-present** one: a nil field encodes as
443/// CBOR **null** (`0xf6`), an empty-non-nil slice as an empty **array** (`0x80`). `last_aum_hash`,
444/// `disablement_values`, and `keys` are therefore `Option`: `None` ⇒ Go nil ⇒ `0xf6`; `Some(vec)`
445/// (incl. `Some(empty)`) ⇒ array. Getting this wrong changes the checkpoint's `Hash` — and thus the
446/// chain head — versus Go, so it is consensus-relevant (this was a recorded interop bug; fixed here).
447#[derive(Debug, Clone, PartialEq, Eq, Default)]
448pub struct AumState {
449    /// The hash of the AUM this state was produced by (Go `LastAUMHash`); `None` (Go nil) ⇒ null.
450    pub last_aum_hash: Option<AumHash>,
451    /// Disablement secret hashes (Go `DisablementValues`). `None` (Go nil) ⇒ CBOR null `0xf6`;
452    /// `Some(vec)` ⇒ array (empty array `0x80` when `Some(vec![])`).
453    pub disablement_values: Option<Vec<Vec<u8>>>,
454    /// The trusted keys at this state (Go `Keys`). `None` (Go nil) ⇒ CBOR null `0xf6`; `Some(vec)` ⇒
455    /// array.
456    pub keys: Option<Vec<AumKey>>,
457    /// Optional state identifier, high half (Go `StateID1`); omitted from CBOR when 0.
458    pub state_id1: u64,
459    /// Optional state identifier, low half (Go `StateID2`); omitted from CBOR when 0.
460    pub state_id2: u64,
461}
462
463impl AumState {
464    fn to_cbor(&self) -> Value {
465        cbor::int_map([
466            (
467                1,
468                Some(match &self.last_aum_hash {
469                    Some(h) => Value::Bytes(h.0.to_vec()),
470                    None => Value::Null,
471                }),
472            ),
473            (
474                2,
475                Some(match &self.disablement_values {
476                    // Go nil ⇒ CBOR null; Some(vec) ⇒ array (empty array for Some(vec![])).
477                    None => Value::Null,
478                    Some(vals) => {
479                        Value::Array(vals.iter().map(|d| Value::Bytes(d.clone())).collect())
480                    }
481                }),
482            ),
483            (
484                3,
485                Some(match &self.keys {
486                    None => Value::Null,
487                    Some(keys) => Value::Array(keys.iter().map(AumKey::to_cbor).collect()),
488                }),
489            ),
490            (
491                4,
492                (self.state_id1 != 0).then_some(Value::Uint(self.state_id1)),
493            ),
494            (
495                5,
496                (self.state_id2 != 0).then_some(Value::Uint(self.state_id2)),
497            ),
498        ])
499    }
500}
501
502/// A signature attached to an [`Aum`] (Go `tkatype.Signature`): which trusted key signed, and the
503/// signature bytes. CBOR keymap: `key_id`=1, `signature`=2 (both non-`omitempty`).
504#[derive(Debug, Clone, PartialEq, Eq)]
505pub struct AumSignature {
506    /// The id of the trusted key that produced `signature`.
507    pub key_id: Vec<u8>,
508    /// The raw signature bytes.
509    pub signature: Vec<u8>,
510}
511
512impl AumSignature {
513    fn to_cbor(&self) -> Value {
514        // Both fields are non-`omitempty` in Go, so an empty/nil `[]byte` encodes as CBOR null
515        // (`0xf6`), not as an empty byte string (`0x40`) and not omitted — same rule as the AUM's
516        // genesis `prev_aum_hash`. (Go's `TestSerialization` Signature vector ends `02 f6`.)
517        cbor::int_map([
518            (1, Some(bytes_or_null(&self.key_id))),
519            (2, Some(bytes_or_null(&self.signature))),
520        ])
521    }
522}
523
524/// An Authority Update Message (Go `tka.AUM`): one link in the tailnet-lock chain. This is the
525/// acquisition-side type a client replays to derive the trusted-key [`State`] (the verify-only path
526/// in [`Authority`] doesn't need it). Serialization is byte-exact with Go's `fxamacker/cbor`
527/// (CTAP2) so [`Aum::hash`]/[`Aum::sig_hash`] match Go's `AUM.Hash`/`AUM.SigHash`.
528///
529/// CBOR keymap (Go `cbor:"…,keyasint"`): `message_kind`=1, `prev_aum_hash`=2 (both
530/// **non-`omitempty`**; a nil `prev` encodes as CBOR null, *not* omitted), `key`=3, `key_id`=4,
531/// `state`=5, `votes`=6, `meta`=7, `signatures`=**23** (all `omitempty`). Key 23 is the last key
532/// encodable in a single CBOR head byte, which is why Go put `Signatures` there.
533#[derive(Debug, Clone, PartialEq, Eq)]
534pub struct Aum {
535    /// The kind of update.
536    pub message_kind: AumKind,
537    /// The hash of the previous AUM in the chain (`None`/empty = genesis, encodes as CBOR null).
538    pub prev_aum_hash: Option<AumHash>,
539    /// `AUMAddKey`/`AUMUpdateKey`: the key being added.
540    pub key: Option<AumKey>,
541    /// `AUMRemoveKey`/`AUMUpdateKey`: the id of the key being removed/updated.
542    pub key_id: Vec<u8>,
543    /// `AUMCheckpoint`: the full state snapshot.
544    pub state: Option<AumState>,
545    /// `AUMUpdateKey`: the new vote weight (Go `*uint`; `None` = unchanged/omitted).
546    pub votes: Option<u32>,
547    /// `AUMUpdateKey`: new metadata.
548    pub meta: Vec<(alloc::string::String, alloc::string::String)>,
549    /// The signatures over this AUM's [`Aum::sig_hash`].
550    pub signatures: Vec<AumSignature>,
551}
552
553impl Aum {
554    fn message_kind_u8(&self) -> u8 {
555        self.message_kind as u8
556    }
557
558    /// Build the canonical CBOR value. When `include_signatures` is false, key 23 is omitted (the
559    /// [`Aum::sig_hash`] preimage — Go nils `Signatures`, which `omitempty`-drops it).
560    fn to_cbor(&self, include_signatures: bool) -> Value {
561        let signatures = if include_signatures && !self.signatures.is_empty() {
562            Some(Value::Array(
563                self.signatures.iter().map(AumSignature::to_cbor).collect(),
564            ))
565        } else {
566            None
567        };
568        cbor::int_map([
569            // key 1, NON-omitempty.
570            (1, Some(Value::Uint(self.message_kind_u8() as u64))),
571            // key 2, NON-omitempty: a nil prev hash is CBOR null, never omitted.
572            (
573                2,
574                Some(match &self.prev_aum_hash {
575                    Some(h) => Value::Bytes(h.0.to_vec()),
576                    None => Value::Null,
577                }),
578            ),
579            (3, self.key.as_ref().map(AumKey::to_cbor)),
580            (4, nonempty_bytes(&self.key_id)),
581            (5, self.state.as_ref().map(AumState::to_cbor)),
582            (6, self.votes.map(|v| Value::Uint(v as u64))),
583            (7, meta_to_cbor(&self.meta)),
584            (23, signatures),
585        ])
586    }
587
588    /// The canonical CBOR serialization including signatures (Go `AUM.Serialize`).
589    pub fn serialize(&self) -> Vec<u8> {
590        self.to_cbor(/* include_signatures = */ true).to_vec()
591    }
592
593    /// The chain-link hash: `BLAKE2s-256` of the full serialization (Go `AUM.Hash`).
594    pub fn hash(&self) -> AumHash {
595        AumHash(blake2s_256(&self.serialize()))
596    }
597
598    /// The signing digest: `BLAKE2s-256` of the serialization with `signatures` omitted (Go
599    /// `AUM.SigHash`).
600    pub fn sig_hash(&self) -> [u8; AUM_HASH_LEN] {
601        blake2s_256(&self.to_cbor(/* include_signatures = */ false).to_vec())
602    }
603}
604
605/// `Some(TextMap)` for a non-empty `map[string]string`, else `None` (the `omitempty` rule). Keys are
606/// UTF-8 text; CTAP2 canonical ordering is applied at encode time by [`cbor::Value::TextMap`].
607fn meta_to_cbor(meta: &[(alloc::string::String, alloc::string::String)]) -> Option<Value> {
608    if meta.is_empty() {
609        return None;
610    }
611    Some(Value::TextMap(
612        meta.iter()
613            .map(|(k, v)| (k.as_bytes().to_vec(), Value::Text(v.as_bytes().to_vec())))
614            .collect(),
615    ))
616}
617
618fn blake2s_256(data: &[u8]) -> [u8; AUM_HASH_LEN] {
619    let mut hasher = Blake2s256::new();
620    hasher.update(data);
621    let out = hasher.finalize();
622    let mut h = [0u8; AUM_HASH_LEN];
623    h.copy_from_slice(&out);
624    h
625}
626
627/// `Some(Bytes)` when `b` is non-empty, else `None` — the `omitempty` rule for byte fields.
628fn nonempty_bytes(b: &[u8]) -> Option<Value> {
629    if b.is_empty() {
630        None
631    } else {
632        Some(Value::Bytes(b.to_vec()))
633    }
634}
635
636/// A byte string, or CBOR null when empty — the encoding Go's `fxamacker/cbor` produces for a
637/// **non-`omitempty`** `[]byte` field that is nil/empty (e.g. `tkatype.Signature.{KeyID,Signature}`,
638/// or an AUM's genesis `PrevAUMHash`). Distinct from [`nonempty_bytes`], which *omits* the field.
639fn bytes_or_null(b: &[u8]) -> Value {
640    if b.is_empty() {
641        Value::Null
642    } else {
643        Value::Bytes(b.to_vec())
644    }
645}
646
647// ===========================================================================================
648// AUM-chain replay (issue #7, chunk 1B) — the acquisition-side derivation of a trusted-key
649// `State`/`Authority` from a chain of `Aum`s, mirroring Go `tka/state.go` + `tka/tka.go`.
650// ===========================================================================================
651
652/// The mutable trusted-key state a replay folds AUMs into. Carries the keys plus the hash of the
653/// last AUM applied (Go `State.LastAUMHash`), which the next AUM's `prev_aum_hash` must match.
654/// Distinct from the public [`State`] (which is the verify-only snapshot the [`Authority`] exposes);
655/// this one tracks the chain cursor needed during replay.
656#[derive(Debug, Clone, Default)]
657struct ReplayState {
658    keys: Vec<AumKey>,
659    last_aum_hash: Option<AumHash>,
660    /// The `(StateID1, StateID2)` seeded by the genesis checkpoint, if any. A *subsequent*
661    /// checkpoint must carry the same pair (Go state.go: "checkpointed state has an incorrect
662    /// stateID"); `None` until a checkpoint is applied.
663    state_id: Option<(u64, u64)>,
664}
665
666impl ReplayState {
667    fn get_key(&self, key_id: &[u8]) -> Option<&AumKey> {
668        self.keys.iter().find(|k| k.id() == key_id)
669    }
670
671    fn find_key_index(&self, key_id: &[u8]) -> Option<usize> {
672        self.keys.iter().position(|k| k.id() == key_id)
673    }
674
675    /// The total signing weight of `aum` under this state (Go `AUM.Weight`): the sum of `votes` over
676    /// the **distinct** keys (deduped by key id) that both signed the AUM *and* are trusted here.
677    /// An unknown signing key contributes 0; a key that signed twice counts once.
678    fn weight(&self, aum: &Aum) -> u64 {
679        let mut seen: Vec<&[u8]> = Vec::new();
680        let mut weight: u64 = 0;
681        for sig in &aum.signatures {
682            let id = sig.key_id.as_slice();
683            if seen.contains(&id) {
684                continue;
685            }
686            if let Some(key) = self.get_key(id) {
687                weight += key.votes as u64;
688                seen.push(id);
689            }
690        }
691        weight
692    }
693
694    /// Fold one already-signature-verified AUM into the state (Go `State.applyVerifiedAUM`).
695    ///
696    /// Checks the parent-hash chain link first (a brand-new state with no `last_aum_hash` matches any
697    /// parent — the genesis case), then applies the per-kind mutation. Advances `last_aum_hash` to
698    /// the applied AUM's own hash so the next link can be verified.
699    ///
700    /// When this is the genesis (the state has no `last_aum_hash` yet), Go restricts the kind to
701    /// `NoOp`/`AddKey`/`Checkpoint` (Go `computeStateAt` rejects anything else as
702    /// "invalid genesis update") and a genesis AUM must carry no parent — both are enforced here so a
703    /// non-genesis-rooted slice can't be silently accepted as if it were a genesis.
704    fn apply_verified_aum(&mut self, aum: &Aum) -> Result<(), TkaError> {
705        match &self.last_aum_hash {
706            // Once the chain is rolling, the AUM must name the current head as its parent.
707            Some(head) => match &aum.prev_aum_hash {
708                Some(prev) if prev == head => {}
709                _ => return Err(TkaError::BadParent),
710            },
711            // Genesis: must have no parent, and only certain kinds may start a chain.
712            None => {
713                if aum.prev_aum_hash.is_some() {
714                    return Err(TkaError::BadParent);
715                }
716                if !matches!(
717                    aum.message_kind,
718                    AumKind::NoOp | AumKind::AddKey | AumKind::Checkpoint
719                ) {
720                    return Err(TkaError::BadChain);
721                }
722            }
723        }
724
725        match aum.message_kind {
726            AumKind::NoOp | AumKind::Invalid => {
727                // No state change (unknown/forward-compat kinds are tolerated as no-ops, matching
728                // Go's `default` arm). The chain cursor still advances (below).
729            }
730            AumKind::Checkpoint => {
731                // A checkpoint replaces the whole key set with its embedded snapshot. A genesis
732                // checkpoint seeds the authority's StateID; a later checkpoint must match it (Go
733                // rejects "checkpointed state has an incorrect stateID") — otherwise it belongs to a
734                // different authority and replaying it would silently fork the trusted-key set.
735                let state = aum
736                    .state
737                    .as_ref()
738                    .ok_or(TkaError::Decode("checkpoint AUM missing state"))?;
739                let incoming = (state.state_id1, state.state_id2);
740                match self.state_id {
741                    Some(existing) if existing != incoming => {
742                        return Err(TkaError::BadKeyState);
743                    }
744                    _ => self.state_id = Some(incoming),
745                }
746                // A checkpoint replaces the key set with its snapshot; a nil `keys` (Go nil) means
747                // "no trusted keys", i.e. the empty set.
748                self.keys = state.keys.clone().unwrap_or_default();
749            }
750            AumKind::AddKey => {
751                let key = aum
752                    .key
753                    .as_ref()
754                    .ok_or(TkaError::Decode("AddKey AUM missing key"))?;
755                if self.get_key(key.id()).is_some() {
756                    return Err(TkaError::BadKeyState);
757                }
758                self.keys.push(key.clone());
759            }
760            AumKind::UpdateKey => {
761                let idx = self
762                    .find_key_index(&aum.key_id)
763                    .ok_or(TkaError::BadKeyState)?;
764                // Mirror Go `applyVerifiedAUM` (AUMUpdateKey): each field is applied only when the
765                // update carries it — `if update.Votes != nil` / `if update.Meta != nil`. `votes`
766                // maps cleanly to `Option<u32>`. `meta` is a `Vec`, which cannot distinguish Go's
767                // nil map (leave unchanged) from an empty-but-present map (clear) — we treat empty
768                // as "not carried / leave unchanged", matching the nil case. The empty-but-present
769                // "clear meta" case is both unrepresentable here and verify-irrelevant (the
770                // verify-path `Key` drops `meta` entirely via `to_key`, so it never reaches a
771                // `node_key_authorized` decision); document the limitation rather than assign
772                // unconditionally, which would wrongly wipe `meta` on a votes-only update.
773                if let Some(votes) = aum.votes {
774                    self.keys[idx].votes = votes;
775                }
776                if !aum.meta.is_empty() {
777                    self.keys[idx].meta = aum.meta.clone();
778                }
779            }
780            AumKind::RemoveKey => {
781                let idx = self
782                    .find_key_index(&aum.key_id)
783                    .ok_or(TkaError::BadKeyState)?;
784                self.keys.remove(idx);
785            }
786        }
787
788        self.last_aum_hash = Some(aum.hash());
789        Ok(())
790    }
791
792    /// The verify-path [`State`] snapshot (just the trusted keys).
793    fn to_state(&self) -> State {
794        State {
795            keys: self.keys.iter().map(AumKey::to_key).collect(),
796        }
797    }
798}
799
800/// Choose the next AUM to apply when more than one child extends the current head (Go
801/// `tka.pickNextAUM`). The three rules, in order:
802///
803/// 1. **Highest signature weight** wins (computed against `state`).
804/// 2. If tied, prefer the **`RemoveKey`** AUM (a revocation should not be out-voted by a no-op fork).
805/// 3. If still tied, the **lowest `AUM.Hash()`** (bytewise) wins — a deterministic, content-derived
806///    tiebreak both peers compute identically.
807///
808/// `candidates` must be non-empty. The comparison is total and deterministic, so every node
809/// replaying the same chain selects the same active branch (the property tailnet-lock relies on).
810fn pick_next_aum<'a>(state: &ReplayState, candidates: &'a [Aum]) -> &'a Aum {
811    debug_assert!(!candidates.is_empty(), "pick_next_aum needs candidates");
812    let mut best = &candidates[0];
813    let mut best_weight = state.weight(best);
814    let mut best_hash = best.hash();
815    for cand in &candidates[1..] {
816        let w = state.weight(cand);
817        let h = cand.hash();
818        // Rule 1: strictly higher weight wins.
819        let better = if w != best_weight {
820            w > best_weight
821        } else if (cand.message_kind == AumKind::RemoveKey)
822            != (best.message_kind == AumKind::RemoveKey)
823        {
824            // Rule 2: exactly one is a RemoveKey → that one wins.
825            cand.message_kind == AumKind::RemoveKey
826        } else {
827            // Rule 3: lowest hash wins.
828            h.0 < best_hash.0
829        };
830        if better {
831            best = cand;
832            best_weight = w;
833            best_hash = h;
834        }
835    }
836    best
837}
838
839impl Authority {
840    /// Build an [`Authority`] by replaying a **linear** chain of AUMs from genesis to head (Go
841    /// `tka.Authority.Head` after `computeActiveChain` on a single confirmed branch).
842    ///
843    /// `aums` must be ordered parent→child: the first is the genesis — a `NoOp`, `AddKey`, or
844    /// `Checkpoint` with **no** parent (Go `computeStateAt` rejects any other kind as an invalid
845    /// genesis) — and each subsequent AUM's `prev_aum_hash` must equal the prior AUM's [`Aum::hash`].
846    /// A slice that is actually a *suffix* of a chain (its first AUM names a parent not in the slice)
847    /// is rejected rather than mis-rooted. Signature verification of the AUMs themselves is the
848    /// caller's responsibility for now (the sync RPC, chunk 3, verifies before handing a confirmed
849    /// chain here); this folds an already-trusted chain into the trusted-key state.
850    ///
851    /// For the **forked** case (competing children of one parent), use [`Authority::from_forked_chain`].
852    ///
853    /// # Errors
854    /// [`TkaError::BadChain`] if `aums` is empty or its genesis is an invalid kind;
855    /// [`TkaError::BadParent`] if a link doesn't chain (incl. a genesis that carries a parent);
856    /// [`TkaError::BadKeyState`] for an invalid add/remove/update or a mismatched checkpoint StateID;
857    /// [`TkaError::Decode`] for a malformed checkpoint/add.
858    pub fn from_chain(aums: &[Aum]) -> Result<Authority, TkaError> {
859        let last = aums.last().ok_or(TkaError::BadChain)?;
860        let head = last.hash();
861        let mut state = ReplayState::default();
862        for aum in aums {
863            state.apply_verified_aum(aum)?;
864        }
865        Ok(Authority {
866            head,
867            state: state.to_state(),
868        })
869    }
870
871    /// Resolve a **single fork point**: a shared linear `prefix` (genesis→fork point, parent-ordered)
872    /// followed by `branches`, the competing children of the fork point. The active child is chosen by
873    /// [`pick_next_aum`]'s deterministic rules (weight → `RemoveKey` preference → lowest hash),
874    /// evaluated against the state at the fork point, and applied. This is the consensus-critical
875    /// selection every node must make identically; the linear [`Authority::from_chain`] is the common
876    /// (no-fork) case.
877    ///
878    /// **Each branch must be exactly one AUM.** In this single-AUM-per-branch shape the choice is
879    /// provably identical to Go's `pickNextAUM` over the fork point's children. A *multi-step* branch
880    /// is **rejected** ([`TkaError::BadChain`]) rather than mis-resolved: Go re-runs `pickNextAUM` at
881    /// *every* link (`advanceByPrimary`), re-evaluating weight against the evolving state, so judging a
882    /// whole multi-AUM branch by its first AUM alone could pick a different active head than Go and
883    /// silently fork the trusted-key set. Implementing the per-step loop (and a general multi-fork DAG
884    /// walk) is deferred to when the sync layer can actually surface such a chain; until then this
885    /// guard keeps the model honest. (The common re-bootstrap case — competing single-AUM heads — is
886    /// fully covered.)
887    ///
888    /// # Errors
889    /// As [`Authority::from_chain`], plus [`TkaError::BadChain`] if `branches` is empty, or any branch
890    /// is not exactly one AUM.
891    pub fn from_forked_chain(prefix: &[Aum], branches: &[&[Aum]]) -> Result<Authority, TkaError> {
892        // Each branch must be exactly one AUM — see the doc: a multi-step branch judged by its first
893        // AUM could diverge from Go's per-step resolution. Reject rather than silently mis-resolve.
894        if branches.is_empty() || branches.iter().any(|b| b.len() != 1) {
895            return Err(TkaError::BadChain);
896        }
897        let mut state = ReplayState::default();
898        for aum in prefix {
899            state.apply_verified_aum(aum)?;
900        }
901        // Choose the winning child, judged against the state at the fork point — exactly Go's
902        // `pickNextAUM` over the children.
903        let heads: Vec<Aum> = branches.iter().map(|b| b[0].clone()).collect();
904        let winner_head = pick_next_aum(&state, &heads).hash();
905        let winner = branches
906            .iter()
907            .find(|b| b[0].hash() == winner_head)
908            .ok_or(TkaError::BadChain)?;
909        state.apply_verified_aum(&winner[0])?;
910        Ok(Authority {
911            head: winner[0].hash(),
912            state: state.to_state(),
913        })
914    }
915}
916
917/// Verify a standard (RFC 8032, non-cofactored) Ed25519 signature.
918fn verify_ed25519_std(public: &[u8], msg: &[u8], sig: &[u8]) -> Result<(), TkaError> {
919    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
920    let pk: [u8; 32] = public
921        .try_into()
922        .map_err(|_| TkaError::Decode("bad pubkey len"))?;
923    let vk = VerifyingKey::from_bytes(&pk).map_err(|_| TkaError::Decode("bad ed25519 pubkey"))?;
924    let sig: [u8; 64] = sig
925        .try_into()
926        .map_err(|_| TkaError::Decode("bad sig len"))?;
927    vk.verify(msg, &Signature::from_bytes(&sig))
928        .map_err(|_| TkaError::BadSignature)
929}
930
931/// Verify a ZIP-215 (cofactored) Ed25519 signature, matching Go `ed25519consensus.Verify`.
932fn verify_ed25519_zip215(public: &[u8], msg: &[u8], sig: &[u8]) -> Result<(), TkaError> {
933    let pk: [u8; 32] = public
934        .try_into()
935        .map_err(|_| TkaError::Decode("bad pubkey len"))?;
936    let vk = ed25519_zebra::VerificationKey::try_from(pk)
937        .map_err(|_| TkaError::Decode("bad ed25519 pubkey"))?;
938    let sig_bytes: [u8; 64] = sig
939        .try_into()
940        .map_err(|_| TkaError::Decode("bad sig len"))?;
941    let sig = ed25519_zebra::Signature::from(sig_bytes);
942    vk.verify(&sig, msg).map_err(|_| TkaError::BadSignature)
943}
944
945/// Decode a [`NodeKeySignature`] from canonical CBOR. This is a minimal decoder for the exact map
946/// shape Go emits (integer keys 1..=6); anything else is rejected (fail-closed).
947fn decode_node_key_signature(buf: &[u8]) -> Result<NodeKeySignature, TkaError> {
948    let (val, rest) = decode_value(buf, 0)?;
949    if !rest.is_empty() {
950        return Err(TkaError::Decode("trailing bytes after signature"));
951    }
952    node_key_signature_from_value(val, 0)
953}
954
955fn node_key_signature_from_value(val: Value, depth: usize) -> Result<NodeKeySignature, TkaError> {
956    if depth > MAX_SIG_NESTING_DEPTH {
957        return Err(TkaError::Decode("nested signature too deep"));
958    }
959    let Value::IntMap(entries) = val else {
960        return Err(TkaError::Decode("signature is not an int-keyed map"));
961    };
962    let mut sig_kind = None;
963    let mut pubkey = Vec::new();
964    let mut key_id = Vec::new();
965    let mut signature = Vec::new();
966    let mut nested = None;
967    let mut wrapping_pubkey = Vec::new();
968
969    for (k, v) in entries {
970        match k {
971            1 => {
972                let Value::Uint(n) = v else {
973                    return Err(TkaError::Decode("sig kind not uint"));
974                };
975                sig_kind = Some(
976                    SigKind::from_u8(
977                        u8::try_from(n).map_err(|_| TkaError::Decode("sig kind range"))?,
978                    )
979                    .ok_or(TkaError::Decode("unknown sig kind"))?,
980                );
981            }
982            2 => pubkey = expect_bytes(v)?,
983            3 => key_id = expect_bytes(v)?,
984            4 => signature = expect_bytes(v)?,
985            5 => {
986                nested = Some(alloc::boxed::Box::new(node_key_signature_from_value(
987                    v,
988                    depth + 1,
989                )?))
990            }
991            6 => wrapping_pubkey = expect_bytes(v)?,
992            _ => return Err(TkaError::Decode("unknown signature field")),
993        }
994    }
995
996    Ok(NodeKeySignature {
997        sig_kind: sig_kind.ok_or(TkaError::Decode("signature missing kind"))?,
998        pubkey,
999        key_id,
1000        signature,
1001        nested,
1002        wrapping_pubkey,
1003    })
1004}
1005
1006fn expect_bytes(v: Value) -> Result<Vec<u8>, TkaError> {
1007    match v {
1008        Value::Bytes(b) => Ok(b),
1009        _ => Err(TkaError::Decode("expected byte string")),
1010    }
1011}
1012
1013/// Decode one CBOR value (the subset the encoder produces) from `buf`, returning the value and the
1014/// remaining bytes. Minimal — only the major types TKA uses.
1015fn decode_value(buf: &[u8], depth: usize) -> Result<(Value, &[u8]), TkaError> {
1016    // Bound generic CBOR container nesting so a deeply-nested array/map (even a non-signature one)
1017    // cannot overflow the recursive decoder before signature-shape validation runs.
1018    if depth > MAX_SIG_NESTING_DEPTH {
1019        return Err(TkaError::Decode("nested signature too deep"));
1020    }
1021    let (major, arg, rest) = decode_head(buf)?;
1022    match major {
1023        0 => Ok((Value::Uint(arg), rest)),
1024        2 => {
1025            let len = arg as usize;
1026            if rest.len() < len {
1027                return Err(TkaError::Decode("byte string truncated"));
1028            }
1029            Ok((Value::Bytes(rest[..len].to_vec()), &rest[len..]))
1030        }
1031        3 => {
1032            let len = arg as usize;
1033            if rest.len() < len {
1034                return Err(TkaError::Decode("text string truncated"));
1035            }
1036            Ok((Value::Text(rest[..len].to_vec()), &rest[len..]))
1037        }
1038        4 => {
1039            let mut items = Vec::new();
1040            let mut cur = rest;
1041            for _ in 0..arg {
1042                let (v, next) = decode_value(cur, depth + 1)?;
1043                items.push(v);
1044                cur = next;
1045            }
1046            Ok((Value::Array(items), cur))
1047        }
1048        5 => {
1049            let mut entries: Vec<(u64, Value)> = Vec::new();
1050            let mut cur = rest;
1051            for _ in 0..arg {
1052                let (k, next) = decode_head(cur).and_then(|(m, a, r)| {
1053                    if m == 0 {
1054                        Ok((a, r))
1055                    } else {
1056                        Err(TkaError::Decode("map key not uint"))
1057                    }
1058                })?;
1059                // CTAP2/Go reject duplicate map keys; do the same (fail-closed) rather than
1060                // silently last-wins.
1061                if entries.iter().any(|(existing, _)| *existing == k) {
1062                    return Err(TkaError::Decode("duplicate map key"));
1063                }
1064                let (v, next2) = decode_value(next, depth + 1)?;
1065                entries.push((k, v));
1066                cur = next2;
1067            }
1068            Ok((Value::IntMap(entries), cur))
1069        }
1070        // Major types 1 (negative int), 6 (tag), and 7 (simple/float — incl. `null` 0xf6) are
1071        // rejected. This decoder backs only the `NodeKeySignature` shape, whose fields are all
1072        // uint/bytes/text/array/map and never null. NOTE for the future AUM-chain decoder (issue #7
1073        // chunk 2): AUMs *do* encode `null` (major 7, `0xf6`) for non-`omitempty` nil byte fields
1074        // (genesis `prev_aum_hash`, `AumSignature.{key_id,signature}`, `AumState.last_aum_hash`) — see
1075        // the encoder's `Value::Null` arm — so an AUM decoder must add a `7 => null` case here (or in
1076        // a dedicated decoder) or it will reject every real AUM.
1077        _ => Err(TkaError::Decode("unsupported CBOR major type")),
1078    }
1079}
1080
1081/// Decode a CBOR head: returns `(major, argument, rest)`.
1082fn decode_head(buf: &[u8]) -> Result<(u8, u64, &[u8]), TkaError> {
1083    let first = *buf.first().ok_or(TkaError::Decode("empty CBOR"))?;
1084    let major = first >> 5;
1085    let info = first & 0x1f;
1086    let rest = &buf[1..];
1087    let (arg, rest) = match info {
1088        n @ 0..=23 => (n as u64, rest),
1089        24 => {
1090            let b = *rest.first().ok_or(TkaError::Decode("truncated u8"))?;
1091            (b as u64, &rest[1..])
1092        }
1093        25 => {
1094            if rest.len() < 2 {
1095                return Err(TkaError::Decode("truncated u16"));
1096            }
1097            (u16::from_be_bytes([rest[0], rest[1]]) as u64, &rest[2..])
1098        }
1099        26 => {
1100            if rest.len() < 4 {
1101                return Err(TkaError::Decode("truncated u32"));
1102            }
1103            (
1104                u32::from_be_bytes([rest[0], rest[1], rest[2], rest[3]]) as u64,
1105                &rest[4..],
1106            )
1107        }
1108        27 => {
1109            if rest.len() < 8 {
1110                return Err(TkaError::Decode("truncated u64"));
1111            }
1112            let mut b = [0u8; 8];
1113            b.copy_from_slice(&rest[..8]);
1114            (u64::from_be_bytes(b), &rest[8..])
1115        }
1116        _ => return Err(TkaError::Decode("indefinite/reserved CBOR length")),
1117    };
1118    Ok((major, arg, rest))
1119}
1120
1121// ----- RFC 4648 base32 (standard alphabet, no padding) -----
1122
1123const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
1124
1125fn base32_encode_nopad(data: &[u8]) -> String {
1126    let mut out = String::new();
1127    let mut buffer: u32 = 0;
1128    let mut bits: u32 = 0;
1129    for &b in data {
1130        buffer = (buffer << 8) | b as u32;
1131        bits += 8;
1132        while bits >= 5 {
1133            bits -= 5;
1134            let idx = ((buffer >> bits) & 0x1f) as usize;
1135            out.push(BASE32_ALPHABET[idx] as char);
1136        }
1137    }
1138    if bits > 0 {
1139        let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
1140        out.push(BASE32_ALPHABET[idx] as char);
1141    }
1142    out
1143}
1144
1145fn base32_decode_nopad(text: &str) -> Option<Vec<u8>> {
1146    let mut buffer: u32 = 0;
1147    let mut bits: u32 = 0;
1148    let mut out = Vec::new();
1149    for c in text.chars() {
1150        let val = match c {
1151            'A'..='Z' => c as u32 - 'A' as u32,
1152            '2'..='7' => c as u32 - '2' as u32 + 26,
1153            _ => return None,
1154        };
1155        buffer = (buffer << 5) | val;
1156        bits += 5;
1157        if bits >= 8 {
1158            bits -= 8;
1159            out.push((buffer >> bits) as u8);
1160        }
1161    }
1162    Some(out)
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167    use super::*;
1168
1169    #[test]
1170    fn base32_roundtrip_32_bytes() {
1171        let h = AumHash([0xABu8; 32]);
1172        let text = h.to_base32();
1173        let back = AumHash::from_base32(&text).unwrap();
1174        assert_eq!(h, back);
1175    }
1176
1177    #[test]
1178    fn base32_rejects_wrong_length() {
1179        // "AAAA" decodes to fewer than 32 bytes.
1180        assert!(AumHash::from_base32("AAAA").is_none());
1181        // Lowercase / invalid alphabet rejected.
1182        assert!(AumHash::from_base32("aaaa").is_none());
1183    }
1184
1185    #[test]
1186    fn base32_matches_known_vector() {
1187        // RFC 4648 base32 of "foobar" is "MZXW6YTBOI" (with padding "======"); no-pad drops the pad.
1188        assert_eq!(base32_encode_nopad(b"foobar"), "MZXW6YTBOI");
1189        assert_eq!(base32_decode_nopad("MZXW6YTBOI").unwrap(), b"foobar");
1190    }
1191
1192    #[test]
1193    fn credential_signature_cannot_authorize() {
1194        let auth = Authority::from_state(AumHash([0; 32]), State::default());
1195        let sig = NodeKeySignature {
1196            sig_kind: SigKind::Credential,
1197            pubkey: alloc::vec![1, 2, 3],
1198            key_id: alloc::vec![4, 5, 6],
1199            signature: alloc::vec![0; 64],
1200            nested: None,
1201            wrapping_pubkey: Vec::new(),
1202        };
1203        let cbor = sig.to_cbor(true).to_vec();
1204        let err = auth.node_key_authorized(&[1, 2, 3], &cbor).unwrap_err();
1205        assert_eq!(err, TkaError::CredentialCannotAuthorize);
1206    }
1207
1208    #[test]
1209    fn untrusted_key_denied() {
1210        // A direct signature whose key id is not in the (empty) trusted state.
1211        let auth = Authority::from_state(AumHash([0; 32]), State::default());
1212        let sig = NodeKeySignature {
1213            sig_kind: SigKind::Direct,
1214            pubkey: alloc::vec![9; 32],
1215            key_id: alloc::vec![7; 32],
1216            signature: alloc::vec![0; 64],
1217            nested: None,
1218            wrapping_pubkey: Vec::new(),
1219        };
1220        let cbor = sig.to_cbor(true).to_vec();
1221        let err = auth.node_key_authorized(&[9; 32], &cbor).unwrap_err();
1222        assert_eq!(err, TkaError::UntrustedKey);
1223    }
1224
1225    #[test]
1226    fn direct_signature_verifies_end_to_end() {
1227        use ed25519_dalek::{Signer, SigningKey};
1228
1229        // A trusted Ed25519 key signs a node key directly.
1230        let signing = SigningKey::from_bytes(&[42u8; 32]);
1231        let trusted_pub = signing.verifying_key().to_bytes().to_vec();
1232        let node_key = alloc::vec![7u8; 32];
1233
1234        let trusted = Key {
1235            kind: KeyKind::Ed25519,
1236            votes: 1,
1237            public: trusted_pub.clone(),
1238        };
1239        let auth = Authority::from_state(
1240            AumHash([0; 32]),
1241            State {
1242                keys: alloc::vec![trusted],
1243            },
1244        );
1245
1246        // Build the signature, compute its sig-hash preimage, sign, then fill in the signature.
1247        let mut sig = NodeKeySignature {
1248            sig_kind: SigKind::Direct,
1249            pubkey: node_key.clone(),
1250            key_id: trusted_pub.clone(),
1251            signature: Vec::new(),
1252            nested: None,
1253            wrapping_pubkey: Vec::new(),
1254        };
1255        let sig_hash = sig.sig_hash();
1256        // NOTE: Go verifies Direct with ZIP-215; a standard ed25519-dalek signature is accepted by
1257        // ZIP-215 verification (ZIP-215 is a superset), so signing with dalek here is valid.
1258        sig.signature = signing.sign(&sig_hash).to_bytes().to_vec();
1259
1260        let cbor = sig.to_cbor(true).to_vec();
1261        assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
1262
1263        // A different node key must NOT be authorized by this signature.
1264        let other = alloc::vec![8u8; 32];
1265        assert_eq!(
1266            auth.node_key_authorized(&other, &cbor).unwrap_err(),
1267            TkaError::NodeKeyMismatch
1268        );
1269    }
1270
1271    #[test]
1272    fn tampered_signature_denied() {
1273        use ed25519_dalek::{Signer, SigningKey};
1274
1275        let signing = SigningKey::from_bytes(&[42u8; 32]);
1276        let trusted_pub = signing.verifying_key().to_bytes().to_vec();
1277        let node_key = alloc::vec![7u8; 32];
1278        let auth = Authority::from_state(
1279            AumHash([0; 32]),
1280            State {
1281                keys: alloc::vec![Key {
1282                    kind: KeyKind::Ed25519,
1283                    votes: 1,
1284                    public: trusted_pub.clone(),
1285                }],
1286            },
1287        );
1288        let mut sig = NodeKeySignature {
1289            sig_kind: SigKind::Direct,
1290            pubkey: node_key.clone(),
1291            key_id: trusted_pub,
1292            signature: Vec::new(),
1293            nested: None,
1294            wrapping_pubkey: Vec::new(),
1295        };
1296        let sig_hash = sig.sig_hash();
1297        let mut sigbytes = signing.sign(&sig_hash).to_bytes();
1298        sigbytes[0] ^= 0xff; // tamper
1299        sig.signature = sigbytes.to_vec();
1300
1301        let cbor = sig.to_cbor(true).to_vec();
1302        assert_eq!(
1303            auth.node_key_authorized(&node_key, &cbor).unwrap_err(),
1304            TkaError::BadSignature
1305        );
1306    }
1307
1308    #[test]
1309    fn head_matches_check() {
1310        let h = AumHash([5u8; 32]);
1311        let auth = Authority::from_state(h, State::default());
1312        assert!(auth.head_matches(&h));
1313        assert!(!auth.head_matches(&AumHash([6u8; 32])));
1314    }
1315
1316    // ----- Fix 1: depth cap on attacker-controlled nesting -----
1317
1318    #[test]
1319    fn deeply_nested_signature_rejected_without_overflow() {
1320        // Wrap a NodeKeySignature inside `nested` far past MAX_SIG_NESTING_DEPTH. This is cheap to
1321        // construct (a few bytes per level) and would overflow an unbounded recursive decoder. The
1322        // decoder must reject it as a Decode error — never panic / stack-overflow.
1323        let mut sig = NodeKeySignature {
1324            sig_kind: SigKind::Direct,
1325            pubkey: alloc::vec![1u8; 32],
1326            key_id: alloc::vec![2u8; 32],
1327            signature: alloc::vec![3u8; 64],
1328            nested: None,
1329            wrapping_pubkey: Vec::new(),
1330        };
1331        for _ in 0..(MAX_SIG_NESTING_DEPTH + 8) {
1332            sig = NodeKeySignature {
1333                sig_kind: SigKind::Rotation,
1334                pubkey: alloc::vec![1u8; 32],
1335                key_id: Vec::new(),
1336                signature: alloc::vec![3u8; 64],
1337                nested: Some(alloc::boxed::Box::new(sig)),
1338                wrapping_pubkey: alloc::vec![1u8; 32],
1339            };
1340        }
1341        let cbor = sig.to_cbor(true).to_vec();
1342        let err = decode_node_key_signature(&cbor).unwrap_err();
1343        assert_eq!(err, TkaError::Decode("nested signature too deep"));
1344    }
1345
1346    // ----- Fix 5: duplicate CBOR map keys rejected -----
1347
1348    #[test]
1349    fn duplicate_map_key_rejected() {
1350        // Hand-craft a CBOR map with key 1 repeated: map(2) { 1:0, 1:1 } => 0xa2 01 00 01 01.
1351        let blob = [0xa2u8, 0x01, 0x00, 0x01, 0x01];
1352        let err = decode_node_key_signature(&blob).unwrap_err();
1353        assert_eq!(err, TkaError::Decode("duplicate map key"));
1354    }
1355
1356    // ----- Fix 3: rotation-chain happy path + ZIP-215/std split -----
1357
1358    // ZIP-215 vs standard ed25519 in TKA, and why this crate carries BOTH verifiers:
1359    //
1360    //   * Direct/Credential signatures are verified with `verify_ed25519_zip215` (ed25519-zebra),
1361    //     matching Go `ed25519consensus.Verify` — the *cofactored* ZIP-215 rule the TKA leaf
1362    //     signatures are produced under. ZIP-215 is a strict superset of RFC 8032: any standard
1363    //     (dalek) signature is accepted by it, which is why the tests below can sign leaves with
1364    //     dalek and still verify under zebra.
1365    //   * The outer rotation WRAP signature is verified with `verify_ed25519_std` (ed25519-dalek),
1366    //     matching Go's plain `ed25519.Verify` for the rotation wrap. Collapsing these two
1367    //     verifiers into one would silently diverge from Go on the wire — hence both deps are kept
1368    //     (see Cargo.toml comment).
1369    #[test]
1370    fn rotation_chain_verifies_end_to_end() {
1371        use ed25519_dalek::{Signer, SigningKey};
1372
1373        // Trusted key signs the inner Direct over the wrapping (pivot) pubkey.
1374        let trusted = SigningKey::from_bytes(&[7u8; 32]);
1375        let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
1376
1377        // The rotation pivot: a fresh keypair whose public key the inner Direct authorizes and
1378        // whose private key signs the outer rotation wrap.
1379        let wrapping = SigningKey::from_bytes(&[9u8; 32]);
1380        let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
1381
1382        let node_key = alloc::vec![5u8; 32];
1383
1384        let auth = Authority::from_state(
1385            AumHash([0; 32]),
1386            State {
1387                keys: alloc::vec![Key {
1388                    kind: KeyKind::Ed25519,
1389                    votes: 1,
1390                    public: trusted_pub.clone(),
1391                }],
1392            },
1393        );
1394
1395        // Inner Direct: trusted key authorizes the wrapping pubkey. Verified ZIP-215, so a dalek
1396        // signature is accepted.
1397        let mut inner = NodeKeySignature {
1398            sig_kind: SigKind::Direct,
1399            pubkey: wrapping_pub.clone(),
1400            key_id: trusted_pub.clone(),
1401            signature: Vec::new(),
1402            nested: None,
1403            wrapping_pubkey: wrapping_pub.clone(),
1404        };
1405        let inner_hash = inner.sig_hash();
1406        inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
1407
1408        // Outer Rotation: signs the node key with the wrapping key (verified STANDARD ed25519).
1409        let mut outer = NodeKeySignature {
1410            sig_kind: SigKind::Rotation,
1411            pubkey: node_key.clone(),
1412            key_id: Vec::new(),
1413            signature: Vec::new(),
1414            nested: Some(alloc::boxed::Box::new(inner)),
1415            wrapping_pubkey: Vec::new(),
1416        };
1417        let outer_hash = outer.sig_hash();
1418        outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
1419
1420        let cbor = outer.to_cbor(true).to_vec();
1421        assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
1422
1423        // A tampered rotation-wrap signature must be rejected by the STANDARD ed25519 verifier.
1424        let mut tampered = outer.clone();
1425        let mut sb = tampered.signature.clone();
1426        sb[0] ^= 0xff;
1427        tampered.signature = sb;
1428        let cbor_bad = tampered.to_cbor(true).to_vec();
1429        assert_eq!(
1430            auth.node_key_authorized(&node_key, &cbor_bad).unwrap_err(),
1431            TkaError::BadSignature
1432        );
1433    }
1434
1435    // ----- Fix 2: nested-Credential pubkey must bind to the rotation pivot -----
1436
1437    #[test]
1438    fn rotation_nested_credential_pubkey_bind() {
1439        use ed25519_dalek::{Signer, SigningKey};
1440
1441        let trusted = SigningKey::from_bytes(&[11u8; 32]);
1442        let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
1443        let wrapping = SigningKey::from_bytes(&[13u8; 32]);
1444        let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
1445        let node_key = alloc::vec![6u8; 32];
1446
1447        let auth = Authority::from_state(
1448            AumHash([0; 32]),
1449            State {
1450                keys: alloc::vec![Key {
1451                    kind: KeyKind::Ed25519,
1452                    votes: 1,
1453                    public: trusted_pub.clone(),
1454                }],
1455            },
1456        );
1457
1458        // Helper: build a rotation wrapping a nested Credential whose `pubkey` is `cred_pubkey`.
1459        let build = |cred_pubkey: Vec<u8>| -> Vec<u8> {
1460            let mut inner = NodeKeySignature {
1461                sig_kind: SigKind::Credential,
1462                pubkey: cred_pubkey,
1463                key_id: trusted_pub.clone(),
1464                signature: Vec::new(),
1465                nested: None,
1466                wrapping_pubkey: wrapping_pub.clone(),
1467            };
1468            let inner_hash = inner.sig_hash();
1469            inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
1470
1471            let mut outer = NodeKeySignature {
1472                sig_kind: SigKind::Rotation,
1473                pubkey: node_key.clone(),
1474                key_id: Vec::new(),
1475                signature: Vec::new(),
1476                nested: Some(alloc::boxed::Box::new(inner)),
1477                wrapping_pubkey: Vec::new(),
1478            };
1479            let outer_hash = outer.sig_hash();
1480            outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
1481            outer.to_cbor(true).to_vec()
1482        };
1483
1484        // Matching: credential covers exactly the wrapping pivot pubkey -> accepted.
1485        let cbor_ok = build(wrapping_pub.clone());
1486        assert!(auth.node_key_authorized(&node_key, &cbor_ok).is_ok());
1487
1488        // Mismatch: credential covers an unrelated pubkey -> rejected (NodeKeyMismatch), even though
1489        // the credential is otherwise signed by the trusted key.
1490        let cbor_bad = build(alloc::vec![0xaau8; 32]);
1491        assert_eq!(
1492            auth.node_key_authorized(&node_key, &cbor_bad).unwrap_err(),
1493            TkaError::NodeKeyMismatch
1494        );
1495    }
1496
1497    // ----- CTAP2-CBOR byte-exactness FROZEN regression vector -----
1498
1499    /// A small hex helper for embedding captured bytes in a failure message.
1500    fn hex(bytes: &[u8]) -> String {
1501        let mut s = String::new();
1502        for b in bytes {
1503            s.push_str(&alloc::format!("{b:02x}"));
1504        }
1505        s
1506    }
1507
1508    /// FROZEN CTAP2-CBOR byte-exactness vector for the wire/signing serialization.
1509    ///
1510    /// The crate docs (and the `cbor` module) state the CTAP2-canonical CBOR encoding is asserted by
1511    /// construction but NOT cross-validated against Go's `fxamacker/cbor` (CTAP2 mode) in this fork.
1512    /// The existing TKA tests build a signature, sign it, and verify round-trip — so they would all
1513    /// still pass if the canonical encoding silently changed (int-map key ordering, smallest-int
1514    /// rule, omitempty), because both sides of the round-trip use the same encoder. That class of
1515    /// change would, however, break wire-compat with a live Go TKA.
1516    ///
1517    /// This pins the EXACT bytes for a fixed `NodeKeySignature` (Direct, deterministic key material):
1518    /// the full `to_cbor(true)` serialization, the `to_cbor(false)` SigHash preimage, the resulting
1519    /// `sig_hash` (BLAKE2s-256 of the preimage), and the `aum_hash` over the full serialization. ANY
1520    /// accidental change to canonical-CBOR encoding or the BLAKE2s digest breaks this test.
1521    ///
1522    /// NOTE: this is a regression-FREEZE vector captured from the current encoder, NOT a Go-sourced
1523    /// cross-vector. It should be replaced with a real `fxamacker/cbor` CTAP2 vector (the same
1524    /// `NodeKeySignature` encoded by a live Go `tka`) once one can be captured.
1525    #[test]
1526    fn node_key_signature_cbor_frozen_vector() {
1527        // Deterministic, fixed key material — NOT random. byte i = i, so the bytes are obvious.
1528        let pubkey: Vec<u8> = (0u8..32).collect();
1529        let key_id: Vec<u8> = (32u8..64).collect();
1530        let signature: Vec<u8> = (64u8..128).collect();
1531
1532        let sig = NodeKeySignature {
1533            sig_kind: SigKind::Direct,
1534            pubkey,
1535            key_id,
1536            signature,
1537            nested: None,
1538            wrapping_pubkey: Vec::new(), // empty -> omitted (omitempty), key 6 must NOT appear
1539        };
1540
1541        // 1. Full serialization (include_signature = true): keys 1,2,3,4 present, 5/6 omitted.
1542        let full = sig.to_cbor(true).to_vec();
1543        const EXPECTED_FULL: &[u8] = &[
1544            0xa4, 0x01, 0x01, 0x02, 0x58, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
1545            0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
1546            0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x03, 0x58, 0x20, 0x20,
1547            0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e,
1548            0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
1549            0x3d, 0x3e, 0x3f, 0x04, 0x58, 0x40, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47,
1550            0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55,
1551            0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, 0x61, 0x62, 0x63,
1552            0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71,
1553            0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f,
1554        ];
1555        assert_eq!(
1556            full,
1557            EXPECTED_FULL,
1558            "full CBOR serialization changed (canonical-CBOR encoding drift). actual: {}",
1559            hex(&full)
1560        );
1561
1562        // 2. SigHash preimage (include_signature = false): key 4 (signature) omitted.
1563        let preimage = sig.to_cbor(false).to_vec();
1564        const EXPECTED_PREIMAGE: &[u8] = &[
1565            0xa3, 0x01, 0x01, 0x02, 0x58, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
1566            0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
1567            0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x03, 0x58, 0x20, 0x20,
1568            0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e,
1569            0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
1570            0x3d, 0x3e, 0x3f,
1571        ];
1572        assert_eq!(
1573            preimage,
1574            EXPECTED_PREIMAGE,
1575            "SigHash preimage CBOR changed. actual: {}",
1576            hex(&preimage)
1577        );
1578
1579        // 3. sig_hash = BLAKE2s-256(preimage) — pinned.
1580        let sig_hash = sig.sig_hash();
1581        const EXPECTED_SIG_HASH: [u8; AUM_HASH_LEN] = [
1582            0x22, 0x6f, 0x9c, 0xbc, 0x63, 0x73, 0x92, 0x75, 0x2e, 0x0e, 0xb1, 0x32, 0x9c, 0xc4,
1583            0x99, 0x07, 0x01, 0x4a, 0xb6, 0x4f, 0x8e, 0x5d, 0x82, 0x85, 0xc2, 0x91, 0x42, 0x62,
1584            0xf6, 0xa6, 0xa8, 0x33,
1585        ];
1586        assert_eq!(
1587            sig_hash,
1588            EXPECTED_SIG_HASH,
1589            "sig_hash (BLAKE2s-256 of preimage) changed. actual: {}",
1590            hex(&sig_hash)
1591        );
1592
1593        // 4. aum_hash over the full serialization — pinned (exercises the public `aum_hash` helper
1594        //    + BLAKE2s digest over a frozen input).
1595        let aum = aum_hash(&full);
1596        const EXPECTED_AUM_HASH: [u8; AUM_HASH_LEN] = [
1597            0xa4, 0x40, 0x71, 0xa3, 0x7a, 0xbf, 0x80, 0x92, 0xd6, 0xff, 0x23, 0x84, 0xb2, 0xb0,
1598            0xa3, 0x50, 0xc7, 0xcb, 0x48, 0x41, 0xed, 0x68, 0x99, 0x62, 0x41, 0x7c, 0xd4, 0x23,
1599            0x68, 0xdc, 0x72, 0x49,
1600        ];
1601        assert_eq!(
1602            aum.0,
1603            EXPECTED_AUM_HASH,
1604            "aum_hash over full serialization changed. actual: {}",
1605            hex(&aum.0)
1606        );
1607    }
1608
1609    // ----- ed25519-speccheck KAT: dual-verifier (dalek std vs zebra ZIP-215) -----
1610
1611    /// Decode an ASCII hex string to bytes. Panics on malformed input (test-only).
1612    fn unhex(s: &str) -> Vec<u8> {
1613        assert!(s.len().is_multiple_of(2), "odd hex length");
1614        let nib = |c: u8| -> u8 {
1615            match c {
1616                b'0'..=b'9' => c - b'0',
1617                b'a'..=b'f' => c - b'a' + 10,
1618                b'A'..=b'F' => c - b'A' + 10,
1619                _ => panic!("bad hex nibble"),
1620            }
1621        };
1622        let b = s.as_bytes();
1623        let mut out = Vec::with_capacity(s.len() / 2);
1624        let mut i = 0;
1625        while i < b.len() {
1626            out.push((nib(b[i]) << 4) | nib(b[i + 1]));
1627            i += 2;
1628        }
1629        out
1630    }
1631
1632    /// The 12 adversarial Ed25519 vectors from `novifinancial/ed25519-speccheck`.
1633    ///
1634    /// Provenance: `cases.json` at commit `65519336fda78a3d016e947df6d82848aca0c9da`
1635    /// (<https://github.com/novifinancial/ed25519-speccheck/blob/main/cases.json>), the canonical
1636    /// generated vectors backing the "Taming the many EdDSAs" paper (IACR 2020/1244, Table 6c).
1637    /// The hex below is copied byte-for-byte from that file; the `message` field is itself hex
1638    /// (the speccheck driver hex-decodes it before verifying), so we decode it the same way.
1639    ///
1640    /// Tuple layout: `(message_hex, pubkey_hex, signature_hex)`.
1641    const SPECCHECK_VECTORS: [(&str, &str, &str); 12] = [
1642        // 0: S = 0; both A and R small-order.
1643        (
1644            "8c93255d71dcab10e8f379c26200f3c7bd5f09d9bc3068d3ef4edeb4853022b6",
1645            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
1646            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a0000000000000000000000000000000000000000000000000000000000000000",
1647        ),
1648        // 1: 0 < S < L; small A only.
1649        (
1650            "9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79",
1651            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
1652            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43a5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
1653        ),
1654        // 2: 0 < S < L; small R only.
1655        (
1656            "aebf3f2601a0c8c5d39cc7d8911642f740b78168218da8471772b35f9d35b9ab",
1657            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
1658            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa8c4bd45aecaca5b24fb97bc10ac27ac8751a7dfe1baff8b953ec9f5833ca260e",
1659        ),
1660        // 3: A and R mixed-order; passes both (unless full-order checked).
1661        (
1662            "9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79",
1663            "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
1664            "9046a64750444938de19f227bb80485e92b83fdb4b6506c160484c016cc1852f87909e14428a7a1d62e9f22f3d3ad7802db02eb2e688b6c52fcd6648a98bd009",
1665        ),
1666        // 4: A and R mixed; passes cofactored, FAILS cofactorless — the cofactored discriminator.
1667        (
1668            "e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c",
1669            "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
1670            "160a1cb0dc9c0258cd0a7d23e94d8fa878bcb1925f2c64246b2dee1796bed5125ec6bc982a269b723e0668e540911a9a6a58921d6925e434ab10aa7940551a09",
1671        ),
1672        // 5: A mixed, R order L; "fails cofactored iff (8h) prereduced".
1673        (
1674            "e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c",
1675            "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
1676            "21122a84e0b5fca4052f5b1235c80a537878b38f3142356b2c2384ebad4668b7e40bc836dac0f71076f9abe3a53f9c03c1ceeeddb658d0030494ace586687405",
1677        ),
1678        // 6: S > L (out of bounds) — malleability vector; std verifier MUST reject.
1679        (
1680            "85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40",
1681            "442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623",
1682            "e96f66be976d82e60150baecff9906684aebb1ef181f67a7189ac78ea23b6c0e547f7690a0e2ddcd04d87dbc3490dc19b3b3052f7ff0538cb68afb369ba3a514",
1683        ),
1684        // 7: S >> L (no canonical serialization with null high bit) — std verifier MUST reject.
1685        (
1686            "85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40",
1687            "442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623",
1688            "8ce5b96c8f26d0ab6c47958c9e68b937104cd36e13c33566acd2fe8d38aa19427e71f98a473474f2f13f06f97c20d58cc3f54b8bd0d272f42b695dd7e89a8c22",
1689        ),
1690        // 8: 0 < S < L; non-canonical R, reduced for hash.
1691        (
1692            "9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41",
1693            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
1694            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff03be9678ac102edcd92b0210bb34d7428d12ffc5df5f37e359941266a4e35f0f",
1695        ),
1696        // 9: 0 < S < L; non-canonical R, NOT reduced for hash.
1697        (
1698            "9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41",
1699            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
1700            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffca8c5b64cd208982aa38d4936621a4775aa233aa0505711d8fdcfdaa943d4908",
1701        ),
1702        // 10: 0 < S < L; non-canonical A, reduced for hash.
1703        (
1704            "e96b7021eb39c1a163b6da4e3093dcd3f21387da4cc4572be588fafae23c155b",
1705            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
1706            "a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
1707        ),
1708        // 11: 0 < S < L; non-canonical A, NOT reduced for hash.
1709        (
1710            "39a591f5321bbe07fd5a23dc2f39d025d74526615746727ceefd6e82ae65c06f",
1711            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
1712            "a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
1713        ),
1714    ];
1715
1716    /// Known-answer test guarding the dual-verifier split that backs TKA consensus correctness.
1717    ///
1718    /// `verify_ed25519_std` wraps `ed25519-dalek 2.x` (standard RFC-8032-ish, cofactorless) and is
1719    /// used for SigRotation WRAPPING signatures. `verify_ed25519_zip215` wraps `ed25519-zebra 4.x`
1720    /// (ZIP-215 cofactored) and is used for Direct/Credential signatures to match Go
1721    /// `ed25519consensus`. If these two ever collapse to identical behavior, Go wire-compat for
1722    /// Tailnet-Lock silently breaks — this test proves they remain distinct on the adversarial set.
1723    ///
1724    /// The accept/reject matrix is asserted **as actually observed** from the pinned crate versions
1725    /// (`ed25519-dalek 2.2.0`, `ed25519-zebra 4.2.0`). These are newer than the versions tabulated
1726    /// in the "Taming the many EdDSAs" paper (Table 5: dalek 1.0.0-pre.4, zebra 2.1.1), so the
1727    /// non-canonical cases (8–11) may differ from the paper; we lock in current behavior as a
1728    /// regression guard. The SECURITY-CRITICAL invariants are NOT version-tunable: the standard
1729    /// verifier MUST reject the S >= L malleability vectors (6, 7), and the two verifiers MUST
1730    /// disagree on the cofactored discriminator (vector 4). Those are hard, separate assertions.
1731    #[test]
1732    fn ed25519_speccheck_dual_verifier_kat() {
1733        // Observed accept(true)/reject(false) matrix for the pinned crates, vectors 0..=11.
1734        // Anchored to the speccheck paper rows then corrected to what the crates actually do
1735        // (see the per-vector run below — any divergence makes the test fail loudly).
1736        //
1737        //                              0    1    2    3    4    5    6    7    8    9    10   11
1738        const STD_EXPECT: [bool; 12] = [
1739            true, true, true, true, false, false, false, false, false, false, false, true,
1740        ];
1741        const ZIP215_EXPECT: [bool; 12] = [
1742            true, true, true, true, true, true, false, false, false, true, true, true,
1743        ];
1744
1745        for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
1746            let msg = unhex(msg_hex);
1747            let pk = unhex(pk_hex);
1748            let sig = unhex(sig_hex);
1749            assert_eq!(pk.len(), 32, "vector {i}: pubkey not 32 bytes");
1750            assert_eq!(sig.len(), 64, "vector {i}: signature not 64 bytes");
1751
1752            let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
1753            let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
1754
1755            assert_eq!(
1756                std_ok, STD_EXPECT[i],
1757                "speccheck vector {i}: verify_ed25519_std accept={std_ok}, expected {}",
1758                STD_EXPECT[i]
1759            );
1760            assert_eq!(
1761                zip_ok, ZIP215_EXPECT[i],
1762                "speccheck vector {i}: verify_ed25519_zip215 accept={zip_ok}, expected {}",
1763                ZIP215_EXPECT[i]
1764            );
1765        }
1766
1767        // SECURITY-CRITICAL invariant (NOT version-tunable): the standard verifier must reject
1768        // signatures whose scalar S is out of range (S >= L). These are vectors 6 and 7 — the
1769        // EdDSA malleability guard. If either ACCEPTS, that is a real security finding.
1770        for &i in &[6usize, 7usize] {
1771            let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[i];
1772            let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
1773            assert!(
1774                verify_ed25519_std(&pk, &msg, &sig).is_err(),
1775                "SECURITY: verify_ed25519_std ACCEPTED S>=L malleability vector {i}"
1776            );
1777        }
1778
1779        // KEY DISCRIMINATOR (vector 4): cofactored (ZIP-215/zebra) accepts, cofactorless
1780        // (standard/dalek) rejects, on the SAME (pk, msg, sig). This proves the dual-verifier
1781        // split is real and not accidentally identical.
1782        {
1783            let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[4];
1784            let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
1785            assert!(
1786                verify_ed25519_zip215(&pk, &msg, &sig).is_ok(),
1787                "vector 4: ZIP-215 (zebra) should ACCEPT the cofactored discriminator"
1788            );
1789            assert!(
1790                verify_ed25519_std(&pk, &msg, &sig).is_err(),
1791                "vector 4: standard (dalek) should REJECT the cofactored discriminator"
1792            );
1793        }
1794    }
1795
1796    // ----- Cross-implementation KATs against real Go `tailscale.com/tka` v1.100.0 -----
1797
1798    /// Cross-implementation Known-Answer-Test: the CTAP2-CBOR serialization and BLAKE2s-256
1799    /// `SigHash` of three `NodeKeySignature` shapes must byte-match the REAL Go
1800    /// `tailscale.com/tka` package, version **v1.100.0** (toolchain **go1.26.4**).
1801    ///
1802    /// Provenance: the golden bytes below were produced by a Go generator that imports the real
1803    /// upstream `tailscale.com/tka` and calls `NodeKeySignature.Serialize()` (full CBOR including
1804    /// the signature field) and `NodeKeySignature.SigHash()` (BLAKE2s-256 of the CBOR with the
1805    /// `Signature` field nil'd). They are authoritative upstream output, NOT this fork's own
1806    /// encoder echoed back — this is the cross-validation the `node_key_signature_cbor_frozen_vector`
1807    /// freeze-test could not provide. The generator lives alongside the speccheck generator under
1808    /// `tests/vectors/gen` (Go module pinned to `tailscale.com v1.100.0`).
1809    ///
1810    /// Three shapes are covered: a `Direct` leaf, a `Credential` leaf (same fields, different
1811    /// `sigKind`), and a `Rotation` wrapping a nested `Direct` (the rotation-chain wire form). The
1812    /// int-map keys are 1=sigKind, 2=pubkey, 3=keyID, 4=signature, 5=nested, 6=wrappingPubkey;
1813    /// empty byte fields are omitted (`omitempty`).
1814    #[test]
1815    fn tka_cbor_matches_go_golden() {
1816        // Common fixed field material (real Go generator inputs).
1817        let pubkey32 = unhex("a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf");
1818        let key_id32 = unhex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f");
1819        let sig64 = unhex(
1820            "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
1821        );
1822        let wrap32 = unhex("101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f");
1823        let rot_sig64 = unhex(
1824            "55565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f9091929394",
1825        );
1826
1827        // GOLDEN 1 — Direct.
1828        {
1829            let sig = NodeKeySignature {
1830                sig_kind: SigKind::Direct,
1831                pubkey: pubkey32.clone(),
1832                key_id: key_id32.clone(),
1833                signature: sig64.clone(),
1834                nested: None,
1835                wrapping_pubkey: Vec::new(),
1836            };
1837            let full = sig.to_cbor(true).to_vec();
1838            let expected_full = unhex(
1839                "a40101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
1840            );
1841            assert_eq!(
1842                full,
1843                expected_full,
1844                "GOLDEN 1 (Direct) full CBOR diverged from Go tka v1.100.0. actual: {}",
1845                hex(&full)
1846            );
1847            let expected_hash =
1848                unhex("7e9653c97d35485b37b9bf942b1861cd2f3cb0663b5bb154f1178cca72101e74");
1849            assert_eq!(
1850                sig.sig_hash().as_slice(),
1851                expected_hash.as_slice(),
1852                "GOLDEN 1 (Direct) sig_hash diverged from Go tka v1.100.0. actual: {}",
1853                hex(&sig.sig_hash())
1854            );
1855        }
1856
1857        // GOLDEN 2 — Credential (same fields as Direct, sigKind=3).
1858        {
1859            let sig = NodeKeySignature {
1860                sig_kind: SigKind::Credential,
1861                pubkey: pubkey32.clone(),
1862                key_id: key_id32.clone(),
1863                signature: sig64.clone(),
1864                nested: None,
1865                wrapping_pubkey: Vec::new(),
1866            };
1867            let full = sig.to_cbor(true).to_vec();
1868            let expected_full = unhex(
1869                "a40103025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
1870            );
1871            assert_eq!(
1872                full,
1873                expected_full,
1874                "GOLDEN 2 (Credential) full CBOR diverged from Go tka v1.100.0. actual: {}",
1875                hex(&full)
1876            );
1877            let expected_hash =
1878                unhex("b6070ea8bc7ae8989ef4293f5031bedaa4a499803ade99f9e2f34dc2898ac03f");
1879            assert_eq!(
1880                sig.sig_hash().as_slice(),
1881                expected_hash.as_slice(),
1882                "GOLDEN 2 (Credential) sig_hash diverged from Go tka v1.100.0. actual: {}",
1883                hex(&sig.sig_hash())
1884            );
1885        }
1886
1887        // GOLDEN 3 — Rotation wrapping a nested Direct.
1888        //
1889        // Decoded from the authoritative Go bytes, the OUTER map is `a4` = 4 entries: keys
1890        // 1=sigKind(Rotation), 2=pubkey(wrap32), 4=signature(rotSig64), 5=nested. The outer has NO
1891        // key 6 (its `wrapping_pubkey` is EMPTY → omitted) and NO key 3 (its `key_id` is EMPTY →
1892        // omitted). The trailing `065820<wrap32>` in the hex belongs to the NESTED Direct map
1893        // (`a5` = 5 entries: keys 1,2,3,4,6), whose `wrapping_pubkey` IS set to wrap32. Constructing
1894        // the structs this way (outer wrapping_pubkey empty, nested wrapping_pubkey=wrap32)
1895        // reproduces the Go bytes exactly.
1896        {
1897            let nested = NodeKeySignature {
1898                sig_kind: SigKind::Direct,
1899                pubkey: pubkey32.clone(),
1900                key_id: key_id32.clone(),
1901                signature: sig64.clone(),
1902                nested: None,
1903                wrapping_pubkey: wrap32.clone(),
1904            };
1905            let sig = NodeKeySignature {
1906                sig_kind: SigKind::Rotation,
1907                pubkey: wrap32.clone(),
1908                key_id: Vec::new(),
1909                signature: rot_sig64.clone(),
1910                nested: Some(alloc::boxed::Box::new(nested)),
1911                wrapping_pubkey: Vec::new(),
1912            };
1913            let full = sig.to_cbor(true).to_vec();
1914            let expected_full = unhex(
1915                "a40102025820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f04584055565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939405a50101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf065820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f",
1916            );
1917            assert_eq!(
1918                full,
1919                expected_full,
1920                "GOLDEN 3 (Rotation) full CBOR diverged from Go tka v1.100.0. actual: {}",
1921                hex(&full)
1922            );
1923            let expected_hash =
1924                unhex("fac0a5a6781bb945369c28a0b3d3eea04e1648b60ec1a990a1ff68a9a566e6a7");
1925            assert_eq!(
1926                sig.sig_hash().as_slice(),
1927                expected_hash.as_slice(),
1928                "GOLDEN 3 (Rotation) sig_hash diverged from Go tka v1.100.0. actual: {}",
1929                hex(&sig.sig_hash())
1930            );
1931        }
1932    }
1933
1934    /// Cross-bind the dual Ed25519 verifier accept/reject matrix to the verdicts produced by the
1935    /// REAL Go implementations on the adversarial speccheck set (see [`SPECCHECK_VECTORS`]).
1936    ///
1937    /// Provenance of the Go verdicts: Go `crypto/ed25519.Verify` (standard, cofactorless) and
1938    /// `github.com/hdevalence/ed25519consensus v0.2.0` (ZIP-215, cofactored), toolchain
1939    /// **go1.26.4**, driven by the generator under `tests/vectors/gen/zip215`. These are the SAME
1940    /// verdicts the pinned Rust crates produce — proving `ed25519-dalek 2.x` == Go-std and
1941    /// `ed25519-zebra 4.x` == Go-`ed25519consensus` on the adversarial set. The arrays below MUST
1942    /// therefore equal `STD_EXPECT` / `ZIP215_EXPECT` asserted in
1943    /// `ed25519_speccheck_dual_verifier_kat`; this test additionally pins them to Go's behavior.
1944    ///
1945    /// NOTE: [`SPECCHECK_VECTORS`] is duplicated (byte-for-byte) in the Go generator at
1946    /// `tests/vectors/gen/zip215/main.go`. Both copies derive from the same upstream
1947    /// `cases.json` commit; if you edit one you MUST edit the other, or this proof would compare
1948    /// inputs the Go verdicts were never computed over.
1949    #[test]
1950    fn ed25519_dual_verifier_matches_go_verdicts() {
1951        //                                  0    1    2    3    4    5    6    7    8    9   10   11
1952        const GO_STD_ACCEPT: [bool; 12] = [
1953            true, true, true, true, false, false, false, false, false, false, false, true,
1954        ];
1955        const GO_ZIP215_ACCEPT: [bool; 12] = [
1956            true, true, true, true, true, true, false, false, false, true, true, true,
1957        ];
1958
1959        for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
1960            let msg = unhex(msg_hex);
1961            let pk = unhex(pk_hex);
1962            let sig = unhex(sig_hex);
1963
1964            let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
1965            let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
1966
1967            assert_eq!(
1968                std_ok, GO_STD_ACCEPT[i],
1969                "vector {i}: Rust verify_ed25519_std accept={std_ok} disagrees with Go \
1970                 crypto/ed25519.Verify={}",
1971                GO_STD_ACCEPT[i]
1972            );
1973            assert_eq!(
1974                zip_ok, GO_ZIP215_ACCEPT[i],
1975                "vector {i}: Rust verify_ed25519_zip215 accept={zip_ok} disagrees with Go \
1976                 ed25519consensus.Verify={}",
1977                GO_ZIP215_ACCEPT[i]
1978            );
1979        }
1980    }
1981
1982    /// Byte-exact cross-validation of [`Aum::serialize`] against the literal `[]byte` vectors in Go
1983    /// `tka/aum_test.go` `TestSerialization` (tailscale v1.100.0, fxamacker/cbor v2.9.2 CTAP2 mode).
1984    /// These are the authoritative oracle: if our CTAP2 CBOR diverges from Go by a single byte, the
1985    /// `AUM.Hash` chain links and every signature digest break. Each case reproduces the exact Go
1986    /// `AUM{…}` literal and asserts identical canonical bytes.
1987    #[test]
1988    fn aum_serialize_matches_go_test_serialization_vectors() {
1989        // AddKey: AUM{MessageKind: AUMAddKey, Key: &Key{}}. Go's *zero* Key{} has Kind=0
1990        // (KeyInvalid) and Public=nil, which our `AumKey` (always a valid KeyKind + Vec) cannot
1991        // model — that zero-Key encoding (`03 a3 01 00 02 00 03 f6`) is asserted directly at the
1992        // CBOR layer here, while the AUM keymap around it (map3, kind=AddKey, null prev, Key at
1993        // key 3) is covered by the structural assertions plus the three full vectors below.
1994        let add_key_inner_zero_key = cbor::Value::IntMap(alloc::vec![
1995            (1, cbor::Value::Uint(0)), // Kind = KeyInvalid(0)
1996            (2, cbor::Value::Uint(0)), // Votes = 0
1997            (3, cbor::Value::Null),    // Public = nil -> null
1998        ]);
1999        assert_eq!(
2000            add_key_inner_zero_key.to_vec(),
2001            alloc::vec![0xa3, 0x01, 0x00, 0x02, 0x00, 0x03, 0xf6],
2002            "Go's zero Key{{}} encodes as map(3){{kind=0, votes=0, public=null}}"
2003        );
2004
2005        // RemoveKey: AUM{MessageKind: AUMRemoveKey, KeyID: []byte{1, 2}}
2006        let remove_key = Aum {
2007            message_kind: AumKind::RemoveKey,
2008            prev_aum_hash: None,
2009            key: None,
2010            key_id: alloc::vec![1, 2],
2011            state: None,
2012            votes: None,
2013            meta: Vec::new(),
2014            signatures: Vec::new(),
2015        };
2016        assert_eq!(
2017            remove_key.serialize(),
2018            // a3 (map3) 01 02 (kind=RemoveKey) 02 f6 (prev=null) 04 42 01 02 (KeyID=bytes{1,2})
2019            alloc::vec![0xa3, 0x01, 0x02, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02],
2020            "RemoveKey AUM serialization must match Go TestSerialization byte-for-byte"
2021        );
2022
2023        // UpdateKey: AUM{MessageKind: AUMUpdateKey, Votes: &uint(2), KeyID: []byte{1,2},
2024        //                Meta: map[string]string{"a":"b"}}
2025        let update_key = Aum {
2026            message_kind: AumKind::UpdateKey,
2027            prev_aum_hash: None,
2028            key: None,
2029            key_id: alloc::vec![1, 2],
2030            state: None,
2031            votes: Some(2),
2032            meta: alloc::vec![("a".into(), "b".into())],
2033            signatures: Vec::new(),
2034        };
2035        assert_eq!(
2036            update_key.serialize(),
2037            // a5 (map5) 01 04 (UpdateKey) 02 f6 (prev null) 04 42 01 02 (KeyID) 06 02 (Votes=2)
2038            // 07 a1 61 61 61 62 (Meta = {"a":"b"})  — keys ascending 1,2,4,6,7
2039            alloc::vec![
2040                0xa5, 0x01, 0x04, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02, 0x06, 0x02, 0x07, 0xa1, 0x61,
2041                0x61, 0x61, 0x62
2042            ],
2043            "UpdateKey AUM serialization must match Go TestSerialization byte-for-byte"
2044        );
2045
2046        // Signature: AUM{MessageKind: AUMAddKey, Signatures: []tkatype.Signature{{KeyID: []byte{1}}}}
2047        let with_sig = Aum {
2048            message_kind: AumKind::AddKey,
2049            prev_aum_hash: None,
2050            key: None,
2051            key_id: Vec::new(),
2052            state: None,
2053            votes: None,
2054            meta: Vec::new(),
2055            signatures: alloc::vec![AumSignature {
2056                key_id: alloc::vec![1],
2057                signature: Vec::new(),
2058            }],
2059        };
2060        assert_eq!(
2061            with_sig.serialize(),
2062            // a3 (map3) 01 01 (AddKey) 02 f6 (prev null) 17 (key 23 = Signatures) 81 (array1)
2063            // a2 (map2) 01 41 01 (Signature.KeyID = bytes{1}) 02 f6 (Signature.Signature = null)
2064            alloc::vec![
2065                0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6
2066            ],
2067            "Signature AUM serialization must match Go TestSerialization (key 23 + nil sig = null)"
2068        );
2069
2070        // sig_hash must drop key 23 (Go SigHash nils Signatures → omitempty): the with_sig AUM's
2071        // sig_hash equals the BLAKE2s of the same AUM with no signatures.
2072        let no_sig = Aum {
2073            signatures: Vec::new(),
2074            ..with_sig.clone()
2075        };
2076        assert_eq!(
2077            with_sig.sig_hash(),
2078            blake2s_256(&no_sig.serialize()),
2079            "SigHash preimage must omit key 23 (Signatures), matching Go AUM.SigHash"
2080        );
2081        // And the full Hash differs from the SigHash (signatures are in the chain-link hash).
2082        assert_ne!(
2083            with_sig.hash().0,
2084            with_sig.sig_hash(),
2085            "Hash (incl. signatures) must differ from SigHash (excl.) when signatures are present"
2086        );
2087    }
2088
2089    /// Checkpoint AUM with an embedded `State`: exercises [`AumState`]/[`AumKey`] CBOR (the 32-byte
2090    /// `LastAUMHash` as a definite-length byte string, the `DisablementValues`/`Keys` arrays, and the
2091    /// `Key.Public` at key 3). Mirrors the structure of Go's `TestSerialization` Checkpoint case.
2092    #[test]
2093    fn aum_checkpoint_state_serialization() {
2094        let checkpoint = Aum {
2095            message_kind: AumKind::Checkpoint,
2096            prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
2097            key: None,
2098            key_id: Vec::new(),
2099            state: Some(AumState {
2100                last_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
2101                disablement_values: Some(Vec::new()),
2102                keys: Some(alloc::vec![AumKey {
2103                    kind: KeyKind::Ed25519,
2104                    votes: 1,
2105                    public: alloc::vec![5, 6],
2106                    meta: Vec::new(),
2107                }]),
2108                state_id1: 0,
2109                state_id2: 0,
2110            }),
2111            votes: None,
2112            meta: Vec::new(),
2113            signatures: Vec::new(),
2114        };
2115        let bytes = checkpoint.serialize();
2116        // Spot-check the structurally-load-bearing pieces (full-vector parity is covered by the
2117        // three exact vectors above; here we pin the State/Key encoding shape):
2118        // map3: key1=Checkpoint(5), key2=prev(32-byte bytestring 0x58 0x20 …), key5=State.
2119        assert_eq!(
2120            &bytes[0..3],
2121            &[0xa3, 0x01, 0x05],
2122            "map(3), MessageKind=Checkpoint(5)"
2123        );
2124        assert_eq!(
2125            &bytes[3..6],
2126            &[0x02, 0x58, 0x20],
2127            "key2 prev = 32-byte byte string head"
2128        );
2129        // The embedded State map (key 5) must contain: LastAUMHash (1) as 32-byte bytes, an empty
2130        // DisablementValues array (2 → 0x80), and a Keys array (3 → 0x81 with one Key map).
2131        // Locate the State map head (key 5) after the 32-byte prev hash: 3 + 3 + 32 = offset 38.
2132        assert_eq!(bytes[38], 0x05, "key 5 = State");
2133        // State is a map; its first entry is key1 (LastAUMHash) = 32-byte byte string.
2134        assert_eq!(
2135            &bytes[39..42],
2136            &[0xa3, 0x01, 0x58],
2137            "State map(3), key1 LastAUMHash bytes"
2138        );
2139        // The Key inside Keys carries Public={5,6} at key 3 (…03 42 05 06) and Votes=1 at key 2.
2140        let tail = &bytes[bytes.len() - 4..];
2141        assert_eq!(
2142            tail,
2143            &[0x03, 0x42, 0x05, 0x06],
2144            "Key.Public (key 3) = bytes{{5,6}}"
2145        );
2146        // Round-trips deterministically (hash is stable).
2147        assert_eq!(checkpoint.hash(), checkpoint.hash());
2148    }
2149
2150    // ---- AUM-chain replay (chunk 1B) -----------------------------------------------------------
2151
2152    /// A test trusted key from a seed byte (deterministic public key + given votes).
2153    fn test_aum_key(seed: u8, votes: u32) -> AumKey {
2154        use ed25519_dalek::SigningKey;
2155        let pubk = SigningKey::from_bytes(&[seed; 32])
2156            .verifying_key()
2157            .to_bytes()
2158            .to_vec();
2159        AumKey {
2160            kind: KeyKind::Ed25519,
2161            votes,
2162            public: pubk,
2163            meta: Vec::new(),
2164        }
2165    }
2166
2167    /// A genesis `AUMAddKey` (no parent) adding `key`.
2168    fn genesis_add(key: AumKey) -> Aum {
2169        Aum {
2170            message_kind: AumKind::AddKey,
2171            prev_aum_hash: None,
2172            key: Some(key),
2173            key_id: Vec::new(),
2174            state: None,
2175            votes: None,
2176            meta: Vec::new(),
2177            signatures: Vec::new(),
2178        }
2179    }
2180
2181    /// A child AUM of `parent` of the given kind, optionally carrying a key / key_id.
2182    fn child(parent: &Aum, kind: AumKind, key: Option<AumKey>, key_id: Vec<u8>) -> Aum {
2183        Aum {
2184            message_kind: kind,
2185            prev_aum_hash: Some(parent.hash()),
2186            key,
2187            key_id,
2188            state: None,
2189            votes: None,
2190            meta: Vec::new(),
2191            signatures: Vec::new(),
2192        }
2193    }
2194
2195    /// Linear replay applies each kind: genesis AddKey(k0), AddKey(k1), UpdateKey(k1 votes), then
2196    /// RemoveKey(k0). The final state has only k1 with its updated votes, and head = last AUM hash.
2197    #[test]
2198    fn replay_linear_chain_folds_all_kinds() {
2199        let k0 = test_aum_key(1, 1);
2200        let k1 = test_aum_key(2, 1);
2201
2202        let a0 = genesis_add(k0.clone());
2203        let a1 = child(&a0, AumKind::AddKey, Some(k1.clone()), Vec::new());
2204        let mut a2 = child(&a1, AumKind::UpdateKey, None, k1.public.clone());
2205        a2.votes = Some(5);
2206        let a3 = child(&a2, AumKind::RemoveKey, None, k0.public.clone());
2207
2208        let auth = Authority::from_chain(&[a0, a1, a2, a3.clone()]).unwrap();
2209
2210        // Only k1 remains, with the updated vote weight.
2211        assert_eq!(auth.state().keys.len(), 1, "k0 removed, k1 remains");
2212        let remaining = &auth.state().keys[0];
2213        assert_eq!(remaining.public, k1.public, "k1 is the surviving key");
2214        assert_eq!(remaining.votes, 5, "UpdateKey raised k1's votes to 5");
2215        // Head is the hash of the last applied AUM.
2216        assert_eq!(auth.head(), a3.hash(), "head = last AUM hash");
2217    }
2218
2219    /// A broken chain link (wrong `prev_aum_hash`) is rejected with `BadParent`.
2220    #[test]
2221    fn replay_rejects_broken_parent_link() {
2222        let k0 = test_aum_key(1, 1);
2223        let k1 = test_aum_key(2, 1);
2224        let a0 = genesis_add(k0);
2225        // a1 claims a bogus parent, not a0's hash.
2226        let mut a1 = child(&a0, AumKind::AddKey, Some(k1), Vec::new());
2227        a1.prev_aum_hash = Some(AumHash([0xab; 32]));
2228        assert_eq!(
2229            Authority::from_chain(&[a0, a1]).unwrap_err(),
2230            TkaError::BadParent
2231        );
2232    }
2233
2234    /// AddKey of an already-trusted key, and Remove/Update of an absent key, are rejected.
2235    #[test]
2236    fn replay_rejects_bad_key_state() {
2237        let k0 = test_aum_key(1, 1);
2238        let a0 = genesis_add(k0.clone());
2239        // Duplicate add of k0.
2240        let dup = child(&a0, AumKind::AddKey, Some(k0.clone()), Vec::new());
2241        assert_eq!(
2242            Authority::from_chain(&[a0.clone(), dup]).unwrap_err(),
2243            TkaError::BadKeyState
2244        );
2245        // Remove of a key that was never added.
2246        let absent = test_aum_key(9, 1);
2247        let rm = child(&a0, AumKind::RemoveKey, None, absent.public.clone());
2248        assert_eq!(
2249            Authority::from_chain(&[a0, rm]).unwrap_err(),
2250            TkaError::BadKeyState
2251        );
2252    }
2253
2254    /// An empty chain is rejected.
2255    #[test]
2256    fn replay_empty_chain_is_bad_chain() {
2257        assert_eq!(Authority::from_chain(&[]).unwrap_err(), TkaError::BadChain);
2258    }
2259
2260    /// `weight` sums the votes of distinct trusted signing keys: an unknown signer contributes 0, and
2261    /// a key that signs twice counts once (Go `TestAUMWeight` "Double use" → its votes, not double).
2262    #[test]
2263    fn replay_weight_dedups_and_ignores_unknown() {
2264        let k0 = test_aum_key(1, 2);
2265        let k1 = test_aum_key(2, 3);
2266        let state = ReplayState {
2267            keys: alloc::vec![k0.clone(), k1.clone()],
2268            last_aum_hash: None,
2269            state_id: None,
2270        };
2271
2272        // Empty signatures → 0.
2273        let mut aum = genesis_add(test_aum_key(5, 1));
2274        assert_eq!(state.weight(&aum), 0);
2275
2276        // One known signer (k0, votes 2).
2277        aum.signatures = alloc::vec![AumSignature {
2278            key_id: k0.public.clone(),
2279            signature: Vec::new()
2280        }];
2281        assert_eq!(state.weight(&aum), 2);
2282
2283        // Two distinct known signers → 2 + 3 = 5.
2284        aum.signatures = alloc::vec![
2285            AumSignature {
2286                key_id: k0.public.clone(),
2287                signature: Vec::new()
2288            },
2289            AumSignature {
2290                key_id: k1.public.clone(),
2291                signature: Vec::new()
2292            },
2293        ];
2294        assert_eq!(state.weight(&aum), 5);
2295
2296        // Double-use of k0 → counted once (2), not 4.
2297        aum.signatures = alloc::vec![
2298            AumSignature {
2299                key_id: k0.public.clone(),
2300                signature: Vec::new()
2301            },
2302            AumSignature {
2303                key_id: k0.public.clone(),
2304                signature: Vec::new()
2305            },
2306        ];
2307        assert_eq!(state.weight(&aum), 2, "a key signing twice counts once");
2308
2309        // Unknown signer → 0.
2310        aum.signatures = alloc::vec![AumSignature {
2311            key_id: alloc::vec![0xff; 32],
2312            signature: Vec::new()
2313        }];
2314        assert_eq!(
2315            state.weight(&aum),
2316            0,
2317            "an untrusted signing key contributes no weight"
2318        );
2319    }
2320
2321    /// `pick_next_aum` rule 3 (the deterministic tiebreak): with equal weight (0, no signatures) and
2322    /// neither a RemoveKey, the candidate with the lexicographically-lowest `Hash()` wins —
2323    /// regardless of input order, so two nodes select the same branch.
2324    #[test]
2325    fn pick_next_aum_lowest_hash_tiebreak_is_order_independent() {
2326        let k = test_aum_key(1, 1);
2327        let a0 = genesis_add(k);
2328        // Two distinct NoOp children of a0 (differ by key_id so their hashes differ).
2329        let c1 = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
2330        let c2 = child(&a0, AumKind::NoOp, None, alloc::vec![2]);
2331        let state = ReplayState::default();
2332
2333        let lower = if c1.hash().0 < c2.hash().0 {
2334            c1.hash()
2335        } else {
2336            c2.hash()
2337        };
2338        let ab = [c1.clone(), c2.clone()];
2339        let ba = [c2, c1];
2340        let pick_ab = pick_next_aum(&state, &ab).hash();
2341        let pick_ba = pick_next_aum(&state, &ba).hash();
2342        assert_eq!(pick_ab, lower, "lowest hash wins");
2343        assert_eq!(
2344            pick_ab, pick_ba,
2345            "selection is independent of candidate order"
2346        );
2347    }
2348
2349    /// `pick_next_aum` rule 1 (weight) dominates rule 3 (hash): a signed child with real weight beats
2350    /// an unsigned child even if the unsigned one has a lower hash.
2351    #[test]
2352    fn pick_next_aum_weight_beats_hash() {
2353        use ed25519_dalek::SigningKey;
2354        let signer_seed = 3u8;
2355        let signer_pub = SigningKey::from_bytes(&[signer_seed; 32])
2356            .verifying_key()
2357            .to_bytes()
2358            .to_vec();
2359        let state = ReplayState {
2360            keys: alloc::vec![AumKey {
2361                kind: KeyKind::Ed25519,
2362                votes: 4,
2363                public: signer_pub.clone(),
2364                meta: Vec::new(),
2365            }],
2366            last_aum_hash: None,
2367            state_id: None,
2368        };
2369
2370        let a0 = genesis_add(test_aum_key(1, 1));
2371        let unsigned = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
2372        let mut signed = child(&a0, AumKind::NoOp, None, alloc::vec![2]);
2373        signed.signatures = alloc::vec![AumSignature {
2374            key_id: signer_pub,
2375            signature: Vec::new(),
2376        }];
2377
2378        // The signed child wins on weight (4 > 0) no matter the hash order.
2379        let candidates = [unsigned.clone(), signed.clone()];
2380        let winner = pick_next_aum(&state, &candidates);
2381        assert_eq!(
2382            winner.hash(),
2383            signed.hash(),
2384            "higher weight wins over lower hash"
2385        );
2386    }
2387
2388    /// `from_forked_chain`: a shared genesis, then two competing RemoveKey vs NoOp branches at equal
2389    /// weight — rule 2 prefers the RemoveKey branch. The resulting state reflects the chosen branch.
2390    #[test]
2391    fn forked_chain_prefers_removekey_branch() {
2392        let k0 = test_aum_key(1, 1);
2393        let k1 = test_aum_key(2, 1);
2394        // Genesis adds both keys (two AUMs).
2395        let a0 = genesis_add(k0.clone());
2396        let a1 = child(&a0, AumKind::AddKey, Some(k1.clone()), Vec::new());
2397        // Fork at a1: branch A removes k0; branch B is a NoOp. Equal weight (0 sigs).
2398        let branch_remove = child(&a1, AumKind::RemoveKey, None, k0.public.clone());
2399        let branch_noop = child(&a1, AumKind::NoOp, None, alloc::vec![9]);
2400
2401        let noop_branch = [branch_noop.clone()];
2402        let remove_branch = [branch_remove.clone()];
2403        let auth = Authority::from_forked_chain(&[a0, a1], &[&noop_branch[..], &remove_branch[..]])
2404            .unwrap();
2405
2406        // RemoveKey branch wins → k0 gone, only k1 remains; head = the RemoveKey AUM.
2407        assert_eq!(auth.state().keys.len(), 1);
2408        assert_eq!(auth.state().keys[0].public, k1.public);
2409        assert_eq!(
2410            auth.head(),
2411            branch_remove.hash(),
2412            "active head = RemoveKey branch"
2413        );
2414    }
2415
2416    /// End-to-end: replay a chain to an `Authority`, then verify it authorizes a node key signed by a
2417    /// trusted key — proving the replayed state drives `node_key_authorized` identically to
2418    /// `from_state`. A key removed by the chain no longer authorizes.
2419    #[test]
2420    fn replayed_authority_authorizes_node_end_to_end() {
2421        use ed25519_dalek::{Signer, SigningKey};
2422
2423        let signing = SigningKey::from_bytes(&[77u8; 32]);
2424        let trusted_pub = signing.verifying_key().to_bytes().to_vec();
2425        let trusted = AumKey {
2426            kind: KeyKind::Ed25519,
2427            votes: 1,
2428            public: trusted_pub.clone(),
2429            meta: Vec::new(),
2430        };
2431        // A second key we'll add then remove, to show a removed key can't authorize.
2432        let revoked_signing = SigningKey::from_bytes(&[88u8; 32]);
2433        let revoked_pub = revoked_signing.verifying_key().to_bytes().to_vec();
2434        let revoked = AumKey {
2435            kind: KeyKind::Ed25519,
2436            votes: 1,
2437            public: revoked_pub.clone(),
2438            meta: Vec::new(),
2439        };
2440
2441        let a0 = genesis_add(trusted);
2442        let a1 = child(&a0, AumKind::AddKey, Some(revoked), Vec::new());
2443        let a2 = child(&a1, AumKind::RemoveKey, None, revoked_pub.clone());
2444        let auth = Authority::from_chain(&[a0, a1, a2]).unwrap();
2445
2446        let node_key = alloc::vec![7u8; 32];
2447        // Signature from the still-trusted key authorizes.
2448        let mut sig = NodeKeySignature {
2449            sig_kind: SigKind::Direct,
2450            pubkey: node_key.clone(),
2451            key_id: trusted_pub.clone(),
2452            signature: Vec::new(),
2453            nested: None,
2454            wrapping_pubkey: Vec::new(),
2455        };
2456        sig.signature = signing.sign(&sig.sig_hash()).to_bytes().to_vec();
2457        assert!(
2458            auth.node_key_authorized(&node_key, &sig.to_cbor(true).to_vec())
2459                .is_ok(),
2460            "the replayed authority must authorize a node signed by a still-trusted key"
2461        );
2462
2463        // The same node key signed by the REVOKED key must be rejected (key no longer in state).
2464        let mut bad = NodeKeySignature {
2465            sig_kind: SigKind::Direct,
2466            pubkey: node_key.clone(),
2467            key_id: revoked_pub.clone(),
2468            signature: Vec::new(),
2469            nested: None,
2470            wrapping_pubkey: Vec::new(),
2471        };
2472        bad.signature = revoked_signing.sign(&bad.sig_hash()).to_bytes().to_vec();
2473        assert_eq!(
2474            auth.node_key_authorized(&node_key, &bad.to_cbor(true).to_vec())
2475                .unwrap_err(),
2476            TkaError::UntrustedKey,
2477            "a key the chain removed must not authorize"
2478        );
2479    }
2480
2481    /// Genesis-kind guard (Go `computeStateAt` "invalid genesis update"): a chain whose first AUM is
2482    /// a `RemoveKey`/`UpdateKey` is rejected. (A genesis `NoOp`/`AddKey`/`Checkpoint` is allowed.)
2483    #[test]
2484    fn replay_rejects_invalid_genesis_kind() {
2485        // A bare RemoveKey as genesis: no key to remove → today this is BadKeyState, but the genesis
2486        // guard catches an UpdateKey before the key lookup. Use UpdateKey to exercise the guard arm.
2487        let mut g = genesis_add(test_aum_key(1, 1));
2488        g.message_kind = AumKind::UpdateKey;
2489        g.key = None;
2490        g.key_id = test_aum_key(1, 1).public.clone();
2491        assert_eq!(
2492            Authority::from_chain(&[g]).unwrap_err(),
2493            TkaError::BadChain,
2494            "an UpdateKey cannot be a genesis AUM"
2495        );
2496    }
2497
2498    /// Genesis must carry no parent: a first AUM with a non-None `prev_aum_hash` (i.e. a chain
2499    /// *suffix* mis-supplied as a whole chain) is rejected as `BadParent`, not silently re-rooted.
2500    #[test]
2501    fn replay_rejects_genesis_with_parent() {
2502        let mut g = genesis_add(test_aum_key(1, 1));
2503        g.prev_aum_hash = Some(AumHash([0x11; 32])); // names a parent not in the slice
2504        assert_eq!(
2505            Authority::from_chain(&[g]).unwrap_err(),
2506            TkaError::BadParent,
2507            "a genesis AUM that names a parent must be rejected (not treated as genesis)"
2508        );
2509    }
2510
2511    /// Checkpoint StateID guard (Go "checkpointed state has an incorrect stateID"): a genesis
2512    /// checkpoint seeds the StateID; a later checkpoint with a different StateID is rejected.
2513    #[test]
2514    fn replay_rejects_checkpoint_stateid_mismatch() {
2515        let k = test_aum_key(1, 1);
2516        // Genesis checkpoint seeds StateID (7, 0).
2517        let genesis = Aum {
2518            message_kind: AumKind::Checkpoint,
2519            prev_aum_hash: None,
2520            key: None,
2521            key_id: Vec::new(),
2522            state: Some(AumState {
2523                last_aum_hash: None,
2524                disablement_values: Some(Vec::new()),
2525                keys: Some(alloc::vec![k.clone()]),
2526                state_id1: 7,
2527                state_id2: 0,
2528            }),
2529            votes: None,
2530            meta: Vec::new(),
2531            signatures: Vec::new(),
2532        };
2533        // A second checkpoint, correctly chained, but with a FOREIGN StateID (8, 0).
2534        let bad = Aum {
2535            message_kind: AumKind::Checkpoint,
2536            prev_aum_hash: Some(genesis.hash()),
2537            key: None,
2538            key_id: Vec::new(),
2539            state: Some(AumState {
2540                last_aum_hash: Some(genesis.hash()),
2541                disablement_values: Some(Vec::new()),
2542                keys: Some(alloc::vec![k.clone()]),
2543                state_id1: 8, // ← mismatch
2544                state_id2: 0,
2545            }),
2546            votes: None,
2547            meta: Vec::new(),
2548            signatures: Vec::new(),
2549        };
2550        assert_eq!(
2551            Authority::from_chain(&[genesis.clone(), bad]).unwrap_err(),
2552            TkaError::BadKeyState,
2553            "a checkpoint with a foreign StateID belongs to another authority and must be rejected"
2554        );
2555        // A matching-StateID second checkpoint is accepted.
2556        let ok = Aum {
2557            message_kind: AumKind::Checkpoint,
2558            prev_aum_hash: Some(genesis.hash()),
2559            key: None,
2560            key_id: Vec::new(),
2561            state: Some(AumState {
2562                last_aum_hash: Some(genesis.hash()),
2563                disablement_values: Some(Vec::new()),
2564                keys: Some(alloc::vec![k]),
2565                state_id1: 7,
2566                state_id2: 0,
2567            }),
2568            votes: None,
2569            meta: Vec::new(),
2570            signatures: Vec::new(),
2571        };
2572        assert!(Authority::from_chain(&[genesis, ok]).is_ok());
2573    }
2574
2575    /// `from_forked_chain` rejects a multi-step branch rather than mis-resolving it (Go re-runs
2576    /// pickNextAUM per link; judging a whole branch by its first AUM could diverge).
2577    #[test]
2578    fn forked_chain_rejects_multistep_branch() {
2579        let k0 = test_aum_key(1, 1);
2580        let a0 = genesis_add(k0.clone());
2581        let b1 = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
2582        // A two-AUM branch (b1 → b2): must be rejected as BadChain.
2583        let b2 = child(&b1, AumKind::NoOp, None, alloc::vec![2]);
2584        let single = [child(&a0, AumKind::NoOp, None, alloc::vec![3])];
2585        let multi = [b1, b2];
2586        assert_eq!(
2587            Authority::from_forked_chain(&[a0], &[&single[..], &multi[..]]).unwrap_err(),
2588            TkaError::BadChain,
2589            "a multi-step branch must be rejected, not judged by its first AUM"
2590        );
2591    }
2592
2593    /// Cross-implementation Known-Answer-Test for the **AUM** type: [`Aum::serialize`],
2594    /// [`Aum::hash`] (Go `AUM.Hash`), and [`Aum::sig_hash`] (Go `AUM.SigHash`) must byte-match the
2595    /// REAL `tailscale.com/tka` package, version **v1.100.0** (toolchain **go1.26.3+**).
2596    ///
2597    /// Provenance: every golden below is authoritative upstream output produced by the Go generator
2598    /// at `tests/vectors/gen/tka/main.go` (which imports the real `tailscale.com/tka`, builds one
2599    /// `tka.AUM` per `MessageKind`, and dumps `AUM.Serialize()`/`AUM.Hash()`/`AUM.SigHash()` hex).
2600    /// The same values are committed for provenance at `tests/vectors/tka_aum_hash_golden.json`.
2601    /// This is the missing half of axis-B for AUM: the sibling
2602    /// [`aum_serialize_matches_go_test_serialization_vectors`] test pins Go's *Serialize()* literals
2603    /// from `tka/aum_test.go`, but no Go-produced *AUM.Hash()* digest was pinned until now — so an
2604    /// error in the BLAKE2s-over-canonical-CBOR digest (the value that links the whole chain and is
2605    /// signed) would have gone undetected. Here `hash`/`sig_hash` are pinned to Go directly.
2606    ///
2607    /// Covered kinds: AddKey (genesis, with a real Key25519 + meta), RemoveKey, UpdateKey
2608    /// (votes+meta), a signed AddKey (Signatures at CBOR key 23), and a Checkpoint with a populated
2609    /// `State`. The signed AUM additionally proves `hash() != sig_hash()` — i.e. `Hash()` covers the
2610    /// signatures and `SigHash()` excludes them, exactly as Go's `AUM.SigHash` nils `Signatures`
2611    /// before serializing.
2612    #[test]
2613    fn aum_hash_sighash_matches_go_golden() {
2614        // Deterministic field material — identical to the Go generator's inputs.
2615        let prev = AumHash({
2616            let mut a = [0u8; AUM_HASH_LEN];
2617            let mut i = 0;
2618            while i < AUM_HASH_LEN {
2619                a[i] = 0x20u8.wrapping_add(i as u8);
2620                i += 1;
2621            }
2622            a
2623        });
2624        let key_pub: Vec<u8> = (0..32u16).map(|i| 0x40u8.wrapping_add(i as u8)).collect();
2625        let key_pub2: Vec<u8> = (0..32u16).map(|i| 0x60u8.wrapping_add(i as u8)).collect();
2626        let sig_bytes: Vec<u8> = (0..64u16).map(|i| 0x80u8.wrapping_add(i as u8)).collect();
2627
2628        // Assert one AUM's serialize/hash/sig_hash against the authoritative Go hex.
2629        let check = |label: &str, aum: &Aum, ser_hex: &str, hash_hex: &str, sig_hash_hex: &str| {
2630            assert_eq!(
2631                hex(&aum.serialize()),
2632                ser_hex,
2633                "{label}: Aum::serialize diverged from Go tka v1.100.0"
2634            );
2635            assert_eq!(
2636                hex(&aum.hash().0),
2637                hash_hex,
2638                "{label}: Aum::hash (Go AUM.Hash) diverged from Go tka v1.100.0"
2639            );
2640            assert_eq!(
2641                hex(&aum.sig_hash()),
2642                sig_hash_hex,
2643                "{label}: Aum::sig_hash (Go AUM.SigHash) diverged from Go tka v1.100.0"
2644            );
2645        };
2646
2647        // (a) AddKey genesis (nil prev) with a real Key25519 + meta {"name":"alpha"}.
2648        let add_key = Aum {
2649            message_kind: AumKind::AddKey,
2650            prev_aum_hash: None,
2651            key: Some(AumKey {
2652                kind: KeyKind::Ed25519,
2653                votes: 7,
2654                public: key_pub.clone(),
2655                meta: alloc::vec![("name".into(), "alpha".into())],
2656            }),
2657            key_id: Vec::new(),
2658            state: None,
2659            votes: None,
2660            meta: Vec::new(),
2661            signatures: Vec::new(),
2662        };
2663        check(
2664            "AddKey",
2665            &add_key,
2666            "a3010102f603a401010207035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f0ca1646e616d6565616c706861",
2667            "921ca301077ae2b892ca8c40b3315e5f2a9ccc9ac99eec784e93b323577e1e14",
2668            "921ca301077ae2b892ca8c40b3315e5f2a9ccc9ac99eec784e93b323577e1e14",
2669        );
2670
2671        // (b) RemoveKey with a non-nil prev.
2672        let remove_key = Aum {
2673            message_kind: AumKind::RemoveKey,
2674            prev_aum_hash: Some(prev),
2675            key: None,
2676            key_id: key_pub.clone(),
2677            state: None,
2678            votes: None,
2679            meta: Vec::new(),
2680            signatures: Vec::new(),
2681        };
2682        check(
2683            "RemoveKey",
2684            &remove_key,
2685            "a30102025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f045820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
2686            "46be17c398760d2e649147b06a68e8576ee37c59cf0558046182f48ba20aa912",
2687            "46be17c398760d2e649147b06a68e8576ee37c59cf0558046182f48ba20aa912",
2688        );
2689
2690        // (c) UpdateKey with votes=2 + meta {"role":"ci"}.
2691        let update_key = Aum {
2692            message_kind: AumKind::UpdateKey,
2693            prev_aum_hash: Some(prev),
2694            key: None,
2695            key_id: key_pub.clone(),
2696            state: None,
2697            votes: Some(2),
2698            meta: alloc::vec![("role".into(), "ci".into())],
2699            signatures: Vec::new(),
2700        };
2701        check(
2702            "UpdateKey",
2703            &update_key,
2704            "a50104025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f045820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f060207a164726f6c65626369",
2705            "42d160d81a0922511b4ce60050dba76569f79423b6e721c5040faf414c250e43",
2706            "42d160d81a0922511b4ce60050dba76569f79423b6e721c5040faf414c250e43",
2707        );
2708
2709        // (e) AddKey carrying one Signature (CBOR key 23). hash (incl sigs) MUST differ from
2710        // sig_hash (excl sigs) — the property the whole signing scheme depends on.
2711        let signed = Aum {
2712            message_kind: AumKind::AddKey,
2713            prev_aum_hash: Some(prev),
2714            key: Some(AumKey {
2715                kind: KeyKind::Ed25519,
2716                votes: 1,
2717                public: key_pub.clone(),
2718                meta: Vec::new(),
2719            }),
2720            key_id: Vec::new(),
2721            state: None,
2722            votes: None,
2723            meta: Vec::new(),
2724            signatures: alloc::vec![AumSignature {
2725                key_id: key_pub2.clone(),
2726                signature: sig_bytes.clone(),
2727            }],
2728        };
2729        check(
2730            "AddKey+Signature",
2731            &signed,
2732            "a40101025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f03a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f1781a2015820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f025840808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
2733            "e70332d9a03b205577204f1896bb8dcb7c8f8894cc87a5b5c4d5dabcdf6ef135",
2734            "0a7a0ecdf854ad99e8728a1de89ac23c1f08457132a537a3add9594749a7f536",
2735        );
2736        assert_ne!(
2737            hex(&signed.hash().0),
2738            hex(&signed.sig_hash()),
2739            "Hash() must cover Signatures while SigHash() excludes them (Go AUM.SigHash nils them)"
2740        );
2741
2742        // (f) Checkpoint carrying a State with a POPULATED DisablementValues [{0xaa,0xbb}] + 1 key.
2743        // This is the common real shape and matches Go byte-for-byte (the array encoding is correct).
2744        let checkpoint = Aum {
2745            message_kind: AumKind::Checkpoint,
2746            prev_aum_hash: Some(prev),
2747            key: None,
2748            key_id: Vec::new(),
2749            state: Some(AumState {
2750                last_aum_hash: Some(prev),
2751                disablement_values: Some(alloc::vec![alloc::vec![0xaa, 0xbb]]),
2752                keys: Some(alloc::vec![AumKey {
2753                    kind: KeyKind::Ed25519,
2754                    votes: 1,
2755                    public: key_pub.clone(),
2756                    meta: Vec::new(),
2757                }]),
2758                state_id1: 0,
2759                state_id2: 0,
2760            }),
2761            votes: None,
2762            meta: Vec::new(),
2763            signatures: Vec::new(),
2764        };
2765        check(
2766            "Checkpoint(populated DisablementValues)",
2767            &checkpoint,
2768            "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f028142aabb0381a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
2769            "38c35c51580dcb75212d79c07695c8ec8b399b59ba552ea93f080b126fdaa0ae",
2770            "38c35c51580dcb75212d79c07695c8ec8b399b59ba552ea93f080b126fdaa0ae",
2771        );
2772    }
2773
2774    /// Go-match golden for the nil-`DisablementValues` checkpoint — the case that was a recorded
2775    /// interop bug (Rust forced an empty array `0x80` where Go emits CBOR null `0xf6`) and is now
2776    /// FIXED by making `AumState.{disablement_values,keys}` `Option` (None = Go nil = `0xf6`).
2777    ///
2778    /// When an `AUMCheckpoint`'s embedded `State` has a **nil** `DisablementValues` (Go's zero value,
2779    /// the overwhelmingly common case), Go's `fxamacker/cbor` CTAP2 encoder emits the field as
2780    /// **CBOR null `0xf6`**; a populated slice encodes as an array (proven by the populated case in
2781    /// [`aum_hash_sighash_matches_go_golden`]). This test pins the Go bytes + Hash for the nil case
2782    /// and asserts the Rust output now byte-matches — guarding the fix against regression.
2783    #[test]
2784    fn aum_checkpoint_nil_disablement_matches_go() {
2785        let prev = AumHash({
2786            let mut a = [0u8; AUM_HASH_LEN];
2787            let mut i = 0;
2788            while i < AUM_HASH_LEN {
2789                a[i] = 0x20u8.wrapping_add(i as u8);
2790                i += 1;
2791            }
2792            a
2793        });
2794        let key_pub: Vec<u8> = (0..32u16).map(|i| 0x40u8.wrapping_add(i as u8)).collect();
2795        let key_pub2: Vec<u8> = (0..32u16).map(|i| 0x60u8.wrapping_add(i as u8)).collect();
2796
2797        // Checkpoint with a State whose DisablementValues is EMPTY (== Go nil zero value), 2 keys.
2798        let checkpoint = Aum {
2799            message_kind: AumKind::Checkpoint,
2800            prev_aum_hash: Some(prev),
2801            key: None,
2802            key_id: Vec::new(),
2803            state: Some(AumState {
2804                last_aum_hash: Some(prev),
2805                disablement_values: Some(Vec::new()),
2806                keys: Some(alloc::vec![
2807                    AumKey {
2808                        kind: KeyKind::Ed25519,
2809                        votes: 1,
2810                        public: key_pub.clone(),
2811                        meta: Vec::new(),
2812                    },
2813                    AumKey {
2814                        kind: KeyKind::Ed25519,
2815                        votes: 3,
2816                        public: key_pub2.clone(),
2817                        meta: alloc::vec![("k".into(), "v".into())],
2818                    },
2819                ]),
2820                state_id1: 0,
2821                state_id2: 0,
2822            }),
2823            votes: None,
2824            meta: Vec::new(),
2825            signatures: Vec::new(),
2826        };
2827
2828        // Authoritative Go bytes (generator case "checkpoint: State w/ nil DisablementValues"):
2829        // the State map is `…02 f6 03 82 …` — a NIL DisablementValues encodes as CBOR null (0xf6).
2830        // FIXED: `AumState.disablement_values` is now `Option`, so the nil case (`None`) is
2831        // representable and encodes as null, byte-matching Go. (Was a recorded interop bug where the
2832        // `Vec` type forced an empty array `0x80` and diverged the checkpoint Hash from Go.)
2833        const GO_SERIALIZE: &str = "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f02f60382a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5fa401010203035820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f0ca1616b6176";
2834        const GO_HASH: &str = "cae17cc938c5a954cd4389d83c6afe4d3487edac38b94824bec3312b82f35710";
2835
2836        // Re-point the checkpoint's State to a genuinely-nil DisablementValues (`None`), which is the
2837        // case the Go golden above was generated from.
2838        let checkpoint = {
2839            let mut c = checkpoint;
2840            if let Some(state) = c.state.as_mut() {
2841                state.disablement_values = None;
2842            }
2843            c
2844        };
2845
2846        assert_eq!(
2847            hex(&checkpoint.serialize()),
2848            GO_SERIALIZE,
2849            "nil DisablementValues must encode as CBOR null (0xf6), byte-matching Go"
2850        );
2851        assert_eq!(
2852            hex(&checkpoint.hash().0),
2853            GO_HASH,
2854            "with the nil-vs-empty fix, the checkpoint chain-link Hash matches Go"
2855        );
2856    }
2857}