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