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 gap
19//!
20//! Verification is fail-closed: any decode/shape/signature problem denies authorization. **Caveat:**
21//! the CTAP2-CBOR byte-exactness has not been cross-validated against Go-produced test vectors in
22//! this fork, so byte-for-byte wire compatibility with a live Tailscale TKA is asserted by
23//! construction, not proven. Treat a *successful* verification as advisory until vectors land;
24//! a *failed* verification is always safe to act on (deny).
25
26extern crate alloc;
27
28use alloc::{string::String, vec::Vec};
29
30use blake2::{Blake2s256, Digest};
31
32pub mod cbor;
33
34use cbor::Value;
35
36/// Length in bytes of an [`AumHash`] (BLAKE2s-256 output).
37pub const AUM_HASH_LEN: usize = 32;
38
39/// Maximum nesting depth allowed when decoding/verifying a [`NodeKeySignature`] and its CBOR.
40///
41/// A peer-supplied signature CBOR is attacker-controlled and cheap to nest arbitrarily deep (a few
42/// bytes per level). Without a bound, the recursive decoder/verifier overflows the stack and aborts
43/// the process (DoS). Go bounds this. Real TKA rotation chains are short (a handful of links), so a
44/// cap of 16 sits comfortably above any legitimate chain while staying far below stack-overflow.
45/// Enforced at DECODE time (before any crypto), and also bounds generic CBOR container nesting.
46const MAX_SIG_NESTING_DEPTH: usize = 16;
47
48/// A BLAKE2s-256 hash of an AUM's canonical serialization. Identifies an AUM and links the chain
49/// (`PrevAUMHash`). Text form is RFC4648 standard base32, no padding (Go `AUMHash.MarshalText`).
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub struct AumHash(pub [u8; AUM_HASH_LEN]);
52
53impl AumHash {
54    /// Decode an `AumHash` from its base32 (no-pad, RFC4648 standard alphabet) text form, as found
55    /// in `TkaInfo.head`. Returns `None` if the text is not exactly 32 decoded bytes.
56    pub fn from_base32(text: &str) -> Option<AumHash> {
57        let decoded = base32_decode_nopad(text)?;
58        if decoded.len() != AUM_HASH_LEN {
59            return None;
60        }
61        let mut h = [0u8; AUM_HASH_LEN];
62        h.copy_from_slice(&decoded);
63        Some(AumHash(h))
64    }
65
66    /// Encode this hash as base32 (no-pad, standard alphabet) — the wire/text form.
67    pub fn to_base32(&self) -> String {
68        base32_encode_nopad(&self.0)
69    }
70}
71
72/// The kind of an AUM (Authority Update Message) (Go `AUMKind`; integer values are wire-stable, do
73/// not reorder).
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75#[repr(u8)]
76pub enum AumKind {
77    /// Invalid / unset (0).
78    Invalid = 0,
79    /// Add a trusted key (1).
80    AddKey = 1,
81    /// Remove a trusted key (2).
82    RemoveKey = 2,
83    /// No-op (3).
84    NoOp = 3,
85    /// Update an existing key's votes/metadata (4).
86    UpdateKey = 4,
87    /// Checkpoint: a full state snapshot (5).
88    Checkpoint = 5,
89}
90
91impl AumKind {
92    /// Decode an [`AumKind`] from its wire integer, or `None` for an unknown kind. Provided for a
93    /// future AUM-chain replayer (the admin-side authority-derivation half), which is out of scope
94    /// for the current client-verify path.
95    pub fn from_u8(n: u8) -> Option<AumKind> {
96        Some(match n {
97            0 => AumKind::Invalid,
98            1 => AumKind::AddKey,
99            2 => AumKind::RemoveKey,
100            3 => AumKind::NoOp,
101            4 => AumKind::UpdateKey,
102            5 => AumKind::Checkpoint,
103            _ => return None,
104        })
105    }
106}
107
108/// The kind of a TKA [`Key`] (Go `KeyKind`).
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum KeyKind {
111    /// Ed25519 trusted key (Go `Key25519` = 1).
112    Ed25519,
113}
114
115/// A trusted TKA key (Go `tka.Key`). Its [`Key::id`] (the 32-byte public key for Ed25519) is what an
116/// [`AumHash`] / [`NodeKeySignature`] references via `KeyID`.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Key {
119    /// Key algorithm.
120    pub kind: KeyKind,
121    /// Voting weight (Go `Votes`, valid range 1..=4096).
122    pub votes: u32,
123    /// The raw public key bytes (32 for Ed25519).
124    pub public: Vec<u8>,
125}
126
127impl Key {
128    /// The key id: for Ed25519 this is the public key verbatim (Go `Key.ID`).
129    pub fn id(&self) -> &[u8] {
130        &self.public
131    }
132}
133
134/// The kind of a [`NodeKeySignature`] (Go `SigKind`).
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136#[repr(u8)]
137pub enum SigKind {
138    /// Invalid (0).
139    Invalid = 0,
140    /// Directly signs a node key with a trusted key (1).
141    Direct = 1,
142    /// Signs a rotated node key, nesting the prior signature (2).
143    Rotation = 2,
144    /// A credential signature; cannot authorize a node on its own (3).
145    Credential = 3,
146}
147
148impl SigKind {
149    fn from_u8(n: u8) -> Option<SigKind> {
150        Some(match n {
151            0 => SigKind::Invalid,
152            1 => SigKind::Direct,
153            2 => SigKind::Rotation,
154            3 => SigKind::Credential,
155            _ => return None,
156        })
157    }
158}
159
160/// A node-key signature (Go `tka.NodeKeySignature`): proof that a node's key is authorized under the
161/// tailnet-lock authority. Decoded from the CBOR blob a peer presents.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct NodeKeySignature {
164    /// Signature kind.
165    pub sig_kind: SigKind,
166    /// The node public key this signature authorizes (Go `Pubkey`).
167    pub pubkey: Vec<u8>,
168    /// The id of the trusted [`Key`] that signed this (Go `KeyID`).
169    pub key_id: Vec<u8>,
170    /// The Ed25519 signature bytes.
171    pub signature: Vec<u8>,
172    /// For [`SigKind::Rotation`], the nested (prior) signature.
173    pub nested: Option<alloc::boxed::Box<NodeKeySignature>>,
174    /// For rotation, the wrapping public key the nested signature authorized.
175    pub wrapping_pubkey: Vec<u8>,
176}
177
178/// Errors from TKA verification.
179#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
180pub enum TkaError {
181    /// The CBOR blob could not be decoded into the expected shape.
182    #[error("TKA decode error: {0}")]
183    Decode(&'static str),
184    /// A signature failed to verify cryptographically.
185    #[error("TKA signature verification failed")]
186    BadSignature,
187    /// The authorizing key is not trusted in the current authority state.
188    #[error("TKA authorizing key is not trusted")]
189    UntrustedKey,
190    /// A credential signature was presented where a node-authorizing signature was required.
191    #[error("a credential signature cannot authorize a node")]
192    CredentialCannotAuthorize,
193    /// The presented signature does not cover the given node key.
194    #[error("signature does not cover this node key")]
195    NodeKeyMismatch,
196}
197
198impl NodeKeySignature {
199    /// The canonical CBOR serialization of this signature with the `Signature` field nil'd, used as
200    /// the signing-digest preimage (Go `NodeKeySignature.SigHash` zeroes `Signature` then
201    /// serializes).
202    fn sig_hash(&self) -> [u8; AUM_HASH_LEN] {
203        let v = self.to_cbor(/* include_signature = */ false);
204        blake2s_256(&v.to_vec())
205    }
206
207    /// Build the CBOR value for this signature. When `include_signature` is false, the signature
208    /// field (key 4) is omitted (the SigHash preimage).
209    fn to_cbor(&self, include_signature: bool) -> Value {
210        cbor::int_map([
211            (1, Some(Value::Uint(self.sig_kind as u8 as u64))),
212            (2, nonempty_bytes(&self.pubkey)),
213            (3, nonempty_bytes(&self.key_id)),
214            (
215                4,
216                if include_signature {
217                    nonempty_bytes(&self.signature)
218                } else {
219                    None
220                },
221            ),
222            (5, self.nested.as_ref().map(|n| n.to_cbor(true))),
223            (6, nonempty_bytes(&self.wrapping_pubkey)),
224        ])
225    }
226
227    /// The key id that ultimately roots this signature in a trusted key (Go `authorizingKeyID`):
228    /// for a rotation, recurse into the nested signature; otherwise this signature's `key_id`.
229    fn authorizing_key_id(&self) -> Result<&[u8], TkaError> {
230        match self.sig_kind {
231            SigKind::Rotation => self
232                .nested
233                .as_ref()
234                .ok_or(TkaError::Decode("rotation signature missing nested"))?
235                .authorizing_key_id(),
236            SigKind::Direct | SigKind::Credential => Ok(&self.key_id),
237            SigKind::Invalid => Err(TkaError::Decode("invalid signature kind")),
238        }
239    }
240
241    /// Verify this signature authorizes `node_key`, rooted in the trusted `verification_key` (Go
242    /// `NodeKeySignature.verifySignature`).
243    fn verify_signature(&self, node_key: &[u8], verification_key: &Key) -> Result<(), TkaError> {
244        // For non-credential signatures the signed pubkey must equal the node key being authorized.
245        if self.sig_kind != SigKind::Credential && self.pubkey != node_key {
246            return Err(TkaError::NodeKeyMismatch);
247        }
248
249        let sig_hash = self.sig_hash();
250
251        match self.sig_kind {
252            SigKind::Rotation => {
253                let nested = self
254                    .nested
255                    .as_ref()
256                    .ok_or(TkaError::Decode("rotation signature missing nested"))?;
257                // The outer rotation signature is verified with STANDARD ed25519 against the nested
258                // signature's wrapping public key.
259                let verify_pub = &nested.wrapping_pubkey;
260                if verify_pub.len() != 32 {
261                    return Err(TkaError::Decode("wrapping pubkey wrong length"));
262                }
263                verify_ed25519_std(verify_pub, &sig_hash, &self.signature)?;
264                // The nested signature must cover the rotation pivot (`verify_pub`). For a nested
265                // Direct this is enforced inside its own `verify_signature` (the non-credential
266                // `pubkey != node_key` check). A nested Credential SKIPS that check, so bind it
267                // here: the credential must cover exactly the wrapping pubkey it is rotating, or an
268                // attacker could splice an unrelated valid credential into the chain.
269                if nested.sig_kind == SigKind::Credential && nested.pubkey != *verify_pub {
270                    return Err(TkaError::NodeKeyMismatch);
271                }
272                // Then the nested signature must itself be valid, rooting in the trusted key.
273                nested.verify_signature(verify_pub, verification_key)
274            }
275            SigKind::Direct | SigKind::Credential => {
276                if self.nested.is_some() {
277                    return Err(TkaError::Decode("direct/credential signature has nested"));
278                }
279                if verification_key.kind != KeyKind::Ed25519 || verification_key.public.len() != 32
280                {
281                    return Err(TkaError::Decode("verification key not ed25519"));
282                }
283                // Direct/credential signatures verify with ZIP-215 (cofactored) ed25519, matching
284                // Go's `ed25519consensus.Verify`.
285                verify_ed25519_zip215(&verification_key.public, &sig_hash, &self.signature)
286            }
287            SigKind::Invalid => Err(TkaError::Decode("invalid signature kind")),
288        }
289    }
290}
291
292/// The current authority state (Go `tka.State`): the set of trusted keys at a given chain head.
293/// This is the minimal slice a client needs for [`Authority::node_key_authorized`].
294#[derive(Debug, Clone, Default, PartialEq, Eq)]
295pub struct State {
296    /// The trusted keys.
297    pub keys: Vec<Key>,
298}
299
300impl State {
301    /// Find a trusted key by its id (Go `State.GetKey`).
302    pub fn get_key(&self, key_id: &[u8]) -> Option<&Key> {
303        self.keys.iter().find(|k| k.id() == key_id)
304    }
305}
306
307/// A tailnet-lock authority as a client tracks it: the current trusted-key [`State`] and the chain
308/// `head`. Built by replaying the AUM chain (or from a control-provided checkpoint); the client
309/// then uses [`Authority::node_key_authorized`] to decide whether a peer is trusted.
310#[derive(Debug, Clone)]
311pub struct Authority {
312    head: AumHash,
313    state: State,
314}
315
316impl Authority {
317    /// Construct an authority directly from a known `head` and trusted-key `state` (e.g. a
318    /// control-provided checkpoint the client already trusts).
319    pub fn from_state(head: AumHash, state: State) -> Authority {
320        Authority { head, state }
321    }
322
323    /// The current chain head hash (Go `Authority.Head`).
324    pub fn head(&self) -> AumHash {
325        self.head
326    }
327
328    /// The trusted-key state.
329    pub fn state(&self) -> &State {
330        &self.state
331    }
332
333    /// Whether `head` (e.g. decoded from `TkaInfo.head`) matches this authority's head. A client
334    /// that finds a mismatch must resync before trusting verifications.
335    pub fn head_matches(&self, head: &AumHash) -> bool {
336        &self.head == head
337    }
338
339    /// Verify that `node_key` is authorized under the current authority state by the given
340    /// node-key-signature CBOR blob (Go `Authority.NodeKeyAuthorized`).
341    ///
342    /// Fail-closed: a credential-only signature, an untrusted authorizing key, a malformed blob, or
343    /// a bad signature all return `Err`.
344    ///
345    /// # Errors
346    ///
347    /// Returns [`TkaError::Decode`] if `signature_cbor` is malformed,
348    /// [`TkaError::CredentialCannotAuthorize`] for a credential-only signature,
349    /// [`TkaError::UntrustedKey`] if the authorizing key is not in the current state,
350    /// [`TkaError::NodeKeyMismatch`] if the signature does not cover `node_key`, or
351    /// [`TkaError::BadSignature`] if cryptographic verification fails.
352    pub fn node_key_authorized(
353        &self,
354        node_key: &[u8],
355        signature_cbor: &[u8],
356    ) -> Result<(), TkaError> {
357        let sig = decode_node_key_signature(signature_cbor)?;
358        // A credential signature can never authorize a node on its own.
359        if sig.sig_kind == SigKind::Credential {
360            return Err(TkaError::CredentialCannotAuthorize);
361        }
362        let key_id = sig.authorizing_key_id()?;
363        let key = self.state.get_key(key_id).ok_or(TkaError::UntrustedKey)?;
364        sig.verify_signature(node_key, key)
365    }
366}
367
368/// Compute the [`AumHash`] of an AUM given its canonical CBOR serialization. Exposed so a chain
369/// replayer can link AUMs (`PrevAUMHash`) without re-deriving the hash function.
370pub fn aum_hash(canonical_cbor: &[u8]) -> AumHash {
371    AumHash(blake2s_256(canonical_cbor))
372}
373
374fn blake2s_256(data: &[u8]) -> [u8; AUM_HASH_LEN] {
375    let mut hasher = Blake2s256::new();
376    hasher.update(data);
377    let out = hasher.finalize();
378    let mut h = [0u8; AUM_HASH_LEN];
379    h.copy_from_slice(&out);
380    h
381}
382
383/// `Some(Bytes)` when `b` is non-empty, else `None` — the `omitempty` rule for byte fields.
384fn nonempty_bytes(b: &[u8]) -> Option<Value> {
385    if b.is_empty() {
386        None
387    } else {
388        Some(Value::Bytes(b.to_vec()))
389    }
390}
391
392/// Verify a standard (RFC 8032, non-cofactored) Ed25519 signature.
393fn verify_ed25519_std(public: &[u8], msg: &[u8], sig: &[u8]) -> Result<(), TkaError> {
394    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
395    let pk: [u8; 32] = public
396        .try_into()
397        .map_err(|_| TkaError::Decode("bad pubkey len"))?;
398    let vk = VerifyingKey::from_bytes(&pk).map_err(|_| TkaError::Decode("bad ed25519 pubkey"))?;
399    let sig: [u8; 64] = sig
400        .try_into()
401        .map_err(|_| TkaError::Decode("bad sig len"))?;
402    vk.verify(msg, &Signature::from_bytes(&sig))
403        .map_err(|_| TkaError::BadSignature)
404}
405
406/// Verify a ZIP-215 (cofactored) Ed25519 signature, matching Go `ed25519consensus.Verify`.
407fn verify_ed25519_zip215(public: &[u8], msg: &[u8], sig: &[u8]) -> Result<(), TkaError> {
408    let pk: [u8; 32] = public
409        .try_into()
410        .map_err(|_| TkaError::Decode("bad pubkey len"))?;
411    let vk = ed25519_zebra::VerificationKey::try_from(pk)
412        .map_err(|_| TkaError::Decode("bad ed25519 pubkey"))?;
413    let sig_bytes: [u8; 64] = sig
414        .try_into()
415        .map_err(|_| TkaError::Decode("bad sig len"))?;
416    let sig = ed25519_zebra::Signature::from(sig_bytes);
417    vk.verify(&sig, msg).map_err(|_| TkaError::BadSignature)
418}
419
420/// Decode a [`NodeKeySignature`] from canonical CBOR. This is a minimal decoder for the exact map
421/// shape Go emits (integer keys 1..=6); anything else is rejected (fail-closed).
422fn decode_node_key_signature(buf: &[u8]) -> Result<NodeKeySignature, TkaError> {
423    let (val, rest) = decode_value(buf, 0)?;
424    if !rest.is_empty() {
425        return Err(TkaError::Decode("trailing bytes after signature"));
426    }
427    node_key_signature_from_value(val, 0)
428}
429
430fn node_key_signature_from_value(val: Value, depth: usize) -> Result<NodeKeySignature, TkaError> {
431    if depth > MAX_SIG_NESTING_DEPTH {
432        return Err(TkaError::Decode("nested signature too deep"));
433    }
434    let Value::IntMap(entries) = val else {
435        return Err(TkaError::Decode("signature is not an int-keyed map"));
436    };
437    let mut sig_kind = None;
438    let mut pubkey = Vec::new();
439    let mut key_id = Vec::new();
440    let mut signature = Vec::new();
441    let mut nested = None;
442    let mut wrapping_pubkey = Vec::new();
443
444    for (k, v) in entries {
445        match k {
446            1 => {
447                let Value::Uint(n) = v else {
448                    return Err(TkaError::Decode("sig kind not uint"));
449                };
450                sig_kind = Some(
451                    SigKind::from_u8(
452                        u8::try_from(n).map_err(|_| TkaError::Decode("sig kind range"))?,
453                    )
454                    .ok_or(TkaError::Decode("unknown sig kind"))?,
455                );
456            }
457            2 => pubkey = expect_bytes(v)?,
458            3 => key_id = expect_bytes(v)?,
459            4 => signature = expect_bytes(v)?,
460            5 => {
461                nested = Some(alloc::boxed::Box::new(node_key_signature_from_value(
462                    v,
463                    depth + 1,
464                )?))
465            }
466            6 => wrapping_pubkey = expect_bytes(v)?,
467            _ => return Err(TkaError::Decode("unknown signature field")),
468        }
469    }
470
471    Ok(NodeKeySignature {
472        sig_kind: sig_kind.ok_or(TkaError::Decode("signature missing kind"))?,
473        pubkey,
474        key_id,
475        signature,
476        nested,
477        wrapping_pubkey,
478    })
479}
480
481fn expect_bytes(v: Value) -> Result<Vec<u8>, TkaError> {
482    match v {
483        Value::Bytes(b) => Ok(b),
484        _ => Err(TkaError::Decode("expected byte string")),
485    }
486}
487
488/// Decode one CBOR value (the subset the encoder produces) from `buf`, returning the value and the
489/// remaining bytes. Minimal — only the major types TKA uses.
490fn decode_value(buf: &[u8], depth: usize) -> Result<(Value, &[u8]), TkaError> {
491    // Bound generic CBOR container nesting so a deeply-nested array/map (even a non-signature one)
492    // cannot overflow the recursive decoder before signature-shape validation runs.
493    if depth > MAX_SIG_NESTING_DEPTH {
494        return Err(TkaError::Decode("nested signature too deep"));
495    }
496    let (major, arg, rest) = decode_head(buf)?;
497    match major {
498        0 => Ok((Value::Uint(arg), rest)),
499        2 => {
500            let len = arg as usize;
501            if rest.len() < len {
502                return Err(TkaError::Decode("byte string truncated"));
503            }
504            Ok((Value::Bytes(rest[..len].to_vec()), &rest[len..]))
505        }
506        3 => {
507            let len = arg as usize;
508            if rest.len() < len {
509                return Err(TkaError::Decode("text string truncated"));
510            }
511            Ok((Value::Text(rest[..len].to_vec()), &rest[len..]))
512        }
513        4 => {
514            let mut items = Vec::new();
515            let mut cur = rest;
516            for _ in 0..arg {
517                let (v, next) = decode_value(cur, depth + 1)?;
518                items.push(v);
519                cur = next;
520            }
521            Ok((Value::Array(items), cur))
522        }
523        5 => {
524            let mut entries: Vec<(u64, Value)> = Vec::new();
525            let mut cur = rest;
526            for _ in 0..arg {
527                let (k, next) = decode_head(cur).and_then(|(m, a, r)| {
528                    if m == 0 {
529                        Ok((a, r))
530                    } else {
531                        Err(TkaError::Decode("map key not uint"))
532                    }
533                })?;
534                // CTAP2/Go reject duplicate map keys; do the same (fail-closed) rather than
535                // silently last-wins.
536                if entries.iter().any(|(existing, _)| *existing == k) {
537                    return Err(TkaError::Decode("duplicate map key"));
538                }
539                let (v, next2) = decode_value(next, depth + 1)?;
540                entries.push((k, v));
541                cur = next2;
542            }
543            Ok((Value::IntMap(entries), cur))
544        }
545        _ => Err(TkaError::Decode("unsupported CBOR major type")),
546    }
547}
548
549/// Decode a CBOR head: returns `(major, argument, rest)`.
550fn decode_head(buf: &[u8]) -> Result<(u8, u64, &[u8]), TkaError> {
551    let first = *buf.first().ok_or(TkaError::Decode("empty CBOR"))?;
552    let major = first >> 5;
553    let info = first & 0x1f;
554    let rest = &buf[1..];
555    let (arg, rest) = match info {
556        n @ 0..=23 => (n as u64, rest),
557        24 => {
558            let b = *rest.first().ok_or(TkaError::Decode("truncated u8"))?;
559            (b as u64, &rest[1..])
560        }
561        25 => {
562            if rest.len() < 2 {
563                return Err(TkaError::Decode("truncated u16"));
564            }
565            (u16::from_be_bytes([rest[0], rest[1]]) as u64, &rest[2..])
566        }
567        26 => {
568            if rest.len() < 4 {
569                return Err(TkaError::Decode("truncated u32"));
570            }
571            (
572                u32::from_be_bytes([rest[0], rest[1], rest[2], rest[3]]) as u64,
573                &rest[4..],
574            )
575        }
576        27 => {
577            if rest.len() < 8 {
578                return Err(TkaError::Decode("truncated u64"));
579            }
580            let mut b = [0u8; 8];
581            b.copy_from_slice(&rest[..8]);
582            (u64::from_be_bytes(b), &rest[8..])
583        }
584        _ => return Err(TkaError::Decode("indefinite/reserved CBOR length")),
585    };
586    Ok((major, arg, rest))
587}
588
589// ----- RFC 4648 base32 (standard alphabet, no padding) -----
590
591const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
592
593fn base32_encode_nopad(data: &[u8]) -> String {
594    let mut out = String::new();
595    let mut buffer: u32 = 0;
596    let mut bits: u32 = 0;
597    for &b in data {
598        buffer = (buffer << 8) | b as u32;
599        bits += 8;
600        while bits >= 5 {
601            bits -= 5;
602            let idx = ((buffer >> bits) & 0x1f) as usize;
603            out.push(BASE32_ALPHABET[idx] as char);
604        }
605    }
606    if bits > 0 {
607        let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
608        out.push(BASE32_ALPHABET[idx] as char);
609    }
610    out
611}
612
613fn base32_decode_nopad(text: &str) -> Option<Vec<u8>> {
614    let mut buffer: u32 = 0;
615    let mut bits: u32 = 0;
616    let mut out = Vec::new();
617    for c in text.chars() {
618        let val = match c {
619            'A'..='Z' => c as u32 - 'A' as u32,
620            '2'..='7' => c as u32 - '2' as u32 + 26,
621            _ => return None,
622        };
623        buffer = (buffer << 5) | val;
624        bits += 5;
625        if bits >= 8 {
626            bits -= 8;
627            out.push((buffer >> bits) as u8);
628        }
629    }
630    Some(out)
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    #[test]
638    fn base32_roundtrip_32_bytes() {
639        let h = AumHash([0xABu8; 32]);
640        let text = h.to_base32();
641        let back = AumHash::from_base32(&text).unwrap();
642        assert_eq!(h, back);
643    }
644
645    #[test]
646    fn base32_rejects_wrong_length() {
647        // "AAAA" decodes to fewer than 32 bytes.
648        assert!(AumHash::from_base32("AAAA").is_none());
649        // Lowercase / invalid alphabet rejected.
650        assert!(AumHash::from_base32("aaaa").is_none());
651    }
652
653    #[test]
654    fn base32_matches_known_vector() {
655        // RFC 4648 base32 of "foobar" is "MZXW6YTBOI" (with padding "======"); no-pad drops the pad.
656        assert_eq!(base32_encode_nopad(b"foobar"), "MZXW6YTBOI");
657        assert_eq!(base32_decode_nopad("MZXW6YTBOI").unwrap(), b"foobar");
658    }
659
660    #[test]
661    fn credential_signature_cannot_authorize() {
662        let auth = Authority::from_state(AumHash([0; 32]), State::default());
663        let sig = NodeKeySignature {
664            sig_kind: SigKind::Credential,
665            pubkey: alloc::vec![1, 2, 3],
666            key_id: alloc::vec![4, 5, 6],
667            signature: alloc::vec![0; 64],
668            nested: None,
669            wrapping_pubkey: Vec::new(),
670        };
671        let cbor = sig.to_cbor(true).to_vec();
672        let err = auth.node_key_authorized(&[1, 2, 3], &cbor).unwrap_err();
673        assert_eq!(err, TkaError::CredentialCannotAuthorize);
674    }
675
676    #[test]
677    fn untrusted_key_denied() {
678        // A direct signature whose key id is not in the (empty) trusted state.
679        let auth = Authority::from_state(AumHash([0; 32]), State::default());
680        let sig = NodeKeySignature {
681            sig_kind: SigKind::Direct,
682            pubkey: alloc::vec![9; 32],
683            key_id: alloc::vec![7; 32],
684            signature: alloc::vec![0; 64],
685            nested: None,
686            wrapping_pubkey: Vec::new(),
687        };
688        let cbor = sig.to_cbor(true).to_vec();
689        let err = auth.node_key_authorized(&[9; 32], &cbor).unwrap_err();
690        assert_eq!(err, TkaError::UntrustedKey);
691    }
692
693    #[test]
694    fn direct_signature_verifies_end_to_end() {
695        use ed25519_dalek::{Signer, SigningKey};
696
697        // A trusted Ed25519 key signs a node key directly.
698        let signing = SigningKey::from_bytes(&[42u8; 32]);
699        let trusted_pub = signing.verifying_key().to_bytes().to_vec();
700        let node_key = alloc::vec![7u8; 32];
701
702        let trusted = Key {
703            kind: KeyKind::Ed25519,
704            votes: 1,
705            public: trusted_pub.clone(),
706        };
707        let auth = Authority::from_state(
708            AumHash([0; 32]),
709            State {
710                keys: alloc::vec![trusted],
711            },
712        );
713
714        // Build the signature, compute its sig-hash preimage, sign, then fill in the signature.
715        let mut sig = NodeKeySignature {
716            sig_kind: SigKind::Direct,
717            pubkey: node_key.clone(),
718            key_id: trusted_pub.clone(),
719            signature: Vec::new(),
720            nested: None,
721            wrapping_pubkey: Vec::new(),
722        };
723        let sig_hash = sig.sig_hash();
724        // NOTE: Go verifies Direct with ZIP-215; a standard ed25519-dalek signature is accepted by
725        // ZIP-215 verification (ZIP-215 is a superset), so signing with dalek here is valid.
726        sig.signature = signing.sign(&sig_hash).to_bytes().to_vec();
727
728        let cbor = sig.to_cbor(true).to_vec();
729        assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
730
731        // A different node key must NOT be authorized by this signature.
732        let other = alloc::vec![8u8; 32];
733        assert_eq!(
734            auth.node_key_authorized(&other, &cbor).unwrap_err(),
735            TkaError::NodeKeyMismatch
736        );
737    }
738
739    #[test]
740    fn tampered_signature_denied() {
741        use ed25519_dalek::{Signer, SigningKey};
742
743        let signing = SigningKey::from_bytes(&[42u8; 32]);
744        let trusted_pub = signing.verifying_key().to_bytes().to_vec();
745        let node_key = alloc::vec![7u8; 32];
746        let auth = Authority::from_state(
747            AumHash([0; 32]),
748            State {
749                keys: alloc::vec![Key {
750                    kind: KeyKind::Ed25519,
751                    votes: 1,
752                    public: trusted_pub.clone(),
753                }],
754            },
755        );
756        let mut sig = NodeKeySignature {
757            sig_kind: SigKind::Direct,
758            pubkey: node_key.clone(),
759            key_id: trusted_pub,
760            signature: Vec::new(),
761            nested: None,
762            wrapping_pubkey: Vec::new(),
763        };
764        let sig_hash = sig.sig_hash();
765        let mut sigbytes = signing.sign(&sig_hash).to_bytes();
766        sigbytes[0] ^= 0xff; // tamper
767        sig.signature = sigbytes.to_vec();
768
769        let cbor = sig.to_cbor(true).to_vec();
770        assert_eq!(
771            auth.node_key_authorized(&node_key, &cbor).unwrap_err(),
772            TkaError::BadSignature
773        );
774    }
775
776    #[test]
777    fn head_matches_check() {
778        let h = AumHash([5u8; 32]);
779        let auth = Authority::from_state(h, State::default());
780        assert!(auth.head_matches(&h));
781        assert!(!auth.head_matches(&AumHash([6u8; 32])));
782    }
783
784    // ----- Fix 1: depth cap on attacker-controlled nesting -----
785
786    #[test]
787    fn deeply_nested_signature_rejected_without_overflow() {
788        // Wrap a NodeKeySignature inside `nested` far past MAX_SIG_NESTING_DEPTH. This is cheap to
789        // construct (a few bytes per level) and would overflow an unbounded recursive decoder. The
790        // decoder must reject it as a Decode error — never panic / stack-overflow.
791        let mut sig = NodeKeySignature {
792            sig_kind: SigKind::Direct,
793            pubkey: alloc::vec![1u8; 32],
794            key_id: alloc::vec![2u8; 32],
795            signature: alloc::vec![3u8; 64],
796            nested: None,
797            wrapping_pubkey: Vec::new(),
798        };
799        for _ in 0..(MAX_SIG_NESTING_DEPTH + 8) {
800            sig = NodeKeySignature {
801                sig_kind: SigKind::Rotation,
802                pubkey: alloc::vec![1u8; 32],
803                key_id: Vec::new(),
804                signature: alloc::vec![3u8; 64],
805                nested: Some(alloc::boxed::Box::new(sig)),
806                wrapping_pubkey: alloc::vec![1u8; 32],
807            };
808        }
809        let cbor = sig.to_cbor(true).to_vec();
810        let err = decode_node_key_signature(&cbor).unwrap_err();
811        assert_eq!(err, TkaError::Decode("nested signature too deep"));
812    }
813
814    // ----- Fix 5: duplicate CBOR map keys rejected -----
815
816    #[test]
817    fn duplicate_map_key_rejected() {
818        // Hand-craft a CBOR map with key 1 repeated: map(2) { 1:0, 1:1 } => 0xa2 01 00 01 01.
819        let blob = [0xa2u8, 0x01, 0x00, 0x01, 0x01];
820        let err = decode_node_key_signature(&blob).unwrap_err();
821        assert_eq!(err, TkaError::Decode("duplicate map key"));
822    }
823
824    // ----- Fix 3: rotation-chain happy path + ZIP-215/std split -----
825
826    // ZIP-215 vs standard ed25519 in TKA, and why this crate carries BOTH verifiers:
827    //
828    //   * Direct/Credential signatures are verified with `verify_ed25519_zip215` (ed25519-zebra),
829    //     matching Go `ed25519consensus.Verify` — the *cofactored* ZIP-215 rule the TKA leaf
830    //     signatures are produced under. ZIP-215 is a strict superset of RFC 8032: any standard
831    //     (dalek) signature is accepted by it, which is why the tests below can sign leaves with
832    //     dalek and still verify under zebra.
833    //   * The outer rotation WRAP signature is verified with `verify_ed25519_std` (ed25519-dalek),
834    //     matching Go's plain `ed25519.Verify` for the rotation wrap. Collapsing these two
835    //     verifiers into one would silently diverge from Go on the wire — hence both deps are kept
836    //     (see Cargo.toml comment).
837    #[test]
838    fn rotation_chain_verifies_end_to_end() {
839        use ed25519_dalek::{Signer, SigningKey};
840
841        // Trusted key signs the inner Direct over the wrapping (pivot) pubkey.
842        let trusted = SigningKey::from_bytes(&[7u8; 32]);
843        let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
844
845        // The rotation pivot: a fresh keypair whose public key the inner Direct authorizes and
846        // whose private key signs the outer rotation wrap.
847        let wrapping = SigningKey::from_bytes(&[9u8; 32]);
848        let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
849
850        let node_key = alloc::vec![5u8; 32];
851
852        let auth = Authority::from_state(
853            AumHash([0; 32]),
854            State {
855                keys: alloc::vec![Key {
856                    kind: KeyKind::Ed25519,
857                    votes: 1,
858                    public: trusted_pub.clone(),
859                }],
860            },
861        );
862
863        // Inner Direct: trusted key authorizes the wrapping pubkey. Verified ZIP-215, so a dalek
864        // signature is accepted.
865        let mut inner = NodeKeySignature {
866            sig_kind: SigKind::Direct,
867            pubkey: wrapping_pub.clone(),
868            key_id: trusted_pub.clone(),
869            signature: Vec::new(),
870            nested: None,
871            wrapping_pubkey: wrapping_pub.clone(),
872        };
873        let inner_hash = inner.sig_hash();
874        inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
875
876        // Outer Rotation: signs the node key with the wrapping key (verified STANDARD ed25519).
877        let mut outer = NodeKeySignature {
878            sig_kind: SigKind::Rotation,
879            pubkey: node_key.clone(),
880            key_id: Vec::new(),
881            signature: Vec::new(),
882            nested: Some(alloc::boxed::Box::new(inner)),
883            wrapping_pubkey: Vec::new(),
884        };
885        let outer_hash = outer.sig_hash();
886        outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
887
888        let cbor = outer.to_cbor(true).to_vec();
889        assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
890
891        // A tampered rotation-wrap signature must be rejected by the STANDARD ed25519 verifier.
892        let mut tampered = outer.clone();
893        let mut sb = tampered.signature.clone();
894        sb[0] ^= 0xff;
895        tampered.signature = sb;
896        let cbor_bad = tampered.to_cbor(true).to_vec();
897        assert_eq!(
898            auth.node_key_authorized(&node_key, &cbor_bad).unwrap_err(),
899            TkaError::BadSignature
900        );
901    }
902
903    // ----- Fix 2: nested-Credential pubkey must bind to the rotation pivot -----
904
905    #[test]
906    fn rotation_nested_credential_pubkey_bind() {
907        use ed25519_dalek::{Signer, SigningKey};
908
909        let trusted = SigningKey::from_bytes(&[11u8; 32]);
910        let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
911        let wrapping = SigningKey::from_bytes(&[13u8; 32]);
912        let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
913        let node_key = alloc::vec![6u8; 32];
914
915        let auth = Authority::from_state(
916            AumHash([0; 32]),
917            State {
918                keys: alloc::vec![Key {
919                    kind: KeyKind::Ed25519,
920                    votes: 1,
921                    public: trusted_pub.clone(),
922                }],
923            },
924        );
925
926        // Helper: build a rotation wrapping a nested Credential whose `pubkey` is `cred_pubkey`.
927        let build = |cred_pubkey: Vec<u8>| -> Vec<u8> {
928            let mut inner = NodeKeySignature {
929                sig_kind: SigKind::Credential,
930                pubkey: cred_pubkey,
931                key_id: trusted_pub.clone(),
932                signature: Vec::new(),
933                nested: None,
934                wrapping_pubkey: wrapping_pub.clone(),
935            };
936            let inner_hash = inner.sig_hash();
937            inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
938
939            let mut outer = NodeKeySignature {
940                sig_kind: SigKind::Rotation,
941                pubkey: node_key.clone(),
942                key_id: Vec::new(),
943                signature: Vec::new(),
944                nested: Some(alloc::boxed::Box::new(inner)),
945                wrapping_pubkey: Vec::new(),
946            };
947            let outer_hash = outer.sig_hash();
948            outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
949            outer.to_cbor(true).to_vec()
950        };
951
952        // Matching: credential covers exactly the wrapping pivot pubkey -> accepted.
953        let cbor_ok = build(wrapping_pub.clone());
954        assert!(auth.node_key_authorized(&node_key, &cbor_ok).is_ok());
955
956        // Mismatch: credential covers an unrelated pubkey -> rejected (NodeKeyMismatch), even though
957        // the credential is otherwise signed by the trusted key.
958        let cbor_bad = build(alloc::vec![0xaau8; 32]);
959        assert_eq!(
960            auth.node_key_authorized(&node_key, &cbor_bad).unwrap_err(),
961            TkaError::NodeKeyMismatch
962        );
963    }
964
965    // ----- CTAP2-CBOR byte-exactness FROZEN regression vector -----
966
967    /// A small hex helper for embedding captured bytes in a failure message.
968    fn hex(bytes: &[u8]) -> String {
969        let mut s = String::new();
970        for b in bytes {
971            s.push_str(&alloc::format!("{b:02x}"));
972        }
973        s
974    }
975
976    /// FROZEN CTAP2-CBOR byte-exactness vector for the wire/signing serialization.
977    ///
978    /// The crate docs (and the `cbor` module) state the CTAP2-canonical CBOR encoding is asserted by
979    /// construction but NOT cross-validated against Go's `fxamacker/cbor` (CTAP2 mode) in this fork.
980    /// The existing TKA tests build a signature, sign it, and verify round-trip — so they would all
981    /// still pass if the canonical encoding silently changed (int-map key ordering, smallest-int
982    /// rule, omitempty), because both sides of the round-trip use the same encoder. That class of
983    /// change would, however, break wire-compat with a live Go TKA.
984    ///
985    /// This pins the EXACT bytes for a fixed `NodeKeySignature` (Direct, deterministic key material):
986    /// the full `to_cbor(true)` serialization, the `to_cbor(false)` SigHash preimage, the resulting
987    /// `sig_hash` (BLAKE2s-256 of the preimage), and the `aum_hash` over the full serialization. ANY
988    /// accidental change to canonical-CBOR encoding or the BLAKE2s digest breaks this test.
989    ///
990    /// NOTE: this is a regression-FREEZE vector captured from the current encoder, NOT a Go-sourced
991    /// cross-vector. It should be replaced with a real `fxamacker/cbor` CTAP2 vector (the same
992    /// `NodeKeySignature` encoded by a live Go `tka`) once one can be captured.
993    #[test]
994    fn node_key_signature_cbor_frozen_vector() {
995        // Deterministic, fixed key material — NOT random. byte i = i, so the bytes are obvious.
996        let pubkey: Vec<u8> = (0u8..32).collect();
997        let key_id: Vec<u8> = (32u8..64).collect();
998        let signature: Vec<u8> = (64u8..128).collect();
999
1000        let sig = NodeKeySignature {
1001            sig_kind: SigKind::Direct,
1002            pubkey,
1003            key_id,
1004            signature,
1005            nested: None,
1006            wrapping_pubkey: Vec::new(), // empty -> omitted (omitempty), key 6 must NOT appear
1007        };
1008
1009        // 1. Full serialization (include_signature = true): keys 1,2,3,4 present, 5/6 omitted.
1010        let full = sig.to_cbor(true).to_vec();
1011        const EXPECTED_FULL: &[u8] = &[
1012            0xa4, 0x01, 0x01, 0x02, 0x58, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
1013            0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
1014            0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x03, 0x58, 0x20, 0x20,
1015            0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e,
1016            0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
1017            0x3d, 0x3e, 0x3f, 0x04, 0x58, 0x40, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47,
1018            0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55,
1019            0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, 0x61, 0x62, 0x63,
1020            0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71,
1021            0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f,
1022        ];
1023        assert_eq!(
1024            full,
1025            EXPECTED_FULL,
1026            "full CBOR serialization changed (canonical-CBOR encoding drift). actual: {}",
1027            hex(&full)
1028        );
1029
1030        // 2. SigHash preimage (include_signature = false): key 4 (signature) omitted.
1031        let preimage = sig.to_cbor(false).to_vec();
1032        const EXPECTED_PREIMAGE: &[u8] = &[
1033            0xa3, 0x01, 0x01, 0x02, 0x58, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
1034            0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
1035            0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x03, 0x58, 0x20, 0x20,
1036            0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e,
1037            0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
1038            0x3d, 0x3e, 0x3f,
1039        ];
1040        assert_eq!(
1041            preimage,
1042            EXPECTED_PREIMAGE,
1043            "SigHash preimage CBOR changed. actual: {}",
1044            hex(&preimage)
1045        );
1046
1047        // 3. sig_hash = BLAKE2s-256(preimage) — pinned.
1048        let sig_hash = sig.sig_hash();
1049        const EXPECTED_SIG_HASH: [u8; AUM_HASH_LEN] = [
1050            0x22, 0x6f, 0x9c, 0xbc, 0x63, 0x73, 0x92, 0x75, 0x2e, 0x0e, 0xb1, 0x32, 0x9c, 0xc4,
1051            0x99, 0x07, 0x01, 0x4a, 0xb6, 0x4f, 0x8e, 0x5d, 0x82, 0x85, 0xc2, 0x91, 0x42, 0x62,
1052            0xf6, 0xa6, 0xa8, 0x33,
1053        ];
1054        assert_eq!(
1055            sig_hash,
1056            EXPECTED_SIG_HASH,
1057            "sig_hash (BLAKE2s-256 of preimage) changed. actual: {}",
1058            hex(&sig_hash)
1059        );
1060
1061        // 4. aum_hash over the full serialization — pinned (exercises the public `aum_hash` helper
1062        //    + BLAKE2s digest over a frozen input).
1063        let aum = aum_hash(&full);
1064        const EXPECTED_AUM_HASH: [u8; AUM_HASH_LEN] = [
1065            0xa4, 0x40, 0x71, 0xa3, 0x7a, 0xbf, 0x80, 0x92, 0xd6, 0xff, 0x23, 0x84, 0xb2, 0xb0,
1066            0xa3, 0x50, 0xc7, 0xcb, 0x48, 0x41, 0xed, 0x68, 0x99, 0x62, 0x41, 0x7c, 0xd4, 0x23,
1067            0x68, 0xdc, 0x72, 0x49,
1068        ];
1069        assert_eq!(
1070            aum.0,
1071            EXPECTED_AUM_HASH,
1072            "aum_hash over full serialization changed. actual: {}",
1073            hex(&aum.0)
1074        );
1075    }
1076
1077    // ----- ed25519-speccheck KAT: dual-verifier (dalek std vs zebra ZIP-215) -----
1078
1079    /// Decode an ASCII hex string to bytes. Panics on malformed input (test-only).
1080    fn unhex(s: &str) -> Vec<u8> {
1081        assert!(s.len().is_multiple_of(2), "odd hex length");
1082        let nib = |c: u8| -> u8 {
1083            match c {
1084                b'0'..=b'9' => c - b'0',
1085                b'a'..=b'f' => c - b'a' + 10,
1086                b'A'..=b'F' => c - b'A' + 10,
1087                _ => panic!("bad hex nibble"),
1088            }
1089        };
1090        let b = s.as_bytes();
1091        let mut out = Vec::with_capacity(s.len() / 2);
1092        let mut i = 0;
1093        while i < b.len() {
1094            out.push((nib(b[i]) << 4) | nib(b[i + 1]));
1095            i += 2;
1096        }
1097        out
1098    }
1099
1100    /// The 12 adversarial Ed25519 vectors from `novifinancial/ed25519-speccheck`.
1101    ///
1102    /// Provenance: `cases.json` at commit `65519336fda78a3d016e947df6d82848aca0c9da`
1103    /// (<https://github.com/novifinancial/ed25519-speccheck/blob/main/cases.json>), the canonical
1104    /// generated vectors backing the "Taming the many EdDSAs" paper (IACR 2020/1244, Table 6c).
1105    /// The hex below is copied byte-for-byte from that file; the `message` field is itself hex
1106    /// (the speccheck driver hex-decodes it before verifying), so we decode it the same way.
1107    ///
1108    /// Tuple layout: `(message_hex, pubkey_hex, signature_hex)`.
1109    const SPECCHECK_VECTORS: [(&str, &str, &str); 12] = [
1110        // 0: S = 0; both A and R small-order.
1111        (
1112            "8c93255d71dcab10e8f379c26200f3c7bd5f09d9bc3068d3ef4edeb4853022b6",
1113            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
1114            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a0000000000000000000000000000000000000000000000000000000000000000",
1115        ),
1116        // 1: 0 < S < L; small A only.
1117        (
1118            "9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79",
1119            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
1120            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43a5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
1121        ),
1122        // 2: 0 < S < L; small R only.
1123        (
1124            "aebf3f2601a0c8c5d39cc7d8911642f740b78168218da8471772b35f9d35b9ab",
1125            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
1126            "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa8c4bd45aecaca5b24fb97bc10ac27ac8751a7dfe1baff8b953ec9f5833ca260e",
1127        ),
1128        // 3: A and R mixed-order; passes both (unless full-order checked).
1129        (
1130            "9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79",
1131            "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
1132            "9046a64750444938de19f227bb80485e92b83fdb4b6506c160484c016cc1852f87909e14428a7a1d62e9f22f3d3ad7802db02eb2e688b6c52fcd6648a98bd009",
1133        ),
1134        // 4: A and R mixed; passes cofactored, FAILS cofactorless — the cofactored discriminator.
1135        (
1136            "e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c",
1137            "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
1138            "160a1cb0dc9c0258cd0a7d23e94d8fa878bcb1925f2c64246b2dee1796bed5125ec6bc982a269b723e0668e540911a9a6a58921d6925e434ab10aa7940551a09",
1139        ),
1140        // 5: A mixed, R order L; "fails cofactored iff (8h) prereduced".
1141        (
1142            "e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c",
1143            "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
1144            "21122a84e0b5fca4052f5b1235c80a537878b38f3142356b2c2384ebad4668b7e40bc836dac0f71076f9abe3a53f9c03c1ceeeddb658d0030494ace586687405",
1145        ),
1146        // 6: S > L (out of bounds) — malleability vector; std verifier MUST reject.
1147        (
1148            "85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40",
1149            "442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623",
1150            "e96f66be976d82e60150baecff9906684aebb1ef181f67a7189ac78ea23b6c0e547f7690a0e2ddcd04d87dbc3490dc19b3b3052f7ff0538cb68afb369ba3a514",
1151        ),
1152        // 7: S >> L (no canonical serialization with null high bit) — std verifier MUST reject.
1153        (
1154            "85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40",
1155            "442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623",
1156            "8ce5b96c8f26d0ab6c47958c9e68b937104cd36e13c33566acd2fe8d38aa19427e71f98a473474f2f13f06f97c20d58cc3f54b8bd0d272f42b695dd7e89a8c22",
1157        ),
1158        // 8: 0 < S < L; non-canonical R, reduced for hash.
1159        (
1160            "9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41",
1161            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
1162            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff03be9678ac102edcd92b0210bb34d7428d12ffc5df5f37e359941266a4e35f0f",
1163        ),
1164        // 9: 0 < S < L; non-canonical R, NOT reduced for hash.
1165        (
1166            "9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41",
1167            "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
1168            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffca8c5b64cd208982aa38d4936621a4775aa233aa0505711d8fdcfdaa943d4908",
1169        ),
1170        // 10: 0 < S < L; non-canonical A, reduced for hash.
1171        (
1172            "e96b7021eb39c1a163b6da4e3093dcd3f21387da4cc4572be588fafae23c155b",
1173            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
1174            "a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
1175        ),
1176        // 11: 0 < S < L; non-canonical A, NOT reduced for hash.
1177        (
1178            "39a591f5321bbe07fd5a23dc2f39d025d74526615746727ceefd6e82ae65c06f",
1179            "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
1180            "a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
1181        ),
1182    ];
1183
1184    /// Known-answer test guarding the dual-verifier split that backs TKA consensus correctness.
1185    ///
1186    /// `verify_ed25519_std` wraps `ed25519-dalek 2.x` (standard RFC-8032-ish, cofactorless) and is
1187    /// used for SigRotation WRAPPING signatures. `verify_ed25519_zip215` wraps `ed25519-zebra 4.x`
1188    /// (ZIP-215 cofactored) and is used for Direct/Credential signatures to match Go
1189    /// `ed25519consensus`. If these two ever collapse to identical behavior, Go wire-compat for
1190    /// Tailnet-Lock silently breaks — this test proves they remain distinct on the adversarial set.
1191    ///
1192    /// The accept/reject matrix is asserted **as actually observed** from the pinned crate versions
1193    /// (`ed25519-dalek 2.2.0`, `ed25519-zebra 4.2.0`). These are newer than the versions tabulated
1194    /// in the "Taming the many EdDSAs" paper (Table 5: dalek 1.0.0-pre.4, zebra 2.1.1), so the
1195    /// non-canonical cases (8–11) may differ from the paper; we lock in current behavior as a
1196    /// regression guard. The SECURITY-CRITICAL invariants are NOT version-tunable: the standard
1197    /// verifier MUST reject the S >= L malleability vectors (6, 7), and the two verifiers MUST
1198    /// disagree on the cofactored discriminator (vector 4). Those are hard, separate assertions.
1199    #[test]
1200    fn ed25519_speccheck_dual_verifier_kat() {
1201        // Observed accept(true)/reject(false) matrix for the pinned crates, vectors 0..=11.
1202        // Anchored to the speccheck paper rows then corrected to what the crates actually do
1203        // (see the per-vector run below — any divergence makes the test fail loudly).
1204        //
1205        //                              0    1    2    3    4    5    6    7    8    9    10   11
1206        const STD_EXPECT: [bool; 12] = [
1207            true, true, true, true, false, false, false, false, false, false, false, true,
1208        ];
1209        const ZIP215_EXPECT: [bool; 12] = [
1210            true, true, true, true, true, true, false, false, false, true, true, true,
1211        ];
1212
1213        for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
1214            let msg = unhex(msg_hex);
1215            let pk = unhex(pk_hex);
1216            let sig = unhex(sig_hex);
1217            assert_eq!(pk.len(), 32, "vector {i}: pubkey not 32 bytes");
1218            assert_eq!(sig.len(), 64, "vector {i}: signature not 64 bytes");
1219
1220            let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
1221            let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
1222
1223            assert_eq!(
1224                std_ok, STD_EXPECT[i],
1225                "speccheck vector {i}: verify_ed25519_std accept={std_ok}, expected {}",
1226                STD_EXPECT[i]
1227            );
1228            assert_eq!(
1229                zip_ok, ZIP215_EXPECT[i],
1230                "speccheck vector {i}: verify_ed25519_zip215 accept={zip_ok}, expected {}",
1231                ZIP215_EXPECT[i]
1232            );
1233        }
1234
1235        // SECURITY-CRITICAL invariant (NOT version-tunable): the standard verifier must reject
1236        // signatures whose scalar S is out of range (S >= L). These are vectors 6 and 7 — the
1237        // EdDSA malleability guard. If either ACCEPTS, that is a real security finding.
1238        for &i in &[6usize, 7usize] {
1239            let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[i];
1240            let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
1241            assert!(
1242                verify_ed25519_std(&pk, &msg, &sig).is_err(),
1243                "SECURITY: verify_ed25519_std ACCEPTED S>=L malleability vector {i}"
1244            );
1245        }
1246
1247        // KEY DISCRIMINATOR (vector 4): cofactored (ZIP-215/zebra) accepts, cofactorless
1248        // (standard/dalek) rejects, on the SAME (pk, msg, sig). This proves the dual-verifier
1249        // split is real and not accidentally identical.
1250        {
1251            let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[4];
1252            let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
1253            assert!(
1254                verify_ed25519_zip215(&pk, &msg, &sig).is_ok(),
1255                "vector 4: ZIP-215 (zebra) should ACCEPT the cofactored discriminator"
1256            );
1257            assert!(
1258                verify_ed25519_std(&pk, &msg, &sig).is_err(),
1259                "vector 4: standard (dalek) should REJECT the cofactored discriminator"
1260            );
1261        }
1262    }
1263
1264    // ----- Cross-implementation KATs against real Go `tailscale.com/tka` v1.100.0 -----
1265
1266    /// Cross-implementation Known-Answer-Test: the CTAP2-CBOR serialization and BLAKE2s-256
1267    /// `SigHash` of three `NodeKeySignature` shapes must byte-match the REAL Go
1268    /// `tailscale.com/tka` package, version **v1.100.0** (toolchain **go1.26.4**).
1269    ///
1270    /// Provenance: the golden bytes below were produced by a Go generator that imports the real
1271    /// upstream `tailscale.com/tka` and calls `NodeKeySignature.Serialize()` (full CBOR including
1272    /// the signature field) and `NodeKeySignature.SigHash()` (BLAKE2s-256 of the CBOR with the
1273    /// `Signature` field nil'd). They are authoritative upstream output, NOT this fork's own
1274    /// encoder echoed back — this is the cross-validation the `node_key_signature_cbor_frozen_vector`
1275    /// freeze-test could not provide. The generator lives alongside the speccheck generator under
1276    /// `tests/vectors/gen` (Go module pinned to `tailscale.com v1.100.0`).
1277    ///
1278    /// Three shapes are covered: a `Direct` leaf, a `Credential` leaf (same fields, different
1279    /// `sigKind`), and a `Rotation` wrapping a nested `Direct` (the rotation-chain wire form). The
1280    /// int-map keys are 1=sigKind, 2=pubkey, 3=keyID, 4=signature, 5=nested, 6=wrappingPubkey;
1281    /// empty byte fields are omitted (`omitempty`).
1282    #[test]
1283    fn tka_cbor_matches_go_golden() {
1284        // Common fixed field material (real Go generator inputs).
1285        let pubkey32 = unhex("a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf");
1286        let key_id32 = unhex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f");
1287        let sig64 = unhex(
1288            "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
1289        );
1290        let wrap32 = unhex("101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f");
1291        let rot_sig64 = unhex(
1292            "55565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f9091929394",
1293        );
1294
1295        // GOLDEN 1 — Direct.
1296        {
1297            let sig = NodeKeySignature {
1298                sig_kind: SigKind::Direct,
1299                pubkey: pubkey32.clone(),
1300                key_id: key_id32.clone(),
1301                signature: sig64.clone(),
1302                nested: None,
1303                wrapping_pubkey: Vec::new(),
1304            };
1305            let full = sig.to_cbor(true).to_vec();
1306            let expected_full = unhex(
1307                "a40101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
1308            );
1309            assert_eq!(
1310                full,
1311                expected_full,
1312                "GOLDEN 1 (Direct) full CBOR diverged from Go tka v1.100.0. actual: {}",
1313                hex(&full)
1314            );
1315            let expected_hash =
1316                unhex("7e9653c97d35485b37b9bf942b1861cd2f3cb0663b5bb154f1178cca72101e74");
1317            assert_eq!(
1318                sig.sig_hash().as_slice(),
1319                expected_hash.as_slice(),
1320                "GOLDEN 1 (Direct) sig_hash diverged from Go tka v1.100.0. actual: {}",
1321                hex(&sig.sig_hash())
1322            );
1323        }
1324
1325        // GOLDEN 2 — Credential (same fields as Direct, sigKind=3).
1326        {
1327            let sig = NodeKeySignature {
1328                sig_kind: SigKind::Credential,
1329                pubkey: pubkey32.clone(),
1330                key_id: key_id32.clone(),
1331                signature: sig64.clone(),
1332                nested: None,
1333                wrapping_pubkey: Vec::new(),
1334            };
1335            let full = sig.to_cbor(true).to_vec();
1336            let expected_full = unhex(
1337                "a40103025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
1338            );
1339            assert_eq!(
1340                full,
1341                expected_full,
1342                "GOLDEN 2 (Credential) full CBOR diverged from Go tka v1.100.0. actual: {}",
1343                hex(&full)
1344            );
1345            let expected_hash =
1346                unhex("b6070ea8bc7ae8989ef4293f5031bedaa4a499803ade99f9e2f34dc2898ac03f");
1347            assert_eq!(
1348                sig.sig_hash().as_slice(),
1349                expected_hash.as_slice(),
1350                "GOLDEN 2 (Credential) sig_hash diverged from Go tka v1.100.0. actual: {}",
1351                hex(&sig.sig_hash())
1352            );
1353        }
1354
1355        // GOLDEN 3 — Rotation wrapping a nested Direct.
1356        //
1357        // Decoded from the authoritative Go bytes, the OUTER map is `a4` = 4 entries: keys
1358        // 1=sigKind(Rotation), 2=pubkey(wrap32), 4=signature(rotSig64), 5=nested. The outer has NO
1359        // key 6 (its `wrapping_pubkey` is EMPTY → omitted) and NO key 3 (its `key_id` is EMPTY →
1360        // omitted). The trailing `065820<wrap32>` in the hex belongs to the NESTED Direct map
1361        // (`a5` = 5 entries: keys 1,2,3,4,6), whose `wrapping_pubkey` IS set to wrap32. Constructing
1362        // the structs this way (outer wrapping_pubkey empty, nested wrapping_pubkey=wrap32)
1363        // reproduces the Go bytes exactly.
1364        {
1365            let nested = NodeKeySignature {
1366                sig_kind: SigKind::Direct,
1367                pubkey: pubkey32.clone(),
1368                key_id: key_id32.clone(),
1369                signature: sig64.clone(),
1370                nested: None,
1371                wrapping_pubkey: wrap32.clone(),
1372            };
1373            let sig = NodeKeySignature {
1374                sig_kind: SigKind::Rotation,
1375                pubkey: wrap32.clone(),
1376                key_id: Vec::new(),
1377                signature: rot_sig64.clone(),
1378                nested: Some(alloc::boxed::Box::new(nested)),
1379                wrapping_pubkey: Vec::new(),
1380            };
1381            let full = sig.to_cbor(true).to_vec();
1382            let expected_full = unhex(
1383                "a40102025820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f04584055565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939405a50101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf065820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f",
1384            );
1385            assert_eq!(
1386                full,
1387                expected_full,
1388                "GOLDEN 3 (Rotation) full CBOR diverged from Go tka v1.100.0. actual: {}",
1389                hex(&full)
1390            );
1391            let expected_hash =
1392                unhex("fac0a5a6781bb945369c28a0b3d3eea04e1648b60ec1a990a1ff68a9a566e6a7");
1393            assert_eq!(
1394                sig.sig_hash().as_slice(),
1395                expected_hash.as_slice(),
1396                "GOLDEN 3 (Rotation) sig_hash diverged from Go tka v1.100.0. actual: {}",
1397                hex(&sig.sig_hash())
1398            );
1399        }
1400    }
1401
1402    /// Cross-bind the dual Ed25519 verifier accept/reject matrix to the verdicts produced by the
1403    /// REAL Go implementations on the adversarial speccheck set (see [`SPECCHECK_VECTORS`]).
1404    ///
1405    /// Provenance of the Go verdicts: Go `crypto/ed25519.Verify` (standard, cofactorless) and
1406    /// `github.com/hdevalence/ed25519consensus v0.2.0` (ZIP-215, cofactored), toolchain
1407    /// **go1.26.4**, driven by the generator under `tests/vectors/gen/zip215`. These are the SAME
1408    /// verdicts the pinned Rust crates produce — proving `ed25519-dalek 2.x` == Go-std and
1409    /// `ed25519-zebra 4.x` == Go-`ed25519consensus` on the adversarial set. The arrays below MUST
1410    /// therefore equal `STD_EXPECT` / `ZIP215_EXPECT` asserted in
1411    /// `ed25519_speccheck_dual_verifier_kat`; this test additionally pins them to Go's behavior.
1412    ///
1413    /// NOTE: [`SPECCHECK_VECTORS`] is duplicated (byte-for-byte) in the Go generator at
1414    /// `tests/vectors/gen/zip215/main.go`. Both copies derive from the same upstream
1415    /// `cases.json` commit; if you edit one you MUST edit the other, or this proof would compare
1416    /// inputs the Go verdicts were never computed over.
1417    #[test]
1418    fn ed25519_dual_verifier_matches_go_verdicts() {
1419        //                                  0    1    2    3    4    5    6    7    8    9   10   11
1420        const GO_STD_ACCEPT: [bool; 12] = [
1421            true, true, true, true, false, false, false, false, false, false, false, true,
1422        ];
1423        const GO_ZIP215_ACCEPT: [bool; 12] = [
1424            true, true, true, true, true, true, false, false, false, true, true, true,
1425        ];
1426
1427        for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
1428            let msg = unhex(msg_hex);
1429            let pk = unhex(pk_hex);
1430            let sig = unhex(sig_hex);
1431
1432            let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
1433            let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
1434
1435            assert_eq!(
1436                std_ok, GO_STD_ACCEPT[i],
1437                "vector {i}: Rust verify_ed25519_std accept={std_ok} disagrees with Go \
1438                 crypto/ed25519.Verify={}",
1439                GO_STD_ACCEPT[i]
1440            );
1441            assert_eq!(
1442                zip_ok, GO_ZIP215_ACCEPT[i],
1443                "vector {i}: Rust verify_ed25519_zip215 accept={zip_ok} disagrees with Go \
1444                 ed25519consensus.Verify={}",
1445                GO_ZIP215_ACCEPT[i]
1446            );
1447        }
1448    }
1449}