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