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}
200
201impl NodeKeySignature {
202    /// The canonical CBOR serialization of this signature with the `Signature` field nil'd, used as
203    /// the signing-digest preimage (Go `NodeKeySignature.SigHash` zeroes `Signature` then
204    /// serializes).
205    fn sig_hash(&self) -> [u8; AUM_HASH_LEN] {
206        let v = self.to_cbor(/* include_signature = */ false);
207        blake2s_256(&v.to_vec())
208    }
209
210    /// Build the CBOR value for this signature. When `include_signature` is false, the signature
211    /// field (key 4) is omitted (the SigHash preimage).
212    fn to_cbor(&self, include_signature: bool) -> Value {
213        cbor::int_map([
214            (1, Some(Value::Uint(self.sig_kind as u8 as u64))),
215            (2, nonempty_bytes(&self.pubkey)),
216            (3, nonempty_bytes(&self.key_id)),
217            (
218                4,
219                if include_signature {
220                    nonempty_bytes(&self.signature)
221                } else {
222                    None
223                },
224            ),
225            (5, self.nested.as_ref().map(|n| n.to_cbor(true))),
226            (6, nonempty_bytes(&self.wrapping_pubkey)),
227        ])
228    }
229
230    /// The key id that ultimately roots this signature in a trusted key (Go `authorizingKeyID`):
231    /// for a rotation, recurse into the nested signature; otherwise this signature's `key_id`.
232    fn authorizing_key_id(&self) -> Result<&[u8], TkaError> {
233        match self.sig_kind {
234            SigKind::Rotation => self
235                .nested
236                .as_ref()
237                .ok_or(TkaError::Decode("rotation signature missing nested"))?
238                .authorizing_key_id(),
239            SigKind::Direct | SigKind::Credential => Ok(&self.key_id),
240            SigKind::Invalid => Err(TkaError::Decode("invalid signature kind")),
241        }
242    }
243
244    /// Verify this signature authorizes `node_key`, rooted in the trusted `verification_key` (Go
245    /// `NodeKeySignature.verifySignature`).
246    fn verify_signature(&self, node_key: &[u8], verification_key: &Key) -> Result<(), TkaError> {
247        // For non-credential signatures the signed pubkey must equal the node key being authorized.
248        if self.sig_kind != SigKind::Credential && self.pubkey != node_key {
249            return Err(TkaError::NodeKeyMismatch);
250        }
251
252        let sig_hash = self.sig_hash();
253
254        match self.sig_kind {
255            SigKind::Rotation => {
256                let nested = self
257                    .nested
258                    .as_ref()
259                    .ok_or(TkaError::Decode("rotation signature missing nested"))?;
260                // The outer rotation signature is verified with STANDARD ed25519 against the nested
261                // signature's wrapping public key.
262                let verify_pub = &nested.wrapping_pubkey;
263                if verify_pub.len() != 32 {
264                    return Err(TkaError::Decode("wrapping pubkey wrong length"));
265                }
266                verify_ed25519_std(verify_pub, &sig_hash, &self.signature)?;
267                // The nested signature must cover the rotation pivot (`verify_pub`). For a nested
268                // Direct this is enforced inside its own `verify_signature` (the non-credential
269                // `pubkey != node_key` check). A nested Credential SKIPS that check, so bind it
270                // here: the credential must cover exactly the wrapping pubkey it is rotating, or an
271                // attacker could splice an unrelated valid credential into the chain.
272                if nested.sig_kind == SigKind::Credential && nested.pubkey != *verify_pub {
273                    return Err(TkaError::NodeKeyMismatch);
274                }
275                // Then the nested signature must itself be valid, rooting in the trusted key.
276                nested.verify_signature(verify_pub, verification_key)
277            }
278            SigKind::Direct | SigKind::Credential => {
279                if self.nested.is_some() {
280                    return Err(TkaError::Decode("direct/credential signature has nested"));
281                }
282                if verification_key.kind != KeyKind::Ed25519 || verification_key.public.len() != 32
283                {
284                    return Err(TkaError::Decode("verification key not ed25519"));
285                }
286                // Direct/credential signatures verify with ZIP-215 (cofactored) ed25519, matching
287                // Go's `ed25519consensus.Verify`.
288                verify_ed25519_zip215(&verification_key.public, &sig_hash, &self.signature)
289            }
290            SigKind::Invalid => Err(TkaError::Decode("invalid signature kind")),
291        }
292    }
293}
294
295/// The current authority state (Go `tka.State`): the set of trusted keys at a given chain head.
296/// This is the minimal slice a client needs for [`Authority::node_key_authorized`].
297#[derive(Debug, Clone, Default, PartialEq, Eq)]
298pub struct State {
299    /// The trusted keys.
300    pub keys: Vec<Key>,
301}
302
303impl State {
304    /// Find a trusted key by its id (Go `State.GetKey`).
305    pub fn get_key(&self, key_id: &[u8]) -> Option<&Key> {
306        self.keys.iter().find(|k| k.id() == key_id)
307    }
308}
309
310/// A tailnet-lock authority as a client tracks it: the current trusted-key [`State`] and the chain
311/// `head`. Built by replaying the AUM chain (or from a control-provided checkpoint); the client
312/// then uses [`Authority::node_key_authorized`] to decide whether a peer is trusted.
313#[derive(Debug, Clone)]
314pub struct Authority {
315    head: AumHash,
316    state: State,
317}
318
319impl Authority {
320    /// Construct an authority directly from a known `head` and trusted-key `state` (e.g. a
321    /// control-provided checkpoint the client already trusts).
322    pub fn from_state(head: AumHash, state: State) -> Authority {
323        Authority { head, state }
324    }
325
326    /// The current chain head hash (Go `Authority.Head`).
327    pub fn head(&self) -> AumHash {
328        self.head
329    }
330
331    /// The trusted-key state.
332    pub fn state(&self) -> &State {
333        &self.state
334    }
335
336    /// Whether `head` (e.g. decoded from `TkaInfo.head`) matches this authority's head. A client
337    /// that finds a mismatch must resync before trusting verifications.
338    pub fn head_matches(&self, head: &AumHash) -> bool {
339        &self.head == head
340    }
341
342    /// Verify that `node_key` is authorized under the current authority state by the given
343    /// node-key-signature CBOR blob (Go `Authority.NodeKeyAuthorized`).
344    ///
345    /// Fail-closed: a credential-only signature, an untrusted authorizing key, a malformed blob, or
346    /// a bad signature all return `Err`.
347    ///
348    /// # Errors
349    ///
350    /// Returns [`TkaError::Decode`] if `signature_cbor` is malformed,
351    /// [`TkaError::CredentialCannotAuthorize`] for a credential-only signature,
352    /// [`TkaError::UntrustedKey`] if the authorizing key is not in the current state,
353    /// [`TkaError::NodeKeyMismatch`] if the signature does not cover `node_key`, or
354    /// [`TkaError::BadSignature`] if cryptographic verification fails.
355    pub fn node_key_authorized(
356        &self,
357        node_key: &[u8],
358        signature_cbor: &[u8],
359    ) -> Result<(), TkaError> {
360        let sig = decode_node_key_signature(signature_cbor)?;
361        // A credential signature can never authorize a node on its own.
362        if sig.sig_kind == SigKind::Credential {
363            return Err(TkaError::CredentialCannotAuthorize);
364        }
365        let key_id = sig.authorizing_key_id()?;
366        let key = self.state.get_key(key_id).ok_or(TkaError::UntrustedKey)?;
367        sig.verify_signature(node_key, key)
368    }
369}
370
371/// Compute the [`AumHash`] of an AUM given its canonical CBOR serialization. Exposed so a chain
372/// replayer can link AUMs (`PrevAUMHash`) without re-deriving the hash function.
373pub fn aum_hash(canonical_cbor: &[u8]) -> AumHash {
374    AumHash(blake2s_256(canonical_cbor))
375}
376
377/// A trusted-key payload as carried *inside* an [`Aum`] (`AUMAddKey`/`AUMUpdateKey`) or a
378/// checkpoint [`AumState`] — Go `tka.Key`, full wire shape (the verify-path [`Key`] is a leaner
379/// slice that omits `meta`, which the node-key-signature path never needs).
380///
381/// CBOR keymap (Go `cbor:"…,keyasint"`): `kind`=1, `votes`=2, `public`=3, `meta`=**12** (omitempty).
382#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct AumKey {
384    /// Key algorithm (`Key25519` = 1).
385    pub kind: KeyKind,
386    /// Voting weight.
387    pub votes: u32,
388    /// Raw public key bytes (32 for Ed25519); the key id for `Key25519`.
389    pub public: Vec<u8>,
390    /// Optional metadata (Go `map[string]string`); omitted from CBOR when empty.
391    pub meta: Vec<(alloc::string::String, alloc::string::String)>,
392}
393
394impl AumKey {
395    /// The key id (for `Key25519`, the public key verbatim — Go `Key.ID`).
396    pub fn id(&self) -> &[u8] {
397        &self.public
398    }
399
400    fn kind_u8(&self) -> u8 {
401        match self.kind {
402            KeyKind::Ed25519 => 1,
403        }
404    }
405
406    fn to_cbor(&self) -> Value {
407        cbor::int_map([
408            (1, Some(Value::Uint(self.kind_u8() as u64))),
409            (2, Some(Value::Uint(self.votes as u64))),
410            (3, Some(Value::Bytes(self.public.clone()))),
411            (12, meta_to_cbor(&self.meta)),
412        ])
413    }
414}
415
416/// A full authority-state snapshot as carried in an `AUMCheckpoint` (Go `tka.State`).
417///
418/// CBOR keymap: `last_aum_hash`=1, `disablement_values`=2, `keys`=3, `state_id1`=4 (omitempty),
419/// `state_id2`=5 (omitempty). Keys 1/2/3 are **non-`omitempty`** (a nil `last_aum_hash` encodes as
420/// CBOR null, a nil `disablement_values`/`keys` as an empty array — matching Go's struct encoding).
421#[derive(Debug, Clone, PartialEq, Eq, Default)]
422pub struct AumState {
423    /// The hash of the AUM this state was produced by (Go `LastAUMHash`); `None` encodes as null.
424    pub last_aum_hash: Option<AumHash>,
425    /// Disablement secret hashes (Go `DisablementValues`).
426    pub disablement_values: Vec<Vec<u8>>,
427    /// The trusted keys at this state.
428    pub keys: Vec<AumKey>,
429    /// Optional state identifier, high half (Go `StateID1`); omitted from CBOR when 0.
430    pub state_id1: u64,
431    /// Optional state identifier, low half (Go `StateID2`); omitted from CBOR when 0.
432    pub state_id2: u64,
433}
434
435impl AumState {
436    fn to_cbor(&self) -> Value {
437        cbor::int_map([
438            (
439                1,
440                Some(match &self.last_aum_hash {
441                    Some(h) => Value::Bytes(h.0.to_vec()),
442                    None => Value::Null,
443                }),
444            ),
445            (
446                2,
447                Some(Value::Array(
448                    self.disablement_values
449                        .iter()
450                        .map(|d| Value::Bytes(d.clone()))
451                        .collect(),
452                )),
453            ),
454            (
455                3,
456                Some(Value::Array(
457                    self.keys.iter().map(AumKey::to_cbor).collect(),
458                )),
459            ),
460            (
461                4,
462                (self.state_id1 != 0).then_some(Value::Uint(self.state_id1)),
463            ),
464            (
465                5,
466                (self.state_id2 != 0).then_some(Value::Uint(self.state_id2)),
467            ),
468        ])
469    }
470}
471
472/// A signature attached to an [`Aum`] (Go `tkatype.Signature`): which trusted key signed, and the
473/// signature bytes. CBOR keymap: `key_id`=1, `signature`=2 (both non-`omitempty`).
474#[derive(Debug, Clone, PartialEq, Eq)]
475pub struct AumSignature {
476    /// The id of the trusted key that produced `signature`.
477    pub key_id: Vec<u8>,
478    /// The raw signature bytes.
479    pub signature: Vec<u8>,
480}
481
482impl AumSignature {
483    fn to_cbor(&self) -> Value {
484        // Both fields are non-`omitempty` in Go, so an empty/nil `[]byte` encodes as CBOR null
485        // (`0xf6`), not as an empty byte string (`0x40`) and not omitted — same rule as the AUM's
486        // genesis `prev_aum_hash`. (Go's `TestSerialization` Signature vector ends `02 f6`.)
487        cbor::int_map([
488            (1, Some(bytes_or_null(&self.key_id))),
489            (2, Some(bytes_or_null(&self.signature))),
490        ])
491    }
492}
493
494/// An Authority Update Message (Go `tka.AUM`): one link in the tailnet-lock chain. This is the
495/// acquisition-side type a client replays to derive the trusted-key [`State`] (the verify-only path
496/// in [`Authority`] doesn't need it). Serialization is byte-exact with Go's `fxamacker/cbor`
497/// (CTAP2) so [`Aum::hash`]/[`Aum::sig_hash`] match Go's `AUM.Hash`/`AUM.SigHash`.
498///
499/// CBOR keymap (Go `cbor:"…,keyasint"`): `message_kind`=1, `prev_aum_hash`=2 (both
500/// **non-`omitempty`**; a nil `prev` encodes as CBOR null, *not* omitted), `key`=3, `key_id`=4,
501/// `state`=5, `votes`=6, `meta`=7, `signatures`=**23** (all `omitempty`). Key 23 is the last key
502/// encodable in a single CBOR head byte, which is why Go put `Signatures` there.
503#[derive(Debug, Clone, PartialEq, Eq)]
504pub struct Aum {
505    /// The kind of update.
506    pub message_kind: AumKind,
507    /// The hash of the previous AUM in the chain (`None`/empty = genesis, encodes as CBOR null).
508    pub prev_aum_hash: Option<AumHash>,
509    /// `AUMAddKey`/`AUMUpdateKey`: the key being added.
510    pub key: Option<AumKey>,
511    /// `AUMRemoveKey`/`AUMUpdateKey`: the id of the key being removed/updated.
512    pub key_id: Vec<u8>,
513    /// `AUMCheckpoint`: the full state snapshot.
514    pub state: Option<AumState>,
515    /// `AUMUpdateKey`: the new vote weight (Go `*uint`; `None` = unchanged/omitted).
516    pub votes: Option<u32>,
517    /// `AUMUpdateKey`: new metadata.
518    pub meta: Vec<(alloc::string::String, alloc::string::String)>,
519    /// The signatures over this AUM's [`Aum::sig_hash`].
520    pub signatures: Vec<AumSignature>,
521}
522
523impl Aum {
524    fn message_kind_u8(&self) -> u8 {
525        self.message_kind as u8
526    }
527
528    /// Build the canonical CBOR value. When `include_signatures` is false, key 23 is omitted (the
529    /// [`Aum::sig_hash`] preimage — Go nils `Signatures`, which `omitempty`-drops it).
530    fn to_cbor(&self, include_signatures: bool) -> Value {
531        let signatures = if include_signatures && !self.signatures.is_empty() {
532            Some(Value::Array(
533                self.signatures.iter().map(AumSignature::to_cbor).collect(),
534            ))
535        } else {
536            None
537        };
538        cbor::int_map([
539            // key 1, NON-omitempty.
540            (1, Some(Value::Uint(self.message_kind_u8() as u64))),
541            // key 2, NON-omitempty: a nil prev hash is CBOR null, never omitted.
542            (
543                2,
544                Some(match &self.prev_aum_hash {
545                    Some(h) => Value::Bytes(h.0.to_vec()),
546                    None => Value::Null,
547                }),
548            ),
549            (3, self.key.as_ref().map(AumKey::to_cbor)),
550            (4, nonempty_bytes(&self.key_id)),
551            (5, self.state.as_ref().map(AumState::to_cbor)),
552            (6, self.votes.map(|v| Value::Uint(v as u64))),
553            (7, meta_to_cbor(&self.meta)),
554            (23, signatures),
555        ])
556    }
557
558    /// The canonical CBOR serialization including signatures (Go `AUM.Serialize`).
559    pub fn serialize(&self) -> Vec<u8> {
560        self.to_cbor(/* include_signatures = */ true).to_vec()
561    }
562
563    /// The chain-link hash: `BLAKE2s-256` of the full serialization (Go `AUM.Hash`).
564    pub fn hash(&self) -> AumHash {
565        AumHash(blake2s_256(&self.serialize()))
566    }
567
568    /// The signing digest: `BLAKE2s-256` of the serialization with `signatures` omitted (Go
569    /// `AUM.SigHash`).
570    pub fn sig_hash(&self) -> [u8; AUM_HASH_LEN] {
571        blake2s_256(&self.to_cbor(/* include_signatures = */ false).to_vec())
572    }
573}
574
575/// `Some(TextMap)` for a non-empty `map[string]string`, else `None` (the `omitempty` rule). Keys are
576/// UTF-8 text; CTAP2 canonical ordering is applied at encode time by [`cbor::Value::TextMap`].
577fn meta_to_cbor(meta: &[(alloc::string::String, alloc::string::String)]) -> Option<Value> {
578    if meta.is_empty() {
579        return None;
580    }
581    Some(Value::TextMap(
582        meta.iter()
583            .map(|(k, v)| (k.as_bytes().to_vec(), Value::Text(v.as_bytes().to_vec())))
584            .collect(),
585    ))
586}
587
588fn blake2s_256(data: &[u8]) -> [u8; AUM_HASH_LEN] {
589    let mut hasher = Blake2s256::new();
590    hasher.update(data);
591    let out = hasher.finalize();
592    let mut h = [0u8; AUM_HASH_LEN];
593    h.copy_from_slice(&out);
594    h
595}
596
597/// `Some(Bytes)` when `b` is non-empty, else `None` — the `omitempty` rule for byte fields.
598fn nonempty_bytes(b: &[u8]) -> Option<Value> {
599    if b.is_empty() {
600        None
601    } else {
602        Some(Value::Bytes(b.to_vec()))
603    }
604}
605
606/// A byte string, or CBOR null when empty — the encoding Go's `fxamacker/cbor` produces for a
607/// **non-`omitempty`** `[]byte` field that is nil/empty (e.g. `tkatype.Signature.{KeyID,Signature}`,
608/// or an AUM's genesis `PrevAUMHash`). Distinct from [`nonempty_bytes`], which *omits* the field.
609fn bytes_or_null(b: &[u8]) -> Value {
610    if b.is_empty() {
611        Value::Null
612    } else {
613        Value::Bytes(b.to_vec())
614    }
615}
616
617/// Verify a standard (RFC 8032, non-cofactored) Ed25519 signature.
618fn verify_ed25519_std(public: &[u8], msg: &[u8], sig: &[u8]) -> Result<(), TkaError> {
619    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
620    let pk: [u8; 32] = public
621        .try_into()
622        .map_err(|_| TkaError::Decode("bad pubkey len"))?;
623    let vk = VerifyingKey::from_bytes(&pk).map_err(|_| TkaError::Decode("bad ed25519 pubkey"))?;
624    let sig: [u8; 64] = sig
625        .try_into()
626        .map_err(|_| TkaError::Decode("bad sig len"))?;
627    vk.verify(msg, &Signature::from_bytes(&sig))
628        .map_err(|_| TkaError::BadSignature)
629}
630
631/// Verify a ZIP-215 (cofactored) Ed25519 signature, matching Go `ed25519consensus.Verify`.
632fn verify_ed25519_zip215(public: &[u8], msg: &[u8], sig: &[u8]) -> Result<(), TkaError> {
633    let pk: [u8; 32] = public
634        .try_into()
635        .map_err(|_| TkaError::Decode("bad pubkey len"))?;
636    let vk = ed25519_zebra::VerificationKey::try_from(pk)
637        .map_err(|_| TkaError::Decode("bad ed25519 pubkey"))?;
638    let sig_bytes: [u8; 64] = sig
639        .try_into()
640        .map_err(|_| TkaError::Decode("bad sig len"))?;
641    let sig = ed25519_zebra::Signature::from(sig_bytes);
642    vk.verify(&sig, msg).map_err(|_| TkaError::BadSignature)
643}
644
645/// Decode a [`NodeKeySignature`] from canonical CBOR. This is a minimal decoder for the exact map
646/// shape Go emits (integer keys 1..=6); anything else is rejected (fail-closed).
647fn decode_node_key_signature(buf: &[u8]) -> Result<NodeKeySignature, TkaError> {
648    let (val, rest) = decode_value(buf, 0)?;
649    if !rest.is_empty() {
650        return Err(TkaError::Decode("trailing bytes after signature"));
651    }
652    node_key_signature_from_value(val, 0)
653}
654
655fn node_key_signature_from_value(val: Value, depth: usize) -> Result<NodeKeySignature, TkaError> {
656    if depth > MAX_SIG_NESTING_DEPTH {
657        return Err(TkaError::Decode("nested signature too deep"));
658    }
659    let Value::IntMap(entries) = val else {
660        return Err(TkaError::Decode("signature is not an int-keyed map"));
661    };
662    let mut sig_kind = None;
663    let mut pubkey = Vec::new();
664    let mut key_id = Vec::new();
665    let mut signature = Vec::new();
666    let mut nested = None;
667    let mut wrapping_pubkey = Vec::new();
668
669    for (k, v) in entries {
670        match k {
671            1 => {
672                let Value::Uint(n) = v else {
673                    return Err(TkaError::Decode("sig kind not uint"));
674                };
675                sig_kind = Some(
676                    SigKind::from_u8(
677                        u8::try_from(n).map_err(|_| TkaError::Decode("sig kind range"))?,
678                    )
679                    .ok_or(TkaError::Decode("unknown sig kind"))?,
680                );
681            }
682            2 => pubkey = expect_bytes(v)?,
683            3 => key_id = expect_bytes(v)?,
684            4 => signature = expect_bytes(v)?,
685            5 => {
686                nested = Some(alloc::boxed::Box::new(node_key_signature_from_value(
687                    v,
688                    depth + 1,
689                )?))
690            }
691            6 => wrapping_pubkey = expect_bytes(v)?,
692            _ => return Err(TkaError::Decode("unknown signature field")),
693        }
694    }
695
696    Ok(NodeKeySignature {
697        sig_kind: sig_kind.ok_or(TkaError::Decode("signature missing kind"))?,
698        pubkey,
699        key_id,
700        signature,
701        nested,
702        wrapping_pubkey,
703    })
704}
705
706fn expect_bytes(v: Value) -> Result<Vec<u8>, TkaError> {
707    match v {
708        Value::Bytes(b) => Ok(b),
709        _ => Err(TkaError::Decode("expected byte string")),
710    }
711}
712
713/// Decode one CBOR value (the subset the encoder produces) from `buf`, returning the value and the
714/// remaining bytes. Minimal — only the major types TKA uses.
715fn decode_value(buf: &[u8], depth: usize) -> Result<(Value, &[u8]), TkaError> {
716    // Bound generic CBOR container nesting so a deeply-nested array/map (even a non-signature one)
717    // cannot overflow the recursive decoder before signature-shape validation runs.
718    if depth > MAX_SIG_NESTING_DEPTH {
719        return Err(TkaError::Decode("nested signature too deep"));
720    }
721    let (major, arg, rest) = decode_head(buf)?;
722    match major {
723        0 => Ok((Value::Uint(arg), rest)),
724        2 => {
725            let len = arg as usize;
726            if rest.len() < len {
727                return Err(TkaError::Decode("byte string truncated"));
728            }
729            Ok((Value::Bytes(rest[..len].to_vec()), &rest[len..]))
730        }
731        3 => {
732            let len = arg as usize;
733            if rest.len() < len {
734                return Err(TkaError::Decode("text string truncated"));
735            }
736            Ok((Value::Text(rest[..len].to_vec()), &rest[len..]))
737        }
738        4 => {
739            let mut items = Vec::new();
740            let mut cur = rest;
741            for _ in 0..arg {
742                let (v, next) = decode_value(cur, depth + 1)?;
743                items.push(v);
744                cur = next;
745            }
746            Ok((Value::Array(items), cur))
747        }
748        5 => {
749            let mut entries: Vec<(u64, Value)> = Vec::new();
750            let mut cur = rest;
751            for _ in 0..arg {
752                let (k, next) = decode_head(cur).and_then(|(m, a, r)| {
753                    if m == 0 {
754                        Ok((a, r))
755                    } else {
756                        Err(TkaError::Decode("map key not uint"))
757                    }
758                })?;
759                // CTAP2/Go reject duplicate map keys; do the same (fail-closed) rather than
760                // silently last-wins.
761                if entries.iter().any(|(existing, _)| *existing == k) {
762                    return Err(TkaError::Decode("duplicate map key"));
763                }
764                let (v, next2) = decode_value(next, depth + 1)?;
765                entries.push((k, v));
766                cur = next2;
767            }
768            Ok((Value::IntMap(entries), cur))
769        }
770        _ => Err(TkaError::Decode("unsupported CBOR major type")),
771    }
772}
773
774/// Decode a CBOR head: returns `(major, argument, rest)`.
775fn decode_head(buf: &[u8]) -> Result<(u8, u64, &[u8]), TkaError> {
776    let first = *buf.first().ok_or(TkaError::Decode("empty CBOR"))?;
777    let major = first >> 5;
778    let info = first & 0x1f;
779    let rest = &buf[1..];
780    let (arg, rest) = match info {
781        n @ 0..=23 => (n as u64, rest),
782        24 => {
783            let b = *rest.first().ok_or(TkaError::Decode("truncated u8"))?;
784            (b as u64, &rest[1..])
785        }
786        25 => {
787            if rest.len() < 2 {
788                return Err(TkaError::Decode("truncated u16"));
789            }
790            (u16::from_be_bytes([rest[0], rest[1]]) as u64, &rest[2..])
791        }
792        26 => {
793            if rest.len() < 4 {
794                return Err(TkaError::Decode("truncated u32"));
795            }
796            (
797                u32::from_be_bytes([rest[0], rest[1], rest[2], rest[3]]) as u64,
798                &rest[4..],
799            )
800        }
801        27 => {
802            if rest.len() < 8 {
803                return Err(TkaError::Decode("truncated u64"));
804            }
805            let mut b = [0u8; 8];
806            b.copy_from_slice(&rest[..8]);
807            (u64::from_be_bytes(b), &rest[8..])
808        }
809        _ => return Err(TkaError::Decode("indefinite/reserved CBOR length")),
810    };
811    Ok((major, arg, rest))
812}
813
814// ----- RFC 4648 base32 (standard alphabet, no padding) -----
815
816const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
817
818fn base32_encode_nopad(data: &[u8]) -> String {
819    let mut out = String::new();
820    let mut buffer: u32 = 0;
821    let mut bits: u32 = 0;
822    for &b in data {
823        buffer = (buffer << 8) | b as u32;
824        bits += 8;
825        while bits >= 5 {
826            bits -= 5;
827            let idx = ((buffer >> bits) & 0x1f) as usize;
828            out.push(BASE32_ALPHABET[idx] as char);
829        }
830    }
831    if bits > 0 {
832        let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
833        out.push(BASE32_ALPHABET[idx] as char);
834    }
835    out
836}
837
838fn base32_decode_nopad(text: &str) -> Option<Vec<u8>> {
839    let mut buffer: u32 = 0;
840    let mut bits: u32 = 0;
841    let mut out = Vec::new();
842    for c in text.chars() {
843        let val = match c {
844            'A'..='Z' => c as u32 - 'A' as u32,
845            '2'..='7' => c as u32 - '2' as u32 + 26,
846            _ => return None,
847        };
848        buffer = (buffer << 5) | val;
849        bits += 5;
850        if bits >= 8 {
851            bits -= 8;
852            out.push((buffer >> bits) as u8);
853        }
854    }
855    Some(out)
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861
862    #[test]
863    fn base32_roundtrip_32_bytes() {
864        let h = AumHash([0xABu8; 32]);
865        let text = h.to_base32();
866        let back = AumHash::from_base32(&text).unwrap();
867        assert_eq!(h, back);
868    }
869
870    #[test]
871    fn base32_rejects_wrong_length() {
872        // "AAAA" decodes to fewer than 32 bytes.
873        assert!(AumHash::from_base32("AAAA").is_none());
874        // Lowercase / invalid alphabet rejected.
875        assert!(AumHash::from_base32("aaaa").is_none());
876    }
877
878    #[test]
879    fn base32_matches_known_vector() {
880        // RFC 4648 base32 of "foobar" is "MZXW6YTBOI" (with padding "======"); no-pad drops the pad.
881        assert_eq!(base32_encode_nopad(b"foobar"), "MZXW6YTBOI");
882        assert_eq!(base32_decode_nopad("MZXW6YTBOI").unwrap(), b"foobar");
883    }
884
885    #[test]
886    fn credential_signature_cannot_authorize() {
887        let auth = Authority::from_state(AumHash([0; 32]), State::default());
888        let sig = NodeKeySignature {
889            sig_kind: SigKind::Credential,
890            pubkey: alloc::vec![1, 2, 3],
891            key_id: alloc::vec![4, 5, 6],
892            signature: alloc::vec![0; 64],
893            nested: None,
894            wrapping_pubkey: Vec::new(),
895        };
896        let cbor = sig.to_cbor(true).to_vec();
897        let err = auth.node_key_authorized(&[1, 2, 3], &cbor).unwrap_err();
898        assert_eq!(err, TkaError::CredentialCannotAuthorize);
899    }
900
901    #[test]
902    fn untrusted_key_denied() {
903        // A direct signature whose key id is not in the (empty) trusted state.
904        let auth = Authority::from_state(AumHash([0; 32]), State::default());
905        let sig = NodeKeySignature {
906            sig_kind: SigKind::Direct,
907            pubkey: alloc::vec![9; 32],
908            key_id: alloc::vec![7; 32],
909            signature: alloc::vec![0; 64],
910            nested: None,
911            wrapping_pubkey: Vec::new(),
912        };
913        let cbor = sig.to_cbor(true).to_vec();
914        let err = auth.node_key_authorized(&[9; 32], &cbor).unwrap_err();
915        assert_eq!(err, TkaError::UntrustedKey);
916    }
917
918    #[test]
919    fn direct_signature_verifies_end_to_end() {
920        use ed25519_dalek::{Signer, SigningKey};
921
922        // A trusted Ed25519 key signs a node key directly.
923        let signing = SigningKey::from_bytes(&[42u8; 32]);
924        let trusted_pub = signing.verifying_key().to_bytes().to_vec();
925        let node_key = alloc::vec![7u8; 32];
926
927        let trusted = Key {
928            kind: KeyKind::Ed25519,
929            votes: 1,
930            public: trusted_pub.clone(),
931        };
932        let auth = Authority::from_state(
933            AumHash([0; 32]),
934            State {
935                keys: alloc::vec![trusted],
936            },
937        );
938
939        // Build the signature, compute its sig-hash preimage, sign, then fill in the signature.
940        let mut sig = NodeKeySignature {
941            sig_kind: SigKind::Direct,
942            pubkey: node_key.clone(),
943            key_id: trusted_pub.clone(),
944            signature: Vec::new(),
945            nested: None,
946            wrapping_pubkey: Vec::new(),
947        };
948        let sig_hash = sig.sig_hash();
949        // NOTE: Go verifies Direct with ZIP-215; a standard ed25519-dalek signature is accepted by
950        // ZIP-215 verification (ZIP-215 is a superset), so signing with dalek here is valid.
951        sig.signature = signing.sign(&sig_hash).to_bytes().to_vec();
952
953        let cbor = sig.to_cbor(true).to_vec();
954        assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
955
956        // A different node key must NOT be authorized by this signature.
957        let other = alloc::vec![8u8; 32];
958        assert_eq!(
959            auth.node_key_authorized(&other, &cbor).unwrap_err(),
960            TkaError::NodeKeyMismatch
961        );
962    }
963
964    #[test]
965    fn tampered_signature_denied() {
966        use ed25519_dalek::{Signer, SigningKey};
967
968        let signing = SigningKey::from_bytes(&[42u8; 32]);
969        let trusted_pub = signing.verifying_key().to_bytes().to_vec();
970        let node_key = alloc::vec![7u8; 32];
971        let auth = Authority::from_state(
972            AumHash([0; 32]),
973            State {
974                keys: alloc::vec![Key {
975                    kind: KeyKind::Ed25519,
976                    votes: 1,
977                    public: trusted_pub.clone(),
978                }],
979            },
980        );
981        let mut sig = NodeKeySignature {
982            sig_kind: SigKind::Direct,
983            pubkey: node_key.clone(),
984            key_id: trusted_pub,
985            signature: Vec::new(),
986            nested: None,
987            wrapping_pubkey: Vec::new(),
988        };
989        let sig_hash = sig.sig_hash();
990        let mut sigbytes = signing.sign(&sig_hash).to_bytes();
991        sigbytes[0] ^= 0xff; // tamper
992        sig.signature = sigbytes.to_vec();
993
994        let cbor = sig.to_cbor(true).to_vec();
995        assert_eq!(
996            auth.node_key_authorized(&node_key, &cbor).unwrap_err(),
997            TkaError::BadSignature
998        );
999    }
1000
1001    #[test]
1002    fn head_matches_check() {
1003        let h = AumHash([5u8; 32]);
1004        let auth = Authority::from_state(h, State::default());
1005        assert!(auth.head_matches(&h));
1006        assert!(!auth.head_matches(&AumHash([6u8; 32])));
1007    }
1008
1009    // ----- Fix 1: depth cap on attacker-controlled nesting -----
1010
1011    #[test]
1012    fn deeply_nested_signature_rejected_without_overflow() {
1013        // Wrap a NodeKeySignature inside `nested` far past MAX_SIG_NESTING_DEPTH. This is cheap to
1014        // construct (a few bytes per level) and would overflow an unbounded recursive decoder. The
1015        // decoder must reject it as a Decode error — never panic / stack-overflow.
1016        let mut sig = NodeKeySignature {
1017            sig_kind: SigKind::Direct,
1018            pubkey: alloc::vec![1u8; 32],
1019            key_id: alloc::vec![2u8; 32],
1020            signature: alloc::vec![3u8; 64],
1021            nested: None,
1022            wrapping_pubkey: Vec::new(),
1023        };
1024        for _ in 0..(MAX_SIG_NESTING_DEPTH + 8) {
1025            sig = NodeKeySignature {
1026                sig_kind: SigKind::Rotation,
1027                pubkey: alloc::vec![1u8; 32],
1028                key_id: Vec::new(),
1029                signature: alloc::vec![3u8; 64],
1030                nested: Some(alloc::boxed::Box::new(sig)),
1031                wrapping_pubkey: alloc::vec![1u8; 32],
1032            };
1033        }
1034        let cbor = sig.to_cbor(true).to_vec();
1035        let err = decode_node_key_signature(&cbor).unwrap_err();
1036        assert_eq!(err, TkaError::Decode("nested signature too deep"));
1037    }
1038
1039    // ----- Fix 5: duplicate CBOR map keys rejected -----
1040
1041    #[test]
1042    fn duplicate_map_key_rejected() {
1043        // Hand-craft a CBOR map with key 1 repeated: map(2) { 1:0, 1:1 } => 0xa2 01 00 01 01.
1044        let blob = [0xa2u8, 0x01, 0x00, 0x01, 0x01];
1045        let err = decode_node_key_signature(&blob).unwrap_err();
1046        assert_eq!(err, TkaError::Decode("duplicate map key"));
1047    }
1048
1049    // ----- Fix 3: rotation-chain happy path + ZIP-215/std split -----
1050
1051    // ZIP-215 vs standard ed25519 in TKA, and why this crate carries BOTH verifiers:
1052    //
1053    //   * Direct/Credential signatures are verified with `verify_ed25519_zip215` (ed25519-zebra),
1054    //     matching Go `ed25519consensus.Verify` — the *cofactored* ZIP-215 rule the TKA leaf
1055    //     signatures are produced under. ZIP-215 is a strict superset of RFC 8032: any standard
1056    //     (dalek) signature is accepted by it, which is why the tests below can sign leaves with
1057    //     dalek and still verify under zebra.
1058    //   * The outer rotation WRAP signature is verified with `verify_ed25519_std` (ed25519-dalek),
1059    //     matching Go's plain `ed25519.Verify` for the rotation wrap. Collapsing these two
1060    //     verifiers into one would silently diverge from Go on the wire — hence both deps are kept
1061    //     (see Cargo.toml comment).
1062    #[test]
1063    fn rotation_chain_verifies_end_to_end() {
1064        use ed25519_dalek::{Signer, SigningKey};
1065
1066        // Trusted key signs the inner Direct over the wrapping (pivot) pubkey.
1067        let trusted = SigningKey::from_bytes(&[7u8; 32]);
1068        let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
1069
1070        // The rotation pivot: a fresh keypair whose public key the inner Direct authorizes and
1071        // whose private key signs the outer rotation wrap.
1072        let wrapping = SigningKey::from_bytes(&[9u8; 32]);
1073        let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
1074
1075        let node_key = alloc::vec![5u8; 32];
1076
1077        let auth = Authority::from_state(
1078            AumHash([0; 32]),
1079            State {
1080                keys: alloc::vec![Key {
1081                    kind: KeyKind::Ed25519,
1082                    votes: 1,
1083                    public: trusted_pub.clone(),
1084                }],
1085            },
1086        );
1087
1088        // Inner Direct: trusted key authorizes the wrapping pubkey. Verified ZIP-215, so a dalek
1089        // signature is accepted.
1090        let mut inner = NodeKeySignature {
1091            sig_kind: SigKind::Direct,
1092            pubkey: wrapping_pub.clone(),
1093            key_id: trusted_pub.clone(),
1094            signature: Vec::new(),
1095            nested: None,
1096            wrapping_pubkey: wrapping_pub.clone(),
1097        };
1098        let inner_hash = inner.sig_hash();
1099        inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
1100
1101        // Outer Rotation: signs the node key with the wrapping key (verified STANDARD ed25519).
1102        let mut outer = NodeKeySignature {
1103            sig_kind: SigKind::Rotation,
1104            pubkey: node_key.clone(),
1105            key_id: Vec::new(),
1106            signature: Vec::new(),
1107            nested: Some(alloc::boxed::Box::new(inner)),
1108            wrapping_pubkey: Vec::new(),
1109        };
1110        let outer_hash = outer.sig_hash();
1111        outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
1112
1113        let cbor = outer.to_cbor(true).to_vec();
1114        assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
1115
1116        // A tampered rotation-wrap signature must be rejected by the STANDARD ed25519 verifier.
1117        let mut tampered = outer.clone();
1118        let mut sb = tampered.signature.clone();
1119        sb[0] ^= 0xff;
1120        tampered.signature = sb;
1121        let cbor_bad = tampered.to_cbor(true).to_vec();
1122        assert_eq!(
1123            auth.node_key_authorized(&node_key, &cbor_bad).unwrap_err(),
1124            TkaError::BadSignature
1125        );
1126    }
1127
1128    // ----- Fix 2: nested-Credential pubkey must bind to the rotation pivot -----
1129
1130    #[test]
1131    fn rotation_nested_credential_pubkey_bind() {
1132        use ed25519_dalek::{Signer, SigningKey};
1133
1134        let trusted = SigningKey::from_bytes(&[11u8; 32]);
1135        let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
1136        let wrapping = SigningKey::from_bytes(&[13u8; 32]);
1137        let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
1138        let node_key = alloc::vec![6u8; 32];
1139
1140        let auth = Authority::from_state(
1141            AumHash([0; 32]),
1142            State {
1143                keys: alloc::vec![Key {
1144                    kind: KeyKind::Ed25519,
1145                    votes: 1,
1146                    public: trusted_pub.clone(),
1147                }],
1148            },
1149        );
1150
1151        // Helper: build a rotation wrapping a nested Credential whose `pubkey` is `cred_pubkey`.
1152        let build = |cred_pubkey: Vec<u8>| -> Vec<u8> {
1153            let mut inner = NodeKeySignature {
1154                sig_kind: SigKind::Credential,
1155                pubkey: cred_pubkey,
1156                key_id: trusted_pub.clone(),
1157                signature: Vec::new(),
1158                nested: None,
1159                wrapping_pubkey: wrapping_pub.clone(),
1160            };
1161            let inner_hash = inner.sig_hash();
1162            inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
1163
1164            let mut outer = NodeKeySignature {
1165                sig_kind: SigKind::Rotation,
1166                pubkey: node_key.clone(),
1167                key_id: Vec::new(),
1168                signature: Vec::new(),
1169                nested: Some(alloc::boxed::Box::new(inner)),
1170                wrapping_pubkey: Vec::new(),
1171            };
1172            let outer_hash = outer.sig_hash();
1173            outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
1174            outer.to_cbor(true).to_vec()
1175        };
1176
1177        // Matching: credential covers exactly the wrapping pivot pubkey -> accepted.
1178        let cbor_ok = build(wrapping_pub.clone());
1179        assert!(auth.node_key_authorized(&node_key, &cbor_ok).is_ok());
1180
1181        // Mismatch: credential covers an unrelated pubkey -> rejected (NodeKeyMismatch), even though
1182        // the credential is otherwise signed by the trusted key.
1183        let cbor_bad = build(alloc::vec![0xaau8; 32]);
1184        assert_eq!(
1185            auth.node_key_authorized(&node_key, &cbor_bad).unwrap_err(),
1186            TkaError::NodeKeyMismatch
1187        );
1188    }
1189
1190    // ----- CTAP2-CBOR byte-exactness FROZEN regression vector -----
1191
1192    /// A small hex helper for embedding captured bytes in a failure message.
1193    fn hex(bytes: &[u8]) -> String {
1194        let mut s = String::new();
1195        for b in bytes {
1196            s.push_str(&alloc::format!("{b:02x}"));
1197        }
1198        s
1199    }
1200
1201    /// FROZEN CTAP2-CBOR byte-exactness vector for the wire/signing serialization.
1202    ///
1203    /// The crate docs (and the `cbor` module) state the CTAP2-canonical CBOR encoding is asserted by
1204    /// construction but NOT cross-validated against Go's `fxamacker/cbor` (CTAP2 mode) in this fork.
1205    /// The existing TKA tests build a signature, sign it, and verify round-trip — so they would all
1206    /// still pass if the canonical encoding silently changed (int-map key ordering, smallest-int
1207    /// rule, omitempty), because both sides of the round-trip use the same encoder. That class of
1208    /// change would, however, break wire-compat with a live Go TKA.
1209    ///
1210    /// This pins the EXACT bytes for a fixed `NodeKeySignature` (Direct, deterministic key material):
1211    /// the full `to_cbor(true)` serialization, the `to_cbor(false)` SigHash preimage, the resulting
1212    /// `sig_hash` (BLAKE2s-256 of the preimage), and the `aum_hash` over the full serialization. ANY
1213    /// accidental change to canonical-CBOR encoding or the BLAKE2s digest breaks this test.
1214    ///
1215    /// NOTE: this is a regression-FREEZE vector captured from the current encoder, NOT a Go-sourced
1216    /// cross-vector. It should be replaced with a real `fxamacker/cbor` CTAP2 vector (the same
1217    /// `NodeKeySignature` encoded by a live Go `tka`) once one can be captured.
1218    #[test]
1219    fn node_key_signature_cbor_frozen_vector() {
1220        // Deterministic, fixed key material — NOT random. byte i = i, so the bytes are obvious.
1221        let pubkey: Vec<u8> = (0u8..32).collect();
1222        let key_id: Vec<u8> = (32u8..64).collect();
1223        let signature: Vec<u8> = (64u8..128).collect();
1224
1225        let sig = NodeKeySignature {
1226            sig_kind: SigKind::Direct,
1227            pubkey,
1228            key_id,
1229            signature,
1230            nested: None,
1231            wrapping_pubkey: Vec::new(), // empty -> omitted (omitempty), key 6 must NOT appear
1232        };
1233
1234        // 1. Full serialization (include_signature = true): keys 1,2,3,4 present, 5/6 omitted.
1235        let full = sig.to_cbor(true).to_vec();
1236        const EXPECTED_FULL: &[u8] = &[
1237            0xa4, 0x01, 0x01, 0x02, 0x58, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
1238            0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
1239            0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x03, 0x58, 0x20, 0x20,
1240            0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e,
1241            0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
1242            0x3d, 0x3e, 0x3f, 0x04, 0x58, 0x40, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47,
1243            0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55,
1244            0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, 0x61, 0x62, 0x63,
1245            0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71,
1246            0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f,
1247        ];
1248        assert_eq!(
1249            full,
1250            EXPECTED_FULL,
1251            "full CBOR serialization changed (canonical-CBOR encoding drift). actual: {}",
1252            hex(&full)
1253        );
1254
1255        // 2. SigHash preimage (include_signature = false): key 4 (signature) omitted.
1256        let preimage = sig.to_cbor(false).to_vec();
1257        const EXPECTED_PREIMAGE: &[u8] = &[
1258            0xa3, 0x01, 0x01, 0x02, 0x58, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
1259            0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
1260            0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x03, 0x58, 0x20, 0x20,
1261            0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e,
1262            0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
1263            0x3d, 0x3e, 0x3f,
1264        ];
1265        assert_eq!(
1266            preimage,
1267            EXPECTED_PREIMAGE,
1268            "SigHash preimage CBOR changed. actual: {}",
1269            hex(&preimage)
1270        );
1271
1272        // 3. sig_hash = BLAKE2s-256(preimage) — pinned.
1273        let sig_hash = sig.sig_hash();
1274        const EXPECTED_SIG_HASH: [u8; AUM_HASH_LEN] = [
1275            0x22, 0x6f, 0x9c, 0xbc, 0x63, 0x73, 0x92, 0x75, 0x2e, 0x0e, 0xb1, 0x32, 0x9c, 0xc4,
1276            0x99, 0x07, 0x01, 0x4a, 0xb6, 0x4f, 0x8e, 0x5d, 0x82, 0x85, 0xc2, 0x91, 0x42, 0x62,
1277            0xf6, 0xa6, 0xa8, 0x33,
1278        ];
1279        assert_eq!(
1280            sig_hash,
1281            EXPECTED_SIG_HASH,
1282            "sig_hash (BLAKE2s-256 of preimage) changed. actual: {}",
1283            hex(&sig_hash)
1284        );
1285
1286        // 4. aum_hash over the full serialization — pinned (exercises the public `aum_hash` helper
1287        //    + BLAKE2s digest over a frozen input).
1288        let aum = aum_hash(&full);
1289        const EXPECTED_AUM_HASH: [u8; AUM_HASH_LEN] = [
1290            0xa4, 0x40, 0x71, 0xa3, 0x7a, 0xbf, 0x80, 0x92, 0xd6, 0xff, 0x23, 0x84, 0xb2, 0xb0,
1291            0xa3, 0x50, 0xc7, 0xcb, 0x48, 0x41, 0xed, 0x68, 0x99, 0x62, 0x41, 0x7c, 0xd4, 0x23,
1292            0x68, 0xdc, 0x72, 0x49,
1293        ];
1294        assert_eq!(
1295            aum.0,
1296            EXPECTED_AUM_HASH,
1297            "aum_hash over full serialization changed. actual: {}",
1298            hex(&aum.0)
1299        );
1300    }
1301
1302    // ----- ed25519-speccheck KAT: dual-verifier (dalek std vs zebra ZIP-215) -----
1303
1304    /// Decode an ASCII hex string to bytes. Panics on malformed input (test-only).
1305    fn unhex(s: &str) -> Vec<u8> {
1306        assert!(s.len().is_multiple_of(2), "odd hex length");
1307        let nib = |c: u8| -> u8 {
1308            match c {
1309                b'0'..=b'9' => c - b'0',
1310                b'a'..=b'f' => c - b'a' + 10,
1311                b'A'..=b'F' => c - b'A' + 10,
1312                _ => panic!("bad hex nibble"),
1313            }
1314        };
1315        let b = s.as_bytes();
1316        let mut out = Vec::with_capacity(s.len() / 2);
1317        let mut i = 0;
1318        while i < b.len() {
1319            out.push((nib(b[i]) << 4) | nib(b[i + 1]));
1320            i += 2;
1321        }
1322        out
1323    }
1324
1325    /// The 12 adversarial Ed25519 vectors from `novifinancial/ed25519-speccheck`.
1326    ///
1327    /// Provenance: `cases.json` at commit `65519336fda78a3d016e947df6d82848aca0c9da`
1328    /// (<https://github.com/novifinancial/ed25519-speccheck/blob/main/cases.json>), the canonical
1329    /// generated vectors backing the "Taming the many EdDSAs" paper (IACR 2020/1244, Table 6c).
1330    /// The hex below is copied byte-for-byte from that file; the `message` field is itself hex
1331    /// (the speccheck driver hex-decodes it before verifying), so we decode it the same way.
1332    ///
1333    /// Tuple layout: `(message_hex, pubkey_hex, signature_hex)`.
1334    const SPECCHECK_VECTORS: [(&str, &str, &str); 12] = [
1335        // 0: S = 0; both A and R small-order.
1336        (
1337            "8c93255d71dcab10e8f379c26200f3c7bd5f09d9bc3068d3ef4edeb4853022b6",
1338            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
1339            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a0000000000000000000000000000000000000000000000000000000000000000",
1340        ),
1341        // 1: 0 < S < L; small A only.
1342        (
1343            "9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79",
1344            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
1345            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43a5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
1346        ),
1347        // 2: 0 < S < L; small R only.
1348        (
1349            "aebf3f2601a0c8c5d39cc7d8911642f740b78168218da8471772b35f9d35b9ab",
1350            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
1351            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa8c4bd45aecaca5b24fb97bc10ac27ac8751a7dfe1baff8b953ec9f5833ca260e",
1352        ),
1353        // 3: A and R mixed-order; passes both (unless full-order checked).
1354        (
1355            "9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79",
1356            "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
1357            "9046a64750444938de19f227bb80485e92b83fdb4b6506c160484c016cc1852f87909e14428a7a1d62e9f22f3d3ad7802db02eb2e688b6c52fcd6648a98bd009",
1358        ),
1359        // 4: A and R mixed; passes cofactored, FAILS cofactorless — the cofactored discriminator.
1360        (
1361            "e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c",
1362            "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
1363            "160a1cb0dc9c0258cd0a7d23e94d8fa878bcb1925f2c64246b2dee1796bed5125ec6bc982a269b723e0668e540911a9a6a58921d6925e434ab10aa7940551a09",
1364        ),
1365        // 5: A mixed, R order L; "fails cofactored iff (8h) prereduced".
1366        (
1367            "e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c",
1368            "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
1369            "21122a84e0b5fca4052f5b1235c80a537878b38f3142356b2c2384ebad4668b7e40bc836dac0f71076f9abe3a53f9c03c1ceeeddb658d0030494ace586687405",
1370        ),
1371        // 6: S > L (out of bounds) — malleability vector; std verifier MUST reject.
1372        (
1373            "85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40",
1374            "442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623",
1375            "e96f66be976d82e60150baecff9906684aebb1ef181f67a7189ac78ea23b6c0e547f7690a0e2ddcd04d87dbc3490dc19b3b3052f7ff0538cb68afb369ba3a514",
1376        ),
1377        // 7: S >> L (no canonical serialization with null high bit) — std verifier MUST reject.
1378        (
1379            "85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40",
1380            "442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623",
1381            "8ce5b96c8f26d0ab6c47958c9e68b937104cd36e13c33566acd2fe8d38aa19427e71f98a473474f2f13f06f97c20d58cc3f54b8bd0d272f42b695dd7e89a8c22",
1382        ),
1383        // 8: 0 < S < L; non-canonical R, reduced for hash.
1384        (
1385            "9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41",
1386            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
1387            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff03be9678ac102edcd92b0210bb34d7428d12ffc5df5f37e359941266a4e35f0f",
1388        ),
1389        // 9: 0 < S < L; non-canonical R, NOT reduced for hash.
1390        (
1391            "9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41",
1392            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
1393            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffca8c5b64cd208982aa38d4936621a4775aa233aa0505711d8fdcfdaa943d4908",
1394        ),
1395        // 10: 0 < S < L; non-canonical A, reduced for hash.
1396        (
1397            "e96b7021eb39c1a163b6da4e3093dcd3f21387da4cc4572be588fafae23c155b",
1398            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
1399            "a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
1400        ),
1401        // 11: 0 < S < L; non-canonical A, NOT reduced for hash.
1402        (
1403            "39a591f5321bbe07fd5a23dc2f39d025d74526615746727ceefd6e82ae65c06f",
1404            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
1405            "a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
1406        ),
1407    ];
1408
1409    /// Known-answer test guarding the dual-verifier split that backs TKA consensus correctness.
1410    ///
1411    /// `verify_ed25519_std` wraps `ed25519-dalek 2.x` (standard RFC-8032-ish, cofactorless) and is
1412    /// used for SigRotation WRAPPING signatures. `verify_ed25519_zip215` wraps `ed25519-zebra 4.x`
1413    /// (ZIP-215 cofactored) and is used for Direct/Credential signatures to match Go
1414    /// `ed25519consensus`. If these two ever collapse to identical behavior, Go wire-compat for
1415    /// Tailnet-Lock silently breaks — this test proves they remain distinct on the adversarial set.
1416    ///
1417    /// The accept/reject matrix is asserted **as actually observed** from the pinned crate versions
1418    /// (`ed25519-dalek 2.2.0`, `ed25519-zebra 4.2.0`). These are newer than the versions tabulated
1419    /// in the "Taming the many EdDSAs" paper (Table 5: dalek 1.0.0-pre.4, zebra 2.1.1), so the
1420    /// non-canonical cases (8–11) may differ from the paper; we lock in current behavior as a
1421    /// regression guard. The SECURITY-CRITICAL invariants are NOT version-tunable: the standard
1422    /// verifier MUST reject the S >= L malleability vectors (6, 7), and the two verifiers MUST
1423    /// disagree on the cofactored discriminator (vector 4). Those are hard, separate assertions.
1424    #[test]
1425    fn ed25519_speccheck_dual_verifier_kat() {
1426        // Observed accept(true)/reject(false) matrix for the pinned crates, vectors 0..=11.
1427        // Anchored to the speccheck paper rows then corrected to what the crates actually do
1428        // (see the per-vector run below — any divergence makes the test fail loudly).
1429        //
1430        //                              0    1    2    3    4    5    6    7    8    9    10   11
1431        const STD_EXPECT: [bool; 12] = [
1432            true, true, true, true, false, false, false, false, false, false, false, true,
1433        ];
1434        const ZIP215_EXPECT: [bool; 12] = [
1435            true, true, true, true, true, true, false, false, false, true, true, true,
1436        ];
1437
1438        for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
1439            let msg = unhex(msg_hex);
1440            let pk = unhex(pk_hex);
1441            let sig = unhex(sig_hex);
1442            assert_eq!(pk.len(), 32, "vector {i}: pubkey not 32 bytes");
1443            assert_eq!(sig.len(), 64, "vector {i}: signature not 64 bytes");
1444
1445            let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
1446            let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
1447
1448            assert_eq!(
1449                std_ok, STD_EXPECT[i],
1450                "speccheck vector {i}: verify_ed25519_std accept={std_ok}, expected {}",
1451                STD_EXPECT[i]
1452            );
1453            assert_eq!(
1454                zip_ok, ZIP215_EXPECT[i],
1455                "speccheck vector {i}: verify_ed25519_zip215 accept={zip_ok}, expected {}",
1456                ZIP215_EXPECT[i]
1457            );
1458        }
1459
1460        // SECURITY-CRITICAL invariant (NOT version-tunable): the standard verifier must reject
1461        // signatures whose scalar S is out of range (S >= L). These are vectors 6 and 7 — the
1462        // EdDSA malleability guard. If either ACCEPTS, that is a real security finding.
1463        for &i in &[6usize, 7usize] {
1464            let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[i];
1465            let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
1466            assert!(
1467                verify_ed25519_std(&pk, &msg, &sig).is_err(),
1468                "SECURITY: verify_ed25519_std ACCEPTED S>=L malleability vector {i}"
1469            );
1470        }
1471
1472        // KEY DISCRIMINATOR (vector 4): cofactored (ZIP-215/zebra) accepts, cofactorless
1473        // (standard/dalek) rejects, on the SAME (pk, msg, sig). This proves the dual-verifier
1474        // split is real and not accidentally identical.
1475        {
1476            let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[4];
1477            let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
1478            assert!(
1479                verify_ed25519_zip215(&pk, &msg, &sig).is_ok(),
1480                "vector 4: ZIP-215 (zebra) should ACCEPT the cofactored discriminator"
1481            );
1482            assert!(
1483                verify_ed25519_std(&pk, &msg, &sig).is_err(),
1484                "vector 4: standard (dalek) should REJECT the cofactored discriminator"
1485            );
1486        }
1487    }
1488
1489    // ----- Cross-implementation KATs against real Go `tailscale.com/tka` v1.100.0 -----
1490
1491    /// Cross-implementation Known-Answer-Test: the CTAP2-CBOR serialization and BLAKE2s-256
1492    /// `SigHash` of three `NodeKeySignature` shapes must byte-match the REAL Go
1493    /// `tailscale.com/tka` package, version **v1.100.0** (toolchain **go1.26.4**).
1494    ///
1495    /// Provenance: the golden bytes below were produced by a Go generator that imports the real
1496    /// upstream `tailscale.com/tka` and calls `NodeKeySignature.Serialize()` (full CBOR including
1497    /// the signature field) and `NodeKeySignature.SigHash()` (BLAKE2s-256 of the CBOR with the
1498    /// `Signature` field nil'd). They are authoritative upstream output, NOT this fork's own
1499    /// encoder echoed back — this is the cross-validation the `node_key_signature_cbor_frozen_vector`
1500    /// freeze-test could not provide. The generator lives alongside the speccheck generator under
1501    /// `tests/vectors/gen` (Go module pinned to `tailscale.com v1.100.0`).
1502    ///
1503    /// Three shapes are covered: a `Direct` leaf, a `Credential` leaf (same fields, different
1504    /// `sigKind`), and a `Rotation` wrapping a nested `Direct` (the rotation-chain wire form). The
1505    /// int-map keys are 1=sigKind, 2=pubkey, 3=keyID, 4=signature, 5=nested, 6=wrappingPubkey;
1506    /// empty byte fields are omitted (`omitempty`).
1507    #[test]
1508    fn tka_cbor_matches_go_golden() {
1509        // Common fixed field material (real Go generator inputs).
1510        let pubkey32 = unhex("a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf");
1511        let key_id32 = unhex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f");
1512        let sig64 = unhex(
1513            "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
1514        );
1515        let wrap32 = unhex("101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f");
1516        let rot_sig64 = unhex(
1517            "55565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f9091929394",
1518        );
1519
1520        // GOLDEN 1 — Direct.
1521        {
1522            let sig = NodeKeySignature {
1523                sig_kind: SigKind::Direct,
1524                pubkey: pubkey32.clone(),
1525                key_id: key_id32.clone(),
1526                signature: sig64.clone(),
1527                nested: None,
1528                wrapping_pubkey: Vec::new(),
1529            };
1530            let full = sig.to_cbor(true).to_vec();
1531            let expected_full = unhex(
1532                "a40101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
1533            );
1534            assert_eq!(
1535                full,
1536                expected_full,
1537                "GOLDEN 1 (Direct) full CBOR diverged from Go tka v1.100.0. actual: {}",
1538                hex(&full)
1539            );
1540            let expected_hash =
1541                unhex("7e9653c97d35485b37b9bf942b1861cd2f3cb0663b5bb154f1178cca72101e74");
1542            assert_eq!(
1543                sig.sig_hash().as_slice(),
1544                expected_hash.as_slice(),
1545                "GOLDEN 1 (Direct) sig_hash diverged from Go tka v1.100.0. actual: {}",
1546                hex(&sig.sig_hash())
1547            );
1548        }
1549
1550        // GOLDEN 2 — Credential (same fields as Direct, sigKind=3).
1551        {
1552            let sig = NodeKeySignature {
1553                sig_kind: SigKind::Credential,
1554                pubkey: pubkey32.clone(),
1555                key_id: key_id32.clone(),
1556                signature: sig64.clone(),
1557                nested: None,
1558                wrapping_pubkey: Vec::new(),
1559            };
1560            let full = sig.to_cbor(true).to_vec();
1561            let expected_full = unhex(
1562                "a40103025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
1563            );
1564            assert_eq!(
1565                full,
1566                expected_full,
1567                "GOLDEN 2 (Credential) full CBOR diverged from Go tka v1.100.0. actual: {}",
1568                hex(&full)
1569            );
1570            let expected_hash =
1571                unhex("b6070ea8bc7ae8989ef4293f5031bedaa4a499803ade99f9e2f34dc2898ac03f");
1572            assert_eq!(
1573                sig.sig_hash().as_slice(),
1574                expected_hash.as_slice(),
1575                "GOLDEN 2 (Credential) sig_hash diverged from Go tka v1.100.0. actual: {}",
1576                hex(&sig.sig_hash())
1577            );
1578        }
1579
1580        // GOLDEN 3 — Rotation wrapping a nested Direct.
1581        //
1582        // Decoded from the authoritative Go bytes, the OUTER map is `a4` = 4 entries: keys
1583        // 1=sigKind(Rotation), 2=pubkey(wrap32), 4=signature(rotSig64), 5=nested. The outer has NO
1584        // key 6 (its `wrapping_pubkey` is EMPTY → omitted) and NO key 3 (its `key_id` is EMPTY →
1585        // omitted). The trailing `065820<wrap32>` in the hex belongs to the NESTED Direct map
1586        // (`a5` = 5 entries: keys 1,2,3,4,6), whose `wrapping_pubkey` IS set to wrap32. Constructing
1587        // the structs this way (outer wrapping_pubkey empty, nested wrapping_pubkey=wrap32)
1588        // reproduces the Go bytes exactly.
1589        {
1590            let nested = NodeKeySignature {
1591                sig_kind: SigKind::Direct,
1592                pubkey: pubkey32.clone(),
1593                key_id: key_id32.clone(),
1594                signature: sig64.clone(),
1595                nested: None,
1596                wrapping_pubkey: wrap32.clone(),
1597            };
1598            let sig = NodeKeySignature {
1599                sig_kind: SigKind::Rotation,
1600                pubkey: wrap32.clone(),
1601                key_id: Vec::new(),
1602                signature: rot_sig64.clone(),
1603                nested: Some(alloc::boxed::Box::new(nested)),
1604                wrapping_pubkey: Vec::new(),
1605            };
1606            let full = sig.to_cbor(true).to_vec();
1607            let expected_full = unhex(
1608                "a40102025820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f04584055565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939405a50101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf065820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f",
1609            );
1610            assert_eq!(
1611                full,
1612                expected_full,
1613                "GOLDEN 3 (Rotation) full CBOR diverged from Go tka v1.100.0. actual: {}",
1614                hex(&full)
1615            );
1616            let expected_hash =
1617                unhex("fac0a5a6781bb945369c28a0b3d3eea04e1648b60ec1a990a1ff68a9a566e6a7");
1618            assert_eq!(
1619                sig.sig_hash().as_slice(),
1620                expected_hash.as_slice(),
1621                "GOLDEN 3 (Rotation) sig_hash diverged from Go tka v1.100.0. actual: {}",
1622                hex(&sig.sig_hash())
1623            );
1624        }
1625    }
1626
1627    /// Cross-bind the dual Ed25519 verifier accept/reject matrix to the verdicts produced by the
1628    /// REAL Go implementations on the adversarial speccheck set (see [`SPECCHECK_VECTORS`]).
1629    ///
1630    /// Provenance of the Go verdicts: Go `crypto/ed25519.Verify` (standard, cofactorless) and
1631    /// `github.com/hdevalence/ed25519consensus v0.2.0` (ZIP-215, cofactored), toolchain
1632    /// **go1.26.4**, driven by the generator under `tests/vectors/gen/zip215`. These are the SAME
1633    /// verdicts the pinned Rust crates produce — proving `ed25519-dalek 2.x` == Go-std and
1634    /// `ed25519-zebra 4.x` == Go-`ed25519consensus` on the adversarial set. The arrays below MUST
1635    /// therefore equal `STD_EXPECT` / `ZIP215_EXPECT` asserted in
1636    /// `ed25519_speccheck_dual_verifier_kat`; this test additionally pins them to Go's behavior.
1637    ///
1638    /// NOTE: [`SPECCHECK_VECTORS`] is duplicated (byte-for-byte) in the Go generator at
1639    /// `tests/vectors/gen/zip215/main.go`. Both copies derive from the same upstream
1640    /// `cases.json` commit; if you edit one you MUST edit the other, or this proof would compare
1641    /// inputs the Go verdicts were never computed over.
1642    #[test]
1643    fn ed25519_dual_verifier_matches_go_verdicts() {
1644        //                                  0    1    2    3    4    5    6    7    8    9   10   11
1645        const GO_STD_ACCEPT: [bool; 12] = [
1646            true, true, true, true, false, false, false, false, false, false, false, true,
1647        ];
1648        const GO_ZIP215_ACCEPT: [bool; 12] = [
1649            true, true, true, true, true, true, false, false, false, true, true, true,
1650        ];
1651
1652        for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
1653            let msg = unhex(msg_hex);
1654            let pk = unhex(pk_hex);
1655            let sig = unhex(sig_hex);
1656
1657            let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
1658            let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
1659
1660            assert_eq!(
1661                std_ok, GO_STD_ACCEPT[i],
1662                "vector {i}: Rust verify_ed25519_std accept={std_ok} disagrees with Go \
1663                 crypto/ed25519.Verify={}",
1664                GO_STD_ACCEPT[i]
1665            );
1666            assert_eq!(
1667                zip_ok, GO_ZIP215_ACCEPT[i],
1668                "vector {i}: Rust verify_ed25519_zip215 accept={zip_ok} disagrees with Go \
1669                 ed25519consensus.Verify={}",
1670                GO_ZIP215_ACCEPT[i]
1671            );
1672        }
1673    }
1674
1675    /// Byte-exact cross-validation of [`Aum::serialize`] against the literal `[]byte` vectors in Go
1676    /// `tka/aum_test.go` `TestSerialization` (tailscale v1.100.0, fxamacker/cbor v2.9.2 CTAP2 mode).
1677    /// These are the authoritative oracle: if our CTAP2 CBOR diverges from Go by a single byte, the
1678    /// `AUM.Hash` chain links and every signature digest break. Each case reproduces the exact Go
1679    /// `AUM{…}` literal and asserts identical canonical bytes.
1680    #[test]
1681    fn aum_serialize_matches_go_test_serialization_vectors() {
1682        // AddKey: AUM{MessageKind: AUMAddKey, Key: &Key{}}. Go's *zero* Key{} has Kind=0
1683        // (KeyInvalid) and Public=nil, which our `AumKey` (always a valid KeyKind + Vec) cannot
1684        // model — that zero-Key encoding (`03 a3 01 00 02 00 03 f6`) is asserted directly at the
1685        // CBOR layer here, while the AUM keymap around it (map3, kind=AddKey, null prev, Key at
1686        // key 3) is covered by the structural assertions plus the three full vectors below.
1687        let add_key_inner_zero_key = cbor::Value::IntMap(alloc::vec![
1688            (1, cbor::Value::Uint(0)), // Kind = KeyInvalid(0)
1689            (2, cbor::Value::Uint(0)), // Votes = 0
1690            (3, cbor::Value::Null),    // Public = nil -> null
1691        ]);
1692        assert_eq!(
1693            add_key_inner_zero_key.to_vec(),
1694            alloc::vec![0xa3, 0x01, 0x00, 0x02, 0x00, 0x03, 0xf6],
1695            "Go's zero Key{{}} encodes as map(3){{kind=0, votes=0, public=null}}"
1696        );
1697
1698        // RemoveKey: AUM{MessageKind: AUMRemoveKey, KeyID: []byte{1, 2}}
1699        let remove_key = Aum {
1700            message_kind: AumKind::RemoveKey,
1701            prev_aum_hash: None,
1702            key: None,
1703            key_id: alloc::vec![1, 2],
1704            state: None,
1705            votes: None,
1706            meta: Vec::new(),
1707            signatures: Vec::new(),
1708        };
1709        assert_eq!(
1710            remove_key.serialize(),
1711            // a3 (map3) 01 02 (kind=RemoveKey) 02 f6 (prev=null) 04 42 01 02 (KeyID=bytes{1,2})
1712            alloc::vec![0xa3, 0x01, 0x02, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02],
1713            "RemoveKey AUM serialization must match Go TestSerialization byte-for-byte"
1714        );
1715
1716        // UpdateKey: AUM{MessageKind: AUMUpdateKey, Votes: &uint(2), KeyID: []byte{1,2},
1717        //                Meta: map[string]string{"a":"b"}}
1718        let update_key = Aum {
1719            message_kind: AumKind::UpdateKey,
1720            prev_aum_hash: None,
1721            key: None,
1722            key_id: alloc::vec![1, 2],
1723            state: None,
1724            votes: Some(2),
1725            meta: alloc::vec![("a".into(), "b".into())],
1726            signatures: Vec::new(),
1727        };
1728        assert_eq!(
1729            update_key.serialize(),
1730            // a5 (map5) 01 04 (UpdateKey) 02 f6 (prev null) 04 42 01 02 (KeyID) 06 02 (Votes=2)
1731            // 07 a1 61 61 61 62 (Meta = {"a":"b"})  — keys ascending 1,2,4,6,7
1732            alloc::vec![
1733                0xa5, 0x01, 0x04, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02, 0x06, 0x02, 0x07, 0xa1, 0x61,
1734                0x61, 0x61, 0x62
1735            ],
1736            "UpdateKey AUM serialization must match Go TestSerialization byte-for-byte"
1737        );
1738
1739        // Signature: AUM{MessageKind: AUMAddKey, Signatures: []tkatype.Signature{{KeyID: []byte{1}}}}
1740        let with_sig = Aum {
1741            message_kind: AumKind::AddKey,
1742            prev_aum_hash: None,
1743            key: None,
1744            key_id: Vec::new(),
1745            state: None,
1746            votes: None,
1747            meta: Vec::new(),
1748            signatures: alloc::vec![AumSignature {
1749                key_id: alloc::vec![1],
1750                signature: Vec::new(),
1751            }],
1752        };
1753        assert_eq!(
1754            with_sig.serialize(),
1755            // a3 (map3) 01 01 (AddKey) 02 f6 (prev null) 17 (key 23 = Signatures) 81 (array1)
1756            // a2 (map2) 01 41 01 (Signature.KeyID = bytes{1}) 02 f6 (Signature.Signature = null)
1757            alloc::vec![
1758                0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6
1759            ],
1760            "Signature AUM serialization must match Go TestSerialization (key 23 + nil sig = null)"
1761        );
1762
1763        // sig_hash must drop key 23 (Go SigHash nils Signatures → omitempty): the with_sig AUM's
1764        // sig_hash equals the BLAKE2s of the same AUM with no signatures.
1765        let no_sig = Aum {
1766            signatures: Vec::new(),
1767            ..with_sig.clone()
1768        };
1769        assert_eq!(
1770            with_sig.sig_hash(),
1771            blake2s_256(&no_sig.serialize()),
1772            "SigHash preimage must omit key 23 (Signatures), matching Go AUM.SigHash"
1773        );
1774        // And the full Hash differs from the SigHash (signatures are in the chain-link hash).
1775        assert_ne!(
1776            with_sig.hash().0,
1777            with_sig.sig_hash(),
1778            "Hash (incl. signatures) must differ from SigHash (excl.) when signatures are present"
1779        );
1780    }
1781
1782    /// Checkpoint AUM with an embedded `State`: exercises [`AumState`]/[`AumKey`] CBOR (the 32-byte
1783    /// `LastAUMHash` as a definite-length byte string, the `DisablementValues`/`Keys` arrays, and the
1784    /// `Key.Public` at key 3). Mirrors the structure of Go's `TestSerialization` Checkpoint case.
1785    #[test]
1786    fn aum_checkpoint_state_serialization() {
1787        let checkpoint = Aum {
1788            message_kind: AumKind::Checkpoint,
1789            prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
1790            key: None,
1791            key_id: Vec::new(),
1792            state: Some(AumState {
1793                last_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
1794                disablement_values: Vec::new(),
1795                keys: alloc::vec![AumKey {
1796                    kind: KeyKind::Ed25519,
1797                    votes: 1,
1798                    public: alloc::vec![5, 6],
1799                    meta: Vec::new(),
1800                }],
1801                state_id1: 0,
1802                state_id2: 0,
1803            }),
1804            votes: None,
1805            meta: Vec::new(),
1806            signatures: Vec::new(),
1807        };
1808        let bytes = checkpoint.serialize();
1809        // Spot-check the structurally-load-bearing pieces (full-vector parity is covered by the
1810        // three exact vectors above; here we pin the State/Key encoding shape):
1811        // map3: key1=Checkpoint(5), key2=prev(32-byte bytestring 0x58 0x20 …), key5=State.
1812        assert_eq!(
1813            &bytes[0..3],
1814            &[0xa3, 0x01, 0x05],
1815            "map(3), MessageKind=Checkpoint(5)"
1816        );
1817        assert_eq!(
1818            &bytes[3..6],
1819            &[0x02, 0x58, 0x20],
1820            "key2 prev = 32-byte byte string head"
1821        );
1822        // The embedded State map (key 5) must contain: LastAUMHash (1) as 32-byte bytes, an empty
1823        // DisablementValues array (2 → 0x80), and a Keys array (3 → 0x81 with one Key map).
1824        // Locate the State map head (key 5) after the 32-byte prev hash: 3 + 3 + 32 = offset 38.
1825        assert_eq!(bytes[38], 0x05, "key 5 = State");
1826        // State is a map; its first entry is key1 (LastAUMHash) = 32-byte byte string.
1827        assert_eq!(
1828            &bytes[39..42],
1829            &[0xa3, 0x01, 0x58],
1830            "State map(3), key1 LastAUMHash bytes"
1831        );
1832        // The Key inside Keys carries Public={5,6} at key 3 (…03 42 05 06) and Votes=1 at key 2.
1833        let tail = &bytes[bytes.len() - 4..];
1834        assert_eq!(
1835            tail,
1836            &[0x03, 0x42, 0x05, 0x06],
1837            "Key.Public (key 3) = bytes{{5,6}}"
1838        );
1839        // Round-trips deterministically (hash is stable).
1840        assert_eq!(checkpoint.hash(), checkpoint.hash());
1841    }
1842}