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