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