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 /// Known-answer test guarding the dual-verifier split that backs TKA consensus correctness.
2989 ///
2990 /// `verify_ed25519_std` wraps `ed25519-dalek 2.x` (standard RFC-8032-ish, cofactorless) and is
2991 /// used for SigRotation WRAPPING signatures. `verify_ed25519_zip215` wraps `ed25519-zebra 4.x`
2992 /// (ZIP-215 cofactored) and is used for Direct/Credential signatures to match Go
2993 /// `ed25519consensus`. If these two ever collapse to identical behavior, Go wire-compat for
2994 /// Tailnet-Lock silently breaks — this test proves they remain distinct on the adversarial set.
2995 ///
2996 /// The accept/reject matrix is asserted **as actually observed** from the pinned crate versions
2997 /// (`ed25519-dalek 2.2.0`, `ed25519-zebra 4.2.0`). These are newer than the versions tabulated
2998 /// in the "Taming the many EdDSAs" paper (Table 5: dalek 1.0.0-pre.4, zebra 2.1.1), so the
2999 /// non-canonical cases (8–11) may differ from the paper; we lock in current behavior as a
3000 /// regression guard. The SECURITY-CRITICAL invariants are NOT version-tunable: the standard
3001 /// verifier MUST reject the S >= L malleability vectors (6, 7), and the two verifiers MUST
3002 /// disagree on the cofactored discriminator (vector 4). Those are hard, separate assertions.
3003 #[test]
3004 fn ed25519_speccheck_dual_verifier_kat() {
3005 // Observed accept(true)/reject(false) matrix for the pinned crates, vectors 0..=11.
3006 // Anchored to the speccheck paper rows then corrected to what the crates actually do
3007 // (see the per-vector run below — any divergence makes the test fail loudly).
3008 //
3009 // 0 1 2 3 4 5 6 7 8 9 10 11
3010 const STD_EXPECT: [bool; 12] = [
3011 true, true, true, true, false, false, false, false, false, false, false, true,
3012 ];
3013 const ZIP215_EXPECT: [bool; 12] = [
3014 true, true, true, true, true, true, false, false, false, true, true, true,
3015 ];
3016
3017 for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
3018 let msg = unhex(msg_hex);
3019 let pk = unhex(pk_hex);
3020 let sig = unhex(sig_hex);
3021 assert_eq!(pk.len(), 32, "vector {i}: pubkey not 32 bytes");
3022 assert_eq!(sig.len(), 64, "vector {i}: signature not 64 bytes");
3023
3024 let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
3025 let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
3026
3027 assert_eq!(
3028 std_ok, STD_EXPECT[i],
3029 "speccheck vector {i}: verify_ed25519_std accept={std_ok}, expected {}",
3030 STD_EXPECT[i]
3031 );
3032 assert_eq!(
3033 zip_ok, ZIP215_EXPECT[i],
3034 "speccheck vector {i}: verify_ed25519_zip215 accept={zip_ok}, expected {}",
3035 ZIP215_EXPECT[i]
3036 );
3037 }
3038
3039 // SECURITY-CRITICAL invariant (NOT version-tunable): the standard verifier must reject
3040 // signatures whose scalar S is out of range (S >= L). These are vectors 6 and 7 — the
3041 // EdDSA malleability guard. If either ACCEPTS, that is a real security finding.
3042 for &i in &[6usize, 7usize] {
3043 let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[i];
3044 let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
3045 assert!(
3046 verify_ed25519_std(&pk, &msg, &sig).is_err(),
3047 "SECURITY: verify_ed25519_std ACCEPTED S>=L malleability vector {i}"
3048 );
3049 }
3050
3051 // KEY DISCRIMINATOR (vector 4): cofactored (ZIP-215/zebra) accepts, cofactorless
3052 // (standard/dalek) rejects, on the SAME (pk, msg, sig). This proves the dual-verifier
3053 // split is real and not accidentally identical.
3054 {
3055 let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[4];
3056 let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
3057 assert!(
3058 verify_ed25519_zip215(&pk, &msg, &sig).is_ok(),
3059 "vector 4: ZIP-215 (zebra) should ACCEPT the cofactored discriminator"
3060 );
3061 assert!(
3062 verify_ed25519_std(&pk, &msg, &sig).is_err(),
3063 "vector 4: standard (dalek) should REJECT the cofactored discriminator"
3064 );
3065 }
3066 }
3067
3068 // ----- Cross-implementation KATs against real Go `tailscale.com/tka` v1.100.0 -----
3069
3070 /// Cross-implementation Known-Answer-Test: the CTAP2-CBOR serialization and BLAKE2s-256
3071 /// `SigHash` of three `NodeKeySignature` shapes must byte-match the REAL Go
3072 /// `tailscale.com/tka` package, version **v1.100.0** (toolchain **go1.26.4**).
3073 ///
3074 /// Provenance: the golden bytes below were produced by a Go generator that imports the real
3075 /// upstream `tailscale.com/tka` and calls `NodeKeySignature.Serialize()` (full CBOR including
3076 /// the signature field) and `NodeKeySignature.SigHash()` (BLAKE2s-256 of the CBOR with the
3077 /// `Signature` field nil'd). They are authoritative upstream output, NOT this fork's own
3078 /// encoder echoed back — this is the cross-validation the `node_key_signature_cbor_frozen_vector`
3079 /// freeze-test could not provide. The generator lives alongside the speccheck generator under
3080 /// `tests/vectors/gen` (Go module pinned to `tailscale.com v1.100.0`).
3081 ///
3082 /// Three shapes are covered: a `Direct` leaf, a `Credential` leaf (same fields, different
3083 /// `sigKind`), and a `Rotation` wrapping a nested `Direct` (the rotation-chain wire form). The
3084 /// int-map keys are 1=sigKind, 2=pubkey, 3=keyID, 4=signature, 5=nested, 6=wrappingPubkey;
3085 /// empty byte fields are omitted (`omitempty`).
3086 #[test]
3087 fn tka_cbor_matches_go_golden() {
3088 // Common fixed field material (real Go generator inputs).
3089 let pubkey32 = unhex("a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf");
3090 let key_id32 = unhex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f");
3091 let sig64 = unhex(
3092 "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
3093 );
3094 let wrap32 = unhex("101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f");
3095 let rot_sig64 = unhex(
3096 "55565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f9091929394",
3097 );
3098
3099 // GOLDEN 1 — Direct.
3100 {
3101 let sig = NodeKeySignature {
3102 sig_kind: SigKind::Direct,
3103 pubkey: pubkey32.clone(),
3104 key_id: key_id32.clone(),
3105 signature: sig64.clone(),
3106 nested: None,
3107 wrapping_pubkey: Vec::new(),
3108 };
3109 let full = sig.to_cbor(true).to_vec();
3110 let expected_full = unhex(
3111 "a40101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
3112 );
3113 assert_eq!(
3114 full,
3115 expected_full,
3116 "GOLDEN 1 (Direct) full CBOR diverged from Go tka v1.100.0. actual: {}",
3117 hex(&full)
3118 );
3119 let expected_hash =
3120 unhex("7e9653c97d35485b37b9bf942b1861cd2f3cb0663b5bb154f1178cca72101e74");
3121 assert_eq!(
3122 sig.sig_hash().as_slice(),
3123 expected_hash.as_slice(),
3124 "GOLDEN 1 (Direct) sig_hash diverged from Go tka v1.100.0. actual: {}",
3125 hex(&sig.sig_hash())
3126 );
3127 }
3128
3129 // GOLDEN 2 — Credential (same fields as Direct, sigKind=3).
3130 {
3131 let sig = NodeKeySignature {
3132 sig_kind: SigKind::Credential,
3133 pubkey: pubkey32.clone(),
3134 key_id: key_id32.clone(),
3135 signature: sig64.clone(),
3136 nested: None,
3137 wrapping_pubkey: Vec::new(),
3138 };
3139 let full = sig.to_cbor(true).to_vec();
3140 let expected_full = unhex(
3141 "a40103025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
3142 );
3143 assert_eq!(
3144 full,
3145 expected_full,
3146 "GOLDEN 2 (Credential) full CBOR diverged from Go tka v1.100.0. actual: {}",
3147 hex(&full)
3148 );
3149 let expected_hash =
3150 unhex("b6070ea8bc7ae8989ef4293f5031bedaa4a499803ade99f9e2f34dc2898ac03f");
3151 assert_eq!(
3152 sig.sig_hash().as_slice(),
3153 expected_hash.as_slice(),
3154 "GOLDEN 2 (Credential) sig_hash diverged from Go tka v1.100.0. actual: {}",
3155 hex(&sig.sig_hash())
3156 );
3157 }
3158
3159 // GOLDEN 3 — Rotation wrapping a nested Direct.
3160 //
3161 // Decoded from the authoritative Go bytes, the OUTER map is `a4` = 4 entries: keys
3162 // 1=sigKind(Rotation), 2=pubkey(wrap32), 4=signature(rotSig64), 5=nested. The outer has NO
3163 // key 6 (its `wrapping_pubkey` is EMPTY → omitted) and NO key 3 (its `key_id` is EMPTY →
3164 // omitted). The trailing `065820<wrap32>` in the hex belongs to the NESTED Direct map
3165 // (`a5` = 5 entries: keys 1,2,3,4,6), whose `wrapping_pubkey` IS set to wrap32. Constructing
3166 // the structs this way (outer wrapping_pubkey empty, nested wrapping_pubkey=wrap32)
3167 // reproduces the Go bytes exactly.
3168 {
3169 let nested = NodeKeySignature {
3170 sig_kind: SigKind::Direct,
3171 pubkey: pubkey32.clone(),
3172 key_id: key_id32.clone(),
3173 signature: sig64.clone(),
3174 nested: None,
3175 wrapping_pubkey: wrap32.clone(),
3176 };
3177 let sig = NodeKeySignature {
3178 sig_kind: SigKind::Rotation,
3179 pubkey: wrap32.clone(),
3180 key_id: Vec::new(),
3181 signature: rot_sig64.clone(),
3182 nested: Some(alloc::boxed::Box::new(nested)),
3183 wrapping_pubkey: Vec::new(),
3184 };
3185 let full = sig.to_cbor(true).to_vec();
3186 let expected_full = unhex(
3187 "a40102025820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f04584055565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939405a50101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf065820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f",
3188 );
3189 assert_eq!(
3190 full,
3191 expected_full,
3192 "GOLDEN 3 (Rotation) full CBOR diverged from Go tka v1.100.0. actual: {}",
3193 hex(&full)
3194 );
3195 let expected_hash =
3196 unhex("fac0a5a6781bb945369c28a0b3d3eea04e1648b60ec1a990a1ff68a9a566e6a7");
3197 assert_eq!(
3198 sig.sig_hash().as_slice(),
3199 expected_hash.as_slice(),
3200 "GOLDEN 3 (Rotation) sig_hash diverged from Go tka v1.100.0. actual: {}",
3201 hex(&sig.sig_hash())
3202 );
3203 }
3204 }
3205
3206 /// Cross-bind the dual Ed25519 verifier accept/reject matrix to the verdicts produced by the
3207 /// REAL Go implementations on the adversarial speccheck set (see [`SPECCHECK_VECTORS`]).
3208 ///
3209 /// Provenance of the Go verdicts: Go `crypto/ed25519.Verify` (standard, cofactorless) and
3210 /// `github.com/hdevalence/ed25519consensus v0.2.0` (ZIP-215, cofactored), toolchain
3211 /// **go1.26.4**, driven by the generator under `tests/vectors/gen/zip215`. These are the SAME
3212 /// verdicts the pinned Rust crates produce — proving `ed25519-dalek 2.x` == Go-std and
3213 /// `ed25519-zebra 4.x` == Go-`ed25519consensus` on the adversarial set. The arrays below MUST
3214 /// therefore equal `STD_EXPECT` / `ZIP215_EXPECT` asserted in
3215 /// `ed25519_speccheck_dual_verifier_kat`; this test additionally pins them to Go's behavior.
3216 ///
3217 /// NOTE: [`SPECCHECK_VECTORS`] is duplicated (byte-for-byte) in the Go generator at
3218 /// `tests/vectors/gen/zip215/main.go`. Both copies derive from the same upstream
3219 /// `cases.json` commit; if you edit one you MUST edit the other, or this proof would compare
3220 /// inputs the Go verdicts were never computed over.
3221 #[test]
3222 fn ed25519_dual_verifier_matches_go_verdicts() {
3223 // 0 1 2 3 4 5 6 7 8 9 10 11
3224 const GO_STD_ACCEPT: [bool; 12] = [
3225 true, true, true, true, false, false, false, false, false, false, false, true,
3226 ];
3227 const GO_ZIP215_ACCEPT: [bool; 12] = [
3228 true, true, true, true, true, true, false, false, false, true, true, true,
3229 ];
3230
3231 for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
3232 let msg = unhex(msg_hex);
3233 let pk = unhex(pk_hex);
3234 let sig = unhex(sig_hex);
3235
3236 let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
3237 let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
3238
3239 assert_eq!(
3240 std_ok, GO_STD_ACCEPT[i],
3241 "vector {i}: Rust verify_ed25519_std accept={std_ok} disagrees with Go \
3242 crypto/ed25519.Verify={}",
3243 GO_STD_ACCEPT[i]
3244 );
3245 assert_eq!(
3246 zip_ok, GO_ZIP215_ACCEPT[i],
3247 "vector {i}: Rust verify_ed25519_zip215 accept={zip_ok} disagrees with Go \
3248 ed25519consensus.Verify={}",
3249 GO_ZIP215_ACCEPT[i]
3250 );
3251 }
3252 }
3253
3254 /// Byte-exact cross-validation of [`Aum::serialize`] against the literal `[]byte` vectors in Go
3255 /// `tka/aum_test.go` `TestSerialization` (tailscale v1.100.0, fxamacker/cbor v2.9.2 CTAP2 mode).
3256 /// These are the authoritative oracle: if our CTAP2 CBOR diverges from Go by a single byte, the
3257 /// `AUM.Hash` chain links and every signature digest break. Each case reproduces the exact Go
3258 /// `AUM{…}` literal and asserts identical canonical bytes.
3259 #[test]
3260 fn aum_serialize_matches_go_test_serialization_vectors() {
3261 // AddKey: AUM{MessageKind: AUMAddKey, Key: &Key{}}. Go's *zero* Key{} has Kind=0
3262 // (KeyInvalid) and Public=nil, which our `AumKey` (always a valid KeyKind + Vec) cannot
3263 // model — that zero-Key encoding (`03 a3 01 00 02 00 03 f6`) is asserted directly at the
3264 // CBOR layer here, while the AUM keymap around it (map3, kind=AddKey, null prev, Key at
3265 // key 3) is covered by the structural assertions plus the three full vectors below.
3266 let add_key_inner_zero_key = cbor::Value::IntMap(alloc::vec![
3267 (1, cbor::Value::Uint(0)), // Kind = KeyInvalid(0)
3268 (2, cbor::Value::Uint(0)), // Votes = 0
3269 (3, cbor::Value::Null), // Public = nil -> null
3270 ]);
3271 assert_eq!(
3272 add_key_inner_zero_key.to_vec(),
3273 alloc::vec![0xa3, 0x01, 0x00, 0x02, 0x00, 0x03, 0xf6],
3274 "Go's zero Key{{}} encodes as map(3){{kind=0, votes=0, public=null}}"
3275 );
3276
3277 // RemoveKey: AUM{MessageKind: AUMRemoveKey, KeyID: []byte{1, 2}}
3278 let remove_key = Aum {
3279 message_kind: AumKind::RemoveKey,
3280 prev_aum_hash: None,
3281 key: None,
3282 key_id: alloc::vec![1, 2],
3283 state: None,
3284 votes: None,
3285 meta: Vec::new(),
3286 signatures: Vec::new(),
3287 };
3288 assert_eq!(
3289 remove_key.serialize(),
3290 // a3 (map3) 01 02 (kind=RemoveKey) 02 f6 (prev=null) 04 42 01 02 (KeyID=bytes{1,2})
3291 alloc::vec![0xa3, 0x01, 0x02, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02],
3292 "RemoveKey AUM serialization must match Go TestSerialization byte-for-byte"
3293 );
3294
3295 // UpdateKey: AUM{MessageKind: AUMUpdateKey, Votes: &uint(2), KeyID: []byte{1,2},
3296 // Meta: map[string]string{"a":"b"}}
3297 let update_key = Aum {
3298 message_kind: AumKind::UpdateKey,
3299 prev_aum_hash: None,
3300 key: None,
3301 key_id: alloc::vec![1, 2],
3302 state: None,
3303 votes: Some(2),
3304 meta: alloc::vec![("a".into(), "b".into())],
3305 signatures: Vec::new(),
3306 };
3307 assert_eq!(
3308 update_key.serialize(),
3309 // a5 (map5) 01 04 (UpdateKey) 02 f6 (prev null) 04 42 01 02 (KeyID) 06 02 (Votes=2)
3310 // 07 a1 61 61 61 62 (Meta = {"a":"b"}) — keys ascending 1,2,4,6,7
3311 alloc::vec![
3312 0xa5, 0x01, 0x04, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02, 0x06, 0x02, 0x07, 0xa1, 0x61,
3313 0x61, 0x61, 0x62
3314 ],
3315 "UpdateKey AUM serialization must match Go TestSerialization byte-for-byte"
3316 );
3317
3318 // Signature: AUM{MessageKind: AUMAddKey, Signatures: []tkatype.Signature{{KeyID: []byte{1}}}}
3319 let with_sig = Aum {
3320 message_kind: AumKind::AddKey,
3321 prev_aum_hash: None,
3322 key: None,
3323 key_id: Vec::new(),
3324 state: None,
3325 votes: None,
3326 meta: Vec::new(),
3327 signatures: alloc::vec![AumSignature {
3328 key_id: alloc::vec![1],
3329 signature: Vec::new(),
3330 }],
3331 };
3332 assert_eq!(
3333 with_sig.serialize(),
3334 // a3 (map3) 01 01 (AddKey) 02 f6 (prev null) 17 (key 23 = Signatures) 81 (array1)
3335 // a2 (map2) 01 41 01 (Signature.KeyID = bytes{1}) 02 f6 (Signature.Signature = null)
3336 alloc::vec![
3337 0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6
3338 ],
3339 "Signature AUM serialization must match Go TestSerialization (key 23 + nil sig = null)"
3340 );
3341
3342 // sig_hash must drop key 23 (Go SigHash nils Signatures → omitempty): the with_sig AUM's
3343 // sig_hash equals the BLAKE2s of the same AUM with no signatures.
3344 let no_sig = Aum {
3345 signatures: Vec::new(),
3346 ..with_sig.clone()
3347 };
3348 assert_eq!(
3349 with_sig.sig_hash(),
3350 blake2s_256(&no_sig.serialize()),
3351 "SigHash preimage must omit key 23 (Signatures), matching Go AUM.SigHash"
3352 );
3353 // And the full Hash differs from the SigHash (signatures are in the chain-link hash).
3354 assert_ne!(
3355 with_sig.hash().0,
3356 with_sig.sig_hash(),
3357 "Hash (incl. signatures) must differ from SigHash (excl.) when signatures are present"
3358 );
3359 }
3360
3361 /// Checkpoint AUM with an embedded `State`: exercises [`AumState`]/[`AumKey`] CBOR (the 32-byte
3362 /// `LastAUMHash` as a definite-length byte string, the `DisablementValues`/`Keys` arrays, and the
3363 /// `Key.Public` at key 3). Mirrors the structure of Go's `TestSerialization` Checkpoint case.
3364 #[test]
3365 fn aum_checkpoint_state_serialization() {
3366 let checkpoint = Aum {
3367 message_kind: AumKind::Checkpoint,
3368 prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
3369 key: None,
3370 key_id: Vec::new(),
3371 state: Some(AumState {
3372 last_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
3373 disablement_values: Some(Vec::new()),
3374 keys: Some(alloc::vec![AumKey {
3375 kind: KeyKind::Ed25519,
3376 votes: 1,
3377 public: alloc::vec![5, 6],
3378 meta: Vec::new(),
3379 }]),
3380 state_id1: 0,
3381 state_id2: 0,
3382 }),
3383 votes: None,
3384 meta: Vec::new(),
3385 signatures: Vec::new(),
3386 };
3387 let bytes = checkpoint.serialize();
3388 // Spot-check the structurally-load-bearing pieces (full-vector parity is covered by the
3389 // three exact vectors above; here we pin the State/Key encoding shape):
3390 // map3: key1=Checkpoint(5), key2=prev(32-byte bytestring 0x58 0x20 …), key5=State.
3391 assert_eq!(
3392 &bytes[0..3],
3393 &[0xa3, 0x01, 0x05],
3394 "map(3), MessageKind=Checkpoint(5)"
3395 );
3396 assert_eq!(
3397 &bytes[3..6],
3398 &[0x02, 0x58, 0x20],
3399 "key2 prev = 32-byte byte string head"
3400 );
3401 // The embedded State map (key 5) must contain: LastAUMHash (1) as 32-byte bytes, an empty
3402 // DisablementValues array (2 → 0x80), and a Keys array (3 → 0x81 with one Key map).
3403 // Locate the State map head (key 5) after the 32-byte prev hash: 3 + 3 + 32 = offset 38.
3404 assert_eq!(bytes[38], 0x05, "key 5 = State");
3405 // State is a map; its first entry is key1 (LastAUMHash) = 32-byte byte string.
3406 assert_eq!(
3407 &bytes[39..42],
3408 &[0xa3, 0x01, 0x58],
3409 "State map(3), key1 LastAUMHash bytes"
3410 );
3411 // The Key inside Keys carries Public={5,6} at key 3 (…03 42 05 06) and Votes=1 at key 2.
3412 let tail = &bytes[bytes.len() - 4..];
3413 assert_eq!(
3414 tail,
3415 &[0x03, 0x42, 0x05, 0x06],
3416 "Key.Public (key 3) = bytes{{5,6}}"
3417 );
3418 // Round-trips deterministically (hash is stable).
3419 assert_eq!(checkpoint.hash(), checkpoint.hash());
3420 }
3421
3422 // ---- AUM-chain replay (chunk 1B) -----------------------------------------------------------
3423
3424 /// A test trusted key from a seed byte (deterministic public key + given votes).
3425 fn test_aum_key(seed: u8, votes: u32) -> AumKey {
3426 use ed25519_dalek::SigningKey;
3427 let pubk = SigningKey::from_bytes(&[seed; 32])
3428 .verifying_key()
3429 .to_bytes()
3430 .to_vec();
3431 AumKey {
3432 kind: KeyKind::Ed25519,
3433 votes,
3434 public: pubk,
3435 meta: Vec::new(),
3436 }
3437 }
3438
3439 /// A genesis `AUMAddKey` (no parent) adding `key`.
3440 fn genesis_add(key: AumKey) -> Aum {
3441 Aum {
3442 message_kind: AumKind::AddKey,
3443 prev_aum_hash: None,
3444 key: Some(key),
3445 key_id: Vec::new(),
3446 state: None,
3447 votes: None,
3448 meta: Vec::new(),
3449 signatures: Vec::new(),
3450 }
3451 }
3452
3453 /// A child AUM of `parent` of the given kind, optionally carrying a key / key_id.
3454 fn child(parent: &Aum, kind: AumKind, key: Option<AumKey>, key_id: Vec<u8>) -> Aum {
3455 Aum {
3456 message_kind: kind,
3457 prev_aum_hash: Some(parent.hash()),
3458 key,
3459 key_id,
3460 state: None,
3461 votes: None,
3462 meta: Vec::new(),
3463 signatures: Vec::new(),
3464 }
3465 }
3466
3467 /// Linear replay applies each kind: genesis AddKey(k0), AddKey(k1), UpdateKey(k1 votes), then
3468 /// RemoveKey(k0). The final state has only k1 with its updated votes, and head = last AUM hash.
3469 #[test]
3470 fn replay_linear_chain_folds_all_kinds() {
3471 let k0 = test_aum_key(1, 1);
3472 let k1 = test_aum_key(2, 1);
3473
3474 let a0 = genesis_add(k0.clone());
3475 let a1 = child(&a0, AumKind::AddKey, Some(k1.clone()), Vec::new());
3476 let mut a2 = child(&a1, AumKind::UpdateKey, None, k1.public.clone());
3477 a2.votes = Some(5);
3478 let a3 = child(&a2, AumKind::RemoveKey, None, k0.public.clone());
3479
3480 let auth = Authority::from_chain(&[a0, a1, a2, a3.clone()]).unwrap();
3481
3482 // Only k1 remains, with the updated vote weight.
3483 assert_eq!(auth.state().keys.len(), 1, "k0 removed, k1 remains");
3484 let remaining = &auth.state().keys[0];
3485 assert_eq!(remaining.public, k1.public, "k1 is the surviving key");
3486 assert_eq!(remaining.votes, 5, "UpdateKey raised k1's votes to 5");
3487 // Head is the hash of the last applied AUM.
3488 assert_eq!(auth.head(), a3.hash(), "head = last AUM hash");
3489 }
3490
3491 /// A broken chain link (wrong `prev_aum_hash`) is rejected with `BadParent`.
3492 #[test]
3493 fn replay_rejects_broken_parent_link() {
3494 let k0 = test_aum_key(1, 1);
3495 let k1 = test_aum_key(2, 1);
3496 let a0 = genesis_add(k0);
3497 // a1 claims a bogus parent, not a0's hash.
3498 let mut a1 = child(&a0, AumKind::AddKey, Some(k1), Vec::new());
3499 a1.prev_aum_hash = Some(AumHash([0xab; 32]));
3500 assert_eq!(
3501 Authority::from_chain(&[a0, a1]).unwrap_err(),
3502 TkaError::BadParent
3503 );
3504 }
3505
3506 /// AddKey of an already-trusted key, and Remove/Update of an absent key, are rejected.
3507 #[test]
3508 fn replay_rejects_bad_key_state() {
3509 let k0 = test_aum_key(1, 1);
3510 let a0 = genesis_add(k0.clone());
3511 // Duplicate add of k0.
3512 let dup = child(&a0, AumKind::AddKey, Some(k0.clone()), Vec::new());
3513 assert_eq!(
3514 Authority::from_chain(&[a0.clone(), dup]).unwrap_err(),
3515 TkaError::BadKeyState
3516 );
3517 // Remove of a key that was never added.
3518 let absent = test_aum_key(9, 1);
3519 let rm = child(&a0, AumKind::RemoveKey, None, absent.public.clone());
3520 assert_eq!(
3521 Authority::from_chain(&[a0, rm]).unwrap_err(),
3522 TkaError::BadKeyState
3523 );
3524 }
3525
3526 /// An empty chain is rejected.
3527 #[test]
3528 fn replay_empty_chain_is_bad_chain() {
3529 assert_eq!(Authority::from_chain(&[]).unwrap_err(), TkaError::BadChain);
3530 }
3531
3532 /// `weight` sums the votes of distinct trusted signing keys: an unknown signer contributes 0, and
3533 /// a key that signs twice counts once (Go `TestAUMWeight` "Double use" → its votes, not double).
3534 #[test]
3535 fn replay_weight_dedups_and_ignores_unknown() {
3536 let k0 = test_aum_key(1, 2);
3537 let k1 = test_aum_key(2, 3);
3538 let state = ReplayState {
3539 keys: alloc::vec![k0.clone(), k1.clone()],
3540 last_aum_hash: None,
3541 state_id: None,
3542 };
3543
3544 // Empty signatures → 0.
3545 let mut aum = genesis_add(test_aum_key(5, 1));
3546 assert_eq!(state.weight(&aum), 0);
3547
3548 // One known signer (k0, votes 2).
3549 aum.signatures = alloc::vec![AumSignature {
3550 key_id: k0.public.clone(),
3551 signature: Vec::new()
3552 }];
3553 assert_eq!(state.weight(&aum), 2);
3554
3555 // Two distinct known signers → 2 + 3 = 5.
3556 aum.signatures = alloc::vec![
3557 AumSignature {
3558 key_id: k0.public.clone(),
3559 signature: Vec::new()
3560 },
3561 AumSignature {
3562 key_id: k1.public.clone(),
3563 signature: Vec::new()
3564 },
3565 ];
3566 assert_eq!(state.weight(&aum), 5);
3567
3568 // Double-use of k0 → counted once (2), not 4.
3569 aum.signatures = alloc::vec![
3570 AumSignature {
3571 key_id: k0.public.clone(),
3572 signature: Vec::new()
3573 },
3574 AumSignature {
3575 key_id: k0.public.clone(),
3576 signature: Vec::new()
3577 },
3578 ];
3579 assert_eq!(state.weight(&aum), 2, "a key signing twice counts once");
3580
3581 // Unknown signer → 0.
3582 aum.signatures = alloc::vec![AumSignature {
3583 key_id: alloc::vec![0xff; 32],
3584 signature: Vec::new()
3585 }];
3586 assert_eq!(
3587 state.weight(&aum),
3588 0,
3589 "an untrusted signing key contributes no weight"
3590 );
3591 }
3592
3593 /// `pick_next_aum` rule 3 (the deterministic tiebreak): with equal weight (0, no signatures) and
3594 /// neither a RemoveKey, the candidate with the lexicographically-lowest `Hash()` wins —
3595 /// regardless of input order, so two nodes select the same branch.
3596 #[test]
3597 fn pick_next_aum_lowest_hash_tiebreak_is_order_independent() {
3598 let k = test_aum_key(1, 1);
3599 let a0 = genesis_add(k);
3600 // Two distinct NoOp children of a0 (differ by key_id so their hashes differ).
3601 let c1 = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
3602 let c2 = child(&a0, AumKind::NoOp, None, alloc::vec![2]);
3603 let state = ReplayState::default();
3604
3605 let lower = if c1.hash().0 < c2.hash().0 {
3606 c1.hash()
3607 } else {
3608 c2.hash()
3609 };
3610 let ab = [c1.clone(), c2.clone()];
3611 let ba = [c2, c1];
3612 let pick_ab = pick_next_aum(&state, &ab).hash();
3613 let pick_ba = pick_next_aum(&state, &ba).hash();
3614 assert_eq!(pick_ab, lower, "lowest hash wins");
3615 assert_eq!(
3616 pick_ab, pick_ba,
3617 "selection is independent of candidate order"
3618 );
3619 }
3620
3621 /// `pick_next_aum` rule 1 (weight) dominates rule 3 (hash): a signed child with real weight beats
3622 /// an unsigned child even if the unsigned one has a lower hash.
3623 #[test]
3624 fn pick_next_aum_weight_beats_hash() {
3625 use ed25519_dalek::SigningKey;
3626 let signer_seed = 3u8;
3627 let signer_pub = SigningKey::from_bytes(&[signer_seed; 32])
3628 .verifying_key()
3629 .to_bytes()
3630 .to_vec();
3631 let state = ReplayState {
3632 keys: alloc::vec![AumKey {
3633 kind: KeyKind::Ed25519,
3634 votes: 4,
3635 public: signer_pub.clone(),
3636 meta: Vec::new(),
3637 }],
3638 last_aum_hash: None,
3639 state_id: None,
3640 };
3641
3642 let a0 = genesis_add(test_aum_key(1, 1));
3643 let unsigned = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
3644 let mut signed = child(&a0, AumKind::NoOp, None, alloc::vec![2]);
3645 signed.signatures = alloc::vec![AumSignature {
3646 key_id: signer_pub,
3647 signature: Vec::new(),
3648 }];
3649
3650 // The signed child wins on weight (4 > 0) no matter the hash order.
3651 let candidates = [unsigned.clone(), signed.clone()];
3652 let winner = pick_next_aum(&state, &candidates);
3653 assert_eq!(
3654 winner.hash(),
3655 signed.hash(),
3656 "higher weight wins over lower hash"
3657 );
3658 }
3659
3660 /// `from_forked_chain`: a shared genesis, then two competing RemoveKey vs NoOp branches at equal
3661 /// weight — rule 2 prefers the RemoveKey branch. The resulting state reflects the chosen branch.
3662 #[test]
3663 fn forked_chain_prefers_removekey_branch() {
3664 let k0 = test_aum_key(1, 1);
3665 let k1 = test_aum_key(2, 1);
3666 // Genesis adds both keys (two AUMs).
3667 let a0 = genesis_add(k0.clone());
3668 let a1 = child(&a0, AumKind::AddKey, Some(k1.clone()), Vec::new());
3669 // Fork at a1: branch A removes k0; branch B is a NoOp. Equal weight (0 sigs).
3670 let branch_remove = child(&a1, AumKind::RemoveKey, None, k0.public.clone());
3671 let branch_noop = child(&a1, AumKind::NoOp, None, alloc::vec![9]);
3672
3673 let noop_branch = [branch_noop.clone()];
3674 let remove_branch = [branch_remove.clone()];
3675 let auth = Authority::from_forked_chain(&[a0, a1], &[&noop_branch[..], &remove_branch[..]])
3676 .unwrap();
3677
3678 // RemoveKey branch wins → k0 gone, only k1 remains; head = the RemoveKey AUM.
3679 assert_eq!(auth.state().keys.len(), 1);
3680 assert_eq!(auth.state().keys[0].public, k1.public);
3681 assert_eq!(
3682 auth.head(),
3683 branch_remove.hash(),
3684 "active head = RemoveKey branch"
3685 );
3686 }
3687
3688 /// End-to-end: replay a chain to an `Authority`, then verify it authorizes a node key signed by a
3689 /// trusted key — proving the replayed state drives `node_key_authorized` identically to
3690 /// `from_state`. A key removed by the chain no longer authorizes.
3691 #[test]
3692 fn replayed_authority_authorizes_node_end_to_end() {
3693 use ed25519_dalek::{Signer, SigningKey};
3694
3695 let signing = SigningKey::from_bytes(&[77u8; 32]);
3696 let trusted_pub = signing.verifying_key().to_bytes().to_vec();
3697 let trusted = AumKey {
3698 kind: KeyKind::Ed25519,
3699 votes: 1,
3700 public: trusted_pub.clone(),
3701 meta: Vec::new(),
3702 };
3703 // A second key we'll add then remove, to show a removed key can't authorize.
3704 let revoked_signing = SigningKey::from_bytes(&[88u8; 32]);
3705 let revoked_pub = revoked_signing.verifying_key().to_bytes().to_vec();
3706 let revoked = AumKey {
3707 kind: KeyKind::Ed25519,
3708 votes: 1,
3709 public: revoked_pub.clone(),
3710 meta: Vec::new(),
3711 };
3712
3713 let a0 = genesis_add(trusted);
3714 let a1 = child(&a0, AumKind::AddKey, Some(revoked), Vec::new());
3715 let a2 = child(&a1, AumKind::RemoveKey, None, revoked_pub.clone());
3716 let auth = Authority::from_chain(&[a0, a1, a2]).unwrap();
3717
3718 let node_key = alloc::vec![7u8; 32];
3719 // Signature from the still-trusted key authorizes.
3720 let mut sig = NodeKeySignature {
3721 sig_kind: SigKind::Direct,
3722 pubkey: node_key.clone(),
3723 key_id: trusted_pub.clone(),
3724 signature: Vec::new(),
3725 nested: None,
3726 wrapping_pubkey: Vec::new(),
3727 };
3728 sig.signature = signing.sign(&sig.sig_hash()).to_bytes().to_vec();
3729 assert!(
3730 auth.node_key_authorized(&node_key, &sig.to_cbor(true).to_vec())
3731 .is_ok(),
3732 "the replayed authority must authorize a node signed by a still-trusted key"
3733 );
3734
3735 // The same node key signed by the REVOKED key must be rejected (key no longer in state).
3736 let mut bad = NodeKeySignature {
3737 sig_kind: SigKind::Direct,
3738 pubkey: node_key.clone(),
3739 key_id: revoked_pub.clone(),
3740 signature: Vec::new(),
3741 nested: None,
3742 wrapping_pubkey: Vec::new(),
3743 };
3744 bad.signature = revoked_signing.sign(&bad.sig_hash()).to_bytes().to_vec();
3745 assert_eq!(
3746 auth.node_key_authorized(&node_key, &bad.to_cbor(true).to_vec())
3747 .unwrap_err(),
3748 TkaError::UntrustedKey,
3749 "a key the chain removed must not authorize"
3750 );
3751 }
3752
3753 /// Genesis-kind guard (Go `computeStateAt` "invalid genesis update"): a chain whose first AUM is
3754 /// a `RemoveKey`/`UpdateKey` is rejected. (A genesis `NoOp`/`AddKey`/`Checkpoint` is allowed.)
3755 #[test]
3756 fn replay_rejects_invalid_genesis_kind() {
3757 // A bare RemoveKey as genesis: no key to remove → today this is BadKeyState, but the genesis
3758 // guard catches an UpdateKey before the key lookup. Use UpdateKey to exercise the guard arm.
3759 let mut g = genesis_add(test_aum_key(1, 1));
3760 g.message_kind = AumKind::UpdateKey;
3761 g.key = None;
3762 g.key_id = test_aum_key(1, 1).public.clone();
3763 assert_eq!(
3764 Authority::from_chain(&[g]).unwrap_err(),
3765 TkaError::BadChain,
3766 "an UpdateKey cannot be a genesis AUM"
3767 );
3768 }
3769
3770 /// Genesis must carry no parent: a first AUM with a non-None `prev_aum_hash` (i.e. a chain
3771 /// *suffix* mis-supplied as a whole chain) is rejected as `BadParent`, not silently re-rooted.
3772 #[test]
3773 fn replay_rejects_genesis_with_parent() {
3774 let mut g = genesis_add(test_aum_key(1, 1));
3775 g.prev_aum_hash = Some(AumHash([0x11; 32])); // names a parent not in the slice
3776 assert_eq!(
3777 Authority::from_chain(&[g]).unwrap_err(),
3778 TkaError::BadParent,
3779 "a genesis AUM that names a parent must be rejected (not treated as genesis)"
3780 );
3781 }
3782
3783 /// Checkpoint StateID guard (Go "checkpointed state has an incorrect stateID"): a genesis
3784 /// checkpoint seeds the StateID; a later checkpoint with a different StateID is rejected.
3785 #[test]
3786 fn replay_rejects_checkpoint_stateid_mismatch() {
3787 let k = test_aum_key(1, 1);
3788 // Genesis checkpoint seeds StateID (7, 0).
3789 let genesis = Aum {
3790 message_kind: AumKind::Checkpoint,
3791 prev_aum_hash: None,
3792 key: None,
3793 key_id: Vec::new(),
3794 state: Some(AumState {
3795 last_aum_hash: None,
3796 disablement_values: Some(Vec::new()),
3797 keys: Some(alloc::vec![k.clone()]),
3798 state_id1: 7,
3799 state_id2: 0,
3800 }),
3801 votes: None,
3802 meta: Vec::new(),
3803 signatures: Vec::new(),
3804 };
3805 // A second checkpoint, correctly chained, but with a FOREIGN StateID (8, 0).
3806 let bad = Aum {
3807 message_kind: AumKind::Checkpoint,
3808 prev_aum_hash: Some(genesis.hash()),
3809 key: None,
3810 key_id: Vec::new(),
3811 state: Some(AumState {
3812 last_aum_hash: Some(genesis.hash()),
3813 disablement_values: Some(Vec::new()),
3814 keys: Some(alloc::vec![k.clone()]),
3815 state_id1: 8, // ← mismatch
3816 state_id2: 0,
3817 }),
3818 votes: None,
3819 meta: Vec::new(),
3820 signatures: Vec::new(),
3821 };
3822 assert_eq!(
3823 Authority::from_chain(&[genesis.clone(), bad]).unwrap_err(),
3824 TkaError::BadKeyState,
3825 "a checkpoint with a foreign StateID belongs to another authority and must be rejected"
3826 );
3827 // A matching-StateID second checkpoint is accepted.
3828 let ok = Aum {
3829 message_kind: AumKind::Checkpoint,
3830 prev_aum_hash: Some(genesis.hash()),
3831 key: None,
3832 key_id: Vec::new(),
3833 state: Some(AumState {
3834 last_aum_hash: Some(genesis.hash()),
3835 disablement_values: Some(Vec::new()),
3836 keys: Some(alloc::vec![k]),
3837 state_id1: 7,
3838 state_id2: 0,
3839 }),
3840 votes: None,
3841 meta: Vec::new(),
3842 signatures: Vec::new(),
3843 };
3844 assert!(Authority::from_chain(&[genesis, ok]).is_ok());
3845 }
3846
3847 /// `from_forked_chain` rejects a multi-step branch rather than mis-resolving it (Go re-runs
3848 /// pickNextAUM per link; judging a whole branch by its first AUM could diverge).
3849 #[test]
3850 fn forked_chain_rejects_multistep_branch() {
3851 let k0 = test_aum_key(1, 1);
3852 let a0 = genesis_add(k0.clone());
3853 let b1 = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
3854 // A two-AUM branch (b1 → b2): must be rejected as BadChain.
3855 let b2 = child(&b1, AumKind::NoOp, None, alloc::vec![2]);
3856 let single = [child(&a0, AumKind::NoOp, None, alloc::vec![3])];
3857 let multi = [b1, b2];
3858 assert_eq!(
3859 Authority::from_forked_chain(&[a0], &[&single[..], &multi[..]]).unwrap_err(),
3860 TkaError::BadChain,
3861 "a multi-step branch must be rejected, not judged by its first AUM"
3862 );
3863 }
3864
3865 /// Cross-implementation Known-Answer-Test for the **AUM** type: [`Aum::serialize`],
3866 /// [`Aum::hash`] (Go `AUM.Hash`), and [`Aum::sig_hash`] (Go `AUM.SigHash`) must byte-match the
3867 /// REAL `tailscale.com/tka` package, version **v1.100.0** (toolchain **go1.26.3+**).
3868 ///
3869 /// Provenance: every golden below is authoritative upstream output produced by the Go generator
3870 /// at `tests/vectors/gen/tka/main.go` (which imports the real `tailscale.com/tka`, builds one
3871 /// `tka.AUM` per `MessageKind`, and dumps `AUM.Serialize()`/`AUM.Hash()`/`AUM.SigHash()` hex).
3872 /// The same values are committed for provenance at `tests/vectors/tka_aum_hash_golden.json`.
3873 /// This is the missing half of axis-B for AUM: the sibling
3874 /// [`aum_serialize_matches_go_test_serialization_vectors`] test pins Go's *Serialize()* literals
3875 /// from `tka/aum_test.go`, but no Go-produced *AUM.Hash()* digest was pinned until now — so an
3876 /// error in the BLAKE2s-over-canonical-CBOR digest (the value that links the whole chain and is
3877 /// signed) would have gone undetected. Here `hash`/`sig_hash` are pinned to Go directly.
3878 ///
3879 /// Covered kinds: AddKey (genesis, with a real Key25519 + meta), RemoveKey, UpdateKey
3880 /// (votes+meta), a signed AddKey (Signatures at CBOR key 23), and a Checkpoint with a populated
3881 /// `State`. The signed AUM additionally proves `hash() != sig_hash()` — i.e. `Hash()` covers the
3882 /// signatures and `SigHash()` excludes them, exactly as Go's `AUM.SigHash` nils `Signatures`
3883 /// before serializing.
3884 #[test]
3885 fn aum_hash_sighash_matches_go_golden() {
3886 // Deterministic field material — identical to the Go generator's inputs.
3887 let prev = AumHash({
3888 let mut a = [0u8; AUM_HASH_LEN];
3889 let mut i = 0;
3890 while i < AUM_HASH_LEN {
3891 a[i] = 0x20u8.wrapping_add(i as u8);
3892 i += 1;
3893 }
3894 a
3895 });
3896 let key_pub: Vec<u8> = (0..32u16).map(|i| 0x40u8.wrapping_add(i as u8)).collect();
3897 let key_pub2: Vec<u8> = (0..32u16).map(|i| 0x60u8.wrapping_add(i as u8)).collect();
3898 let sig_bytes: Vec<u8> = (0..64u16).map(|i| 0x80u8.wrapping_add(i as u8)).collect();
3899
3900 // Assert one AUM's serialize/hash/sig_hash against the authoritative Go hex.
3901 let check = |label: &str, aum: &Aum, ser_hex: &str, hash_hex: &str, sig_hash_hex: &str| {
3902 assert_eq!(
3903 hex(&aum.serialize()),
3904 ser_hex,
3905 "{label}: Aum::serialize diverged from Go tka v1.100.0"
3906 );
3907 assert_eq!(
3908 hex(&aum.hash().0),
3909 hash_hex,
3910 "{label}: Aum::hash (Go AUM.Hash) diverged from Go tka v1.100.0"
3911 );
3912 assert_eq!(
3913 hex(&aum.sig_hash()),
3914 sig_hash_hex,
3915 "{label}: Aum::sig_hash (Go AUM.SigHash) diverged from Go tka v1.100.0"
3916 );
3917 };
3918
3919 // (a) AddKey genesis (nil prev) with a real Key25519 + meta {"name":"alpha"}.
3920 let add_key = Aum {
3921 message_kind: AumKind::AddKey,
3922 prev_aum_hash: None,
3923 key: Some(AumKey {
3924 kind: KeyKind::Ed25519,
3925 votes: 7,
3926 public: key_pub.clone(),
3927 meta: alloc::vec![("name".into(), "alpha".into())],
3928 }),
3929 key_id: Vec::new(),
3930 state: None,
3931 votes: None,
3932 meta: Vec::new(),
3933 signatures: Vec::new(),
3934 };
3935 check(
3936 "AddKey",
3937 &add_key,
3938 "a3010102f603a401010207035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f0ca1646e616d6565616c706861",
3939 "921ca301077ae2b892ca8c40b3315e5f2a9ccc9ac99eec784e93b323577e1e14",
3940 "921ca301077ae2b892ca8c40b3315e5f2a9ccc9ac99eec784e93b323577e1e14",
3941 );
3942
3943 // (b) RemoveKey with a non-nil prev.
3944 let remove_key = Aum {
3945 message_kind: AumKind::RemoveKey,
3946 prev_aum_hash: Some(prev),
3947 key: None,
3948 key_id: key_pub.clone(),
3949 state: None,
3950 votes: None,
3951 meta: Vec::new(),
3952 signatures: Vec::new(),
3953 };
3954 check(
3955 "RemoveKey",
3956 &remove_key,
3957 "a30102025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f045820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
3958 "46be17c398760d2e649147b06a68e8576ee37c59cf0558046182f48ba20aa912",
3959 "46be17c398760d2e649147b06a68e8576ee37c59cf0558046182f48ba20aa912",
3960 );
3961
3962 // (c) UpdateKey with votes=2 + meta {"role":"ci"}.
3963 let update_key = Aum {
3964 message_kind: AumKind::UpdateKey,
3965 prev_aum_hash: Some(prev),
3966 key: None,
3967 key_id: key_pub.clone(),
3968 state: None,
3969 votes: Some(2),
3970 meta: alloc::vec![("role".into(), "ci".into())],
3971 signatures: Vec::new(),
3972 };
3973 check(
3974 "UpdateKey",
3975 &update_key,
3976 "a50104025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f045820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f060207a164726f6c65626369",
3977 "42d160d81a0922511b4ce60050dba76569f79423b6e721c5040faf414c250e43",
3978 "42d160d81a0922511b4ce60050dba76569f79423b6e721c5040faf414c250e43",
3979 );
3980
3981 // (e) AddKey carrying one Signature (CBOR key 23). hash (incl sigs) MUST differ from
3982 // sig_hash (excl sigs) — the property the whole signing scheme depends on.
3983 let signed = Aum {
3984 message_kind: AumKind::AddKey,
3985 prev_aum_hash: Some(prev),
3986 key: Some(AumKey {
3987 kind: KeyKind::Ed25519,
3988 votes: 1,
3989 public: key_pub.clone(),
3990 meta: Vec::new(),
3991 }),
3992 key_id: Vec::new(),
3993 state: None,
3994 votes: None,
3995 meta: Vec::new(),
3996 signatures: alloc::vec![AumSignature {
3997 key_id: key_pub2.clone(),
3998 signature: sig_bytes.clone(),
3999 }],
4000 };
4001 check(
4002 "AddKey+Signature",
4003 &signed,
4004 "a40101025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f03a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f1781a2015820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f025840808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
4005 "e70332d9a03b205577204f1896bb8dcb7c8f8894cc87a5b5c4d5dabcdf6ef135",
4006 "0a7a0ecdf854ad99e8728a1de89ac23c1f08457132a537a3add9594749a7f536",
4007 );
4008 assert_ne!(
4009 hex(&signed.hash().0),
4010 hex(&signed.sig_hash()),
4011 "Hash() must cover Signatures while SigHash() excludes them (Go AUM.SigHash nils them)"
4012 );
4013
4014 // (f) Checkpoint carrying a State with a POPULATED DisablementValues [{0xaa,0xbb}] + 1 key.
4015 // This is the common real shape and matches Go byte-for-byte (the array encoding is correct).
4016 let checkpoint = Aum {
4017 message_kind: AumKind::Checkpoint,
4018 prev_aum_hash: Some(prev),
4019 key: None,
4020 key_id: Vec::new(),
4021 state: Some(AumState {
4022 last_aum_hash: Some(prev),
4023 disablement_values: Some(alloc::vec![alloc::vec![0xaa, 0xbb]]),
4024 keys: Some(alloc::vec![AumKey {
4025 kind: KeyKind::Ed25519,
4026 votes: 1,
4027 public: key_pub.clone(),
4028 meta: Vec::new(),
4029 }]),
4030 state_id1: 0,
4031 state_id2: 0,
4032 }),
4033 votes: None,
4034 meta: Vec::new(),
4035 signatures: Vec::new(),
4036 };
4037 check(
4038 "Checkpoint(populated DisablementValues)",
4039 &checkpoint,
4040 "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f028142aabb0381a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
4041 "38c35c51580dcb75212d79c07695c8ec8b399b59ba552ea93f080b126fdaa0ae",
4042 "38c35c51580dcb75212d79c07695c8ec8b399b59ba552ea93f080b126fdaa0ae",
4043 );
4044 }
4045
4046 /// Go-match golden for the nil-`DisablementValues` checkpoint — the case that was a recorded
4047 /// interop bug (Rust forced an empty array `0x80` where Go emits CBOR null `0xf6`) and is now
4048 /// FIXED by making `AumState.{disablement_values,keys}` `Option` (None = Go nil = `0xf6`).
4049 ///
4050 /// When an `AUMCheckpoint`'s embedded `State` has a **nil** `DisablementValues` (Go's zero value,
4051 /// the overwhelmingly common case), Go's `fxamacker/cbor` CTAP2 encoder emits the field as
4052 /// **CBOR null `0xf6`**; a populated slice encodes as an array (proven by the populated case in
4053 /// [`aum_hash_sighash_matches_go_golden`]). This test pins the Go bytes + Hash for the nil case
4054 /// and asserts the Rust output now byte-matches — guarding the fix against regression.
4055 #[test]
4056 fn aum_checkpoint_nil_disablement_matches_go() {
4057 let prev = AumHash({
4058 let mut a = [0u8; AUM_HASH_LEN];
4059 let mut i = 0;
4060 while i < AUM_HASH_LEN {
4061 a[i] = 0x20u8.wrapping_add(i as u8);
4062 i += 1;
4063 }
4064 a
4065 });
4066 let key_pub: Vec<u8> = (0..32u16).map(|i| 0x40u8.wrapping_add(i as u8)).collect();
4067 let key_pub2: Vec<u8> = (0..32u16).map(|i| 0x60u8.wrapping_add(i as u8)).collect();
4068
4069 // Checkpoint with a State whose DisablementValues is EMPTY (== Go nil zero value), 2 keys.
4070 let checkpoint = Aum {
4071 message_kind: AumKind::Checkpoint,
4072 prev_aum_hash: Some(prev),
4073 key: None,
4074 key_id: Vec::new(),
4075 state: Some(AumState {
4076 last_aum_hash: Some(prev),
4077 disablement_values: Some(Vec::new()),
4078 keys: Some(alloc::vec![
4079 AumKey {
4080 kind: KeyKind::Ed25519,
4081 votes: 1,
4082 public: key_pub.clone(),
4083 meta: Vec::new(),
4084 },
4085 AumKey {
4086 kind: KeyKind::Ed25519,
4087 votes: 3,
4088 public: key_pub2.clone(),
4089 meta: alloc::vec![("k".into(), "v".into())],
4090 },
4091 ]),
4092 state_id1: 0,
4093 state_id2: 0,
4094 }),
4095 votes: None,
4096 meta: Vec::new(),
4097 signatures: Vec::new(),
4098 };
4099
4100 // Authoritative Go bytes (generator case "checkpoint: State w/ nil DisablementValues"):
4101 // the State map is `…02 f6 03 82 …` — a NIL DisablementValues encodes as CBOR null (0xf6).
4102 // FIXED: `AumState.disablement_values` is now `Option`, so the nil case (`None`) is
4103 // representable and encodes as null, byte-matching Go. (Was a recorded interop bug where the
4104 // `Vec` type forced an empty array `0x80` and diverged the checkpoint Hash from Go.)
4105 const GO_SERIALIZE: &str = "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f02f60382a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5fa401010203035820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f0ca1616b6176";
4106 const GO_HASH: &str = "cae17cc938c5a954cd4389d83c6afe4d3487edac38b94824bec3312b82f35710";
4107
4108 // Re-point the checkpoint's State to a genuinely-nil DisablementValues (`None`), which is the
4109 // case the Go golden above was generated from.
4110 let checkpoint = {
4111 let mut c = checkpoint;
4112 if let Some(state) = c.state.as_mut() {
4113 state.disablement_values = None;
4114 }
4115 c
4116 };
4117
4118 assert_eq!(
4119 hex(&checkpoint.serialize()),
4120 GO_SERIALIZE,
4121 "nil DisablementValues must encode as CBOR null (0xf6), byte-matching Go"
4122 );
4123 assert_eq!(
4124 hex(&checkpoint.hash().0),
4125 GO_HASH,
4126 "with the nil-vs-empty fix, the checkpoint chain-link Hash matches Go"
4127 );
4128 }
4129
4130 // =======================================================================================
4131 // MUST-1: AUM signature verification (`VerifiedAumChain` / Go `aumVerify`). The trust
4132 // boundary for a control-supplied chain — an AUM may advance the trusted-key state only if
4133 // every signature on it verifies against a key already trusted at its parent.
4134 // =======================================================================================
4135
4136 /// The signing key whose public key `test_aum_key(seed, _)` derives — so a key trusted via
4137 /// `test_aum_key(seed, v)` can be made to actually sign an AUM.
4138 fn signer_for(seed: u8) -> ed25519_dalek::SigningKey {
4139 ed25519_dalek::SigningKey::from_bytes(&[seed; 32])
4140 }
4141
4142 /// Sign `aum` with each `(seed)` signer, appending a real `AumSignature` over `aum.sig_hash()`.
4143 /// The signer's public key is `test_aum_key(seed, _).public`, so signing with `seed` produces a
4144 /// signature that a state trusting `test_aum_key(seed, _)` will accept.
4145 fn sign_aum(aum: &mut Aum, seeds: &[u8]) {
4146 use ed25519_dalek::Signer;
4147 let sig_hash = aum.sig_hash();
4148 for &seed in seeds {
4149 let signer = signer_for(seed);
4150 aum.signatures.push(AumSignature {
4151 key_id: signer.verifying_key().to_bytes().to_vec(),
4152 signature: signer.sign(&sig_hash).to_bytes().to_vec(),
4153 });
4154 }
4155 }
4156
4157 /// A genesis `AddKey` that adds `test_aum_key(seed, votes)` and is self-signed by that very key
4158 /// — the bootstrapping shape (Go verifies a genesis against the keys it itself establishes).
4159 fn signed_genesis_add(seed: u8, votes: u32) -> Aum {
4160 let mut g = genesis_add(test_aum_key(seed, votes));
4161 sign_aum(&mut g, &[seed]);
4162 g
4163 }
4164
4165 /// Happy path: a self-signed genesis followed by a child signed by the trusted genesis key
4166 /// verifies, and `from_verified_chain` yields the same state as the structural `from_chain`.
4167 #[test]
4168 fn verified_chain_accepts_properly_signed_chain() {
4169 let g = signed_genesis_add(1, 1);
4170 // Child adds a second key, signed by the trusted key from the genesis (seed 1).
4171 let mut a1 = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4172 sign_aum(&mut a1, &[1]);
4173
4174 let chain = [g.clone(), a1.clone()];
4175 let verified = VerifiedAumChain::verify(&chain).expect("a properly signed chain verifies");
4176 let auth = Authority::from_verified_chain(verified);
4177
4178 assert_eq!(auth.head(), a1.hash(), "head = last AUM");
4179 assert_eq!(auth.state().keys.len(), 2, "both keys trusted");
4180 // The verified-path state must equal the structural-path state for an authentic chain.
4181 let structural = Authority::from_chain(&chain).unwrap();
4182 assert_eq!(auth.state(), structural.state());
4183 assert_eq!(auth.head(), structural.head());
4184 }
4185
4186 /// An unsigned AUM (no signatures at all) is rejected — Go `aumVerify` "unsigned AUM". This
4187 /// holds even for the genesis.
4188 #[test]
4189 fn verified_chain_rejects_unsigned_aum() {
4190 // Unsigned genesis.
4191 let g = genesis_add(test_aum_key(1, 1));
4192 assert_eq!(
4193 VerifiedAumChain::verify(core::slice::from_ref(&g)).unwrap_err(),
4194 TkaError::UnsignedAum,
4195 "an unsigned genesis must be rejected"
4196 );
4197
4198 // Signed genesis, but an unsigned child.
4199 let sg = signed_genesis_add(1, 1);
4200 let a1 = child(&sg, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4201 assert_eq!(
4202 VerifiedAumChain::verify(&[sg, a1]).unwrap_err(),
4203 TkaError::UnsignedAum,
4204 "an unsigned non-genesis AUM must be rejected"
4205 );
4206 }
4207
4208 /// THE headline security property: a malicious control plane inserts an `AddKey` that adds the
4209 /// attacker's own key, signed by the attacker (a key NOT trusted in the current state). MUST-1
4210 /// rejects it as `UntrustedKey` — so the forged key never reaches a live `Authority`. Without
4211 /// the signature gate, `from_chain` would happily fold it (demonstrated) — which is exactly the
4212 /// tailnet-lock-defeating forgery the type-enforced `VerifiedAumChain` prevents.
4213 #[test]
4214 fn verified_chain_rejects_forged_addkey_from_untrusted_signer() {
4215 let g = signed_genesis_add(1, 1); // only key seed=1 is trusted
4216 // Attacker forges an AddKey inserting their own key (seed 9), signed by seed 9 (untrusted).
4217 let mut forged = child(&g, AumKind::AddKey, Some(test_aum_key(9, 99)), Vec::new());
4218 sign_aum(&mut forged, &[9]);
4219
4220 assert_eq!(
4221 VerifiedAumChain::verify(&[g.clone(), forged.clone()]).unwrap_err(),
4222 TkaError::UntrustedKey,
4223 "an AddKey signed only by an untrusted key must be rejected"
4224 );
4225 // Contrast: the structural-only `from_chain` (NOT a trust boundary) DOES fold the forgery,
4226 // proving why the type-enforced verified path is necessary.
4227 let structural = Authority::from_chain(&[g, forged]).unwrap();
4228 assert_eq!(
4229 structural.state().keys.len(),
4230 2,
4231 "structural from_chain folds the forged key — exactly why it is not a trust boundary"
4232 );
4233 }
4234
4235 /// A signature whose `key_id` IS trusted but whose bytes were produced over different content
4236 /// (here: signed by the wrong private key but labelled with the trusted key's id) fails the
4237 /// cryptographic check → `BadSignature`.
4238 #[test]
4239 fn verified_chain_rejects_tampered_signature() {
4240 let g = signed_genesis_add(1, 1);
4241 let mut a1 = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4242 // Label the signature with the trusted key's id (seed 1) but sign with the WRONG key.
4243 use ed25519_dalek::Signer;
4244 let wrong = signer_for(42);
4245 a1.signatures.push(AumSignature {
4246 key_id: signer_for(1).verifying_key().to_bytes().to_vec(),
4247 signature: wrong.sign(&a1.sig_hash()).to_bytes().to_vec(),
4248 });
4249 assert_eq!(
4250 VerifiedAumChain::verify(&[g, a1]).unwrap_err(),
4251 TkaError::BadSignature,
4252 "a signature that doesn't verify under the named trusted key is rejected"
4253 );
4254 }
4255
4256 /// Every signature must verify (Go loops over all, failing on the first bad one): a child with
4257 /// one valid trusted signature AND one bad/untrusted signature is still rejected.
4258 #[test]
4259 fn verified_chain_requires_all_signatures_valid() {
4260 let g = signed_genesis_add(1, 1);
4261 let mut a1 = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4262 // First a valid signature by the trusted key, then a second by an untrusted key.
4263 sign_aum(&mut a1, &[1]); // valid (seed 1 trusted)
4264 sign_aum(&mut a1, &[7]); // untrusted (seed 7 not in state)
4265 assert_eq!(
4266 VerifiedAumChain::verify(&[g, a1]).unwrap_err(),
4267 TkaError::UntrustedKey,
4268 "a single untrusted signature rejects the AUM even alongside a valid one"
4269 );
4270 }
4271
4272 /// A genesis `Checkpoint` self-certifies against the keys it embeds (Go
4273 /// `aumVerify(bootstrap, *bootstrap.State, true)`): the checkpoint's signature must verify
4274 /// against a key inside its own `State`. The embedded `State` must itself be Go-valid (≥1
4275 /// disablement value of 32 bytes, ≥1 key) — `static_validate_checkpoint` enforces that.
4276 #[test]
4277 fn verified_chain_genesis_checkpoint_self_certifies() {
4278 let trusted = test_aum_key(1, 1);
4279 let mut g = Aum {
4280 message_kind: AumKind::Checkpoint,
4281 prev_aum_hash: None,
4282 key: None,
4283 key_id: Vec::new(),
4284 state: Some(AumState {
4285 last_aum_hash: None,
4286 // A valid checkpoint needs ≥1 disablement value, each exactly 32 bytes.
4287 disablement_values: Some(alloc::vec![alloc::vec![0xD5u8; DISABLEMENT_LENGTH]]),
4288 keys: Some(alloc::vec![trusted.clone()]),
4289 state_id1: 0,
4290 state_id2: 0,
4291 }),
4292 votes: None,
4293 meta: Vec::new(),
4294 signatures: Vec::new(),
4295 };
4296 // Unsigned → rejected.
4297 assert_eq!(
4298 VerifiedAumChain::verify(&[g.clone()]).unwrap_err(),
4299 TkaError::UnsignedAum
4300 );
4301 // Signed by the key embedded in its own State → accepted.
4302 sign_aum(&mut g, &[1]);
4303 let verified = VerifiedAumChain::verify(&[g.clone()])
4304 .expect("a checkpoint signed by an embedded key self-certifies");
4305 let auth = Authority::from_verified_chain(verified);
4306 assert_eq!(auth.state().keys.len(), 1);
4307 assert_eq!(auth.head(), g.hash());
4308 }
4309
4310 /// A genesis `Checkpoint` whose embedded `State` is malformed is rejected by
4311 /// `static_validate_checkpoint` (Go `staticValidateCheckpoint`), before any signature check.
4312 #[test]
4313 fn verified_chain_rejects_malformed_checkpoint_state() {
4314 let trusted = test_aum_key(1, 1);
4315 let mk = |state: AumState| {
4316 let mut g = Aum {
4317 message_kind: AumKind::Checkpoint,
4318 prev_aum_hash: None,
4319 key: None,
4320 key_id: Vec::new(),
4321 state: Some(state),
4322 votes: None,
4323 meta: Vec::new(),
4324 signatures: Vec::new(),
4325 };
4326 sign_aum(&mut g, &[1]);
4327 g
4328 };
4329 let base = AumState {
4330 last_aum_hash: None,
4331 disablement_values: Some(alloc::vec![alloc::vec![0xD5u8; DISABLEMENT_LENGTH]]),
4332 keys: Some(alloc::vec![trusted.clone()]),
4333 state_id1: 0,
4334 state_id2: 0,
4335 };
4336
4337 // No disablement values → rejected.
4338 let no_disable = AumState {
4339 disablement_values: None,
4340 ..base.clone()
4341 };
4342 assert_eq!(
4343 VerifiedAumChain::verify(&[mk(no_disable)]).unwrap_err(),
4344 TkaError::BadKeyState,
4345 "a checkpoint with no disablement value is rejected"
4346 );
4347
4348 // Disablement value of the wrong length → rejected.
4349 let bad_len = AumState {
4350 disablement_values: Some(alloc::vec![alloc::vec![0u8; 16]]),
4351 ..base.clone()
4352 };
4353 assert_eq!(
4354 VerifiedAumChain::verify(&[mk(bad_len)]).unwrap_err(),
4355 TkaError::BadKeyState,
4356 "a disablement value of the wrong length is rejected"
4357 );
4358
4359 // No keys → rejected.
4360 let no_keys = AumState {
4361 keys: Some(Vec::new()),
4362 ..base.clone()
4363 };
4364 assert_eq!(
4365 VerifiedAumChain::verify(&[mk(no_keys)]).unwrap_err(),
4366 TkaError::BadKeyState,
4367 "a checkpoint with no keys is rejected"
4368 );
4369
4370 // Duplicate keys → rejected.
4371 let dup_keys = AumState {
4372 keys: Some(alloc::vec![trusted.clone(), trusted.clone()]),
4373 ..base.clone()
4374 };
4375 assert_eq!(
4376 VerifiedAumChain::verify(&[mk(dup_keys)]).unwrap_err(),
4377 TkaError::BadKeyState,
4378 "a checkpoint with duplicate key ids is rejected"
4379 );
4380
4381 // F5: a NON-adjacent duplicate ([a, b, a]) is also caught (the prefix-scan dedup checks all
4382 // earlier elements, not just the neighbor).
4383 let nonadjacent_dup = AumState {
4384 keys: Some(alloc::vec![
4385 test_aum_key(1, 1),
4386 test_aum_key(2, 1),
4387 test_aum_key(1, 1)
4388 ]),
4389 ..base.clone()
4390 };
4391 assert_eq!(
4392 VerifiedAumChain::verify(&[mk(nonadjacent_dup)]).unwrap_err(),
4393 TkaError::BadKeyState,
4394 "a non-adjacent duplicate key id is rejected"
4395 );
4396
4397 // F4: more than MAX_KEYS (512) keys → rejected. Use distinct 32-byte public keys (index-
4398 // encoded) so there are no duplicate ids — only the `> MAX_KEYS` cap can be the failure.
4399 let distinct_over_cap: alloc::vec::Vec<AumKey> = (0u32..=(MAX_KEYS as u32))
4400 .map(|i| AumKey {
4401 kind: KeyKind::Ed25519,
4402 votes: 1,
4403 // Distinct 32-byte public keys by encoding the index — no dup, so only the >512 cap trips.
4404 public: {
4405 let mut p = alloc::vec![0u8; 32];
4406 p[0..4].copy_from_slice(&i.to_le_bytes());
4407 p
4408 },
4409 meta: Vec::new(),
4410 })
4411 .collect();
4412 assert_eq!(distinct_over_cap.len(), MAX_KEYS + 1);
4413 let over_keys = AumState {
4414 keys: Some(distinct_over_cap),
4415 ..base.clone()
4416 };
4417 assert_eq!(
4418 VerifiedAumChain::verify(&[mk(over_keys)]).unwrap_err(),
4419 TkaError::BadKeyState,
4420 "a checkpoint with > MAX_KEYS keys is rejected"
4421 );
4422
4423 // F4: more than MAX_DISABLEMENT_VALUES (32) disablement values → rejected (each distinct).
4424 let over_disablements = AumState {
4425 disablement_values: Some(
4426 (0u8..=(MAX_DISABLEMENT_VALUES as u8))
4427 .map(|i| {
4428 let mut d = alloc::vec![0u8; DISABLEMENT_LENGTH];
4429 d[0] = i;
4430 d
4431 })
4432 .collect(),
4433 ),
4434 ..base
4435 };
4436 assert_eq!(
4437 VerifiedAumChain::verify(&[mk(over_disablements)]).unwrap_err(),
4438 TkaError::BadKeyState,
4439 "a checkpoint with > MAX_DISABLEMENT_VALUES disablement values is rejected"
4440 );
4441 }
4442
4443 /// A broken parent link is still caught on the verified path (the structural fold runs after the
4444 /// signature check for non-genesis AUMs).
4445 #[test]
4446 fn verified_chain_rejects_broken_parent_link() {
4447 let g = signed_genesis_add(1, 1);
4448 let mut orphan = child(&g, AumKind::NoOp, None, alloc::vec![9]);
4449 orphan.prev_aum_hash = Some(AumHash([0xAB; 32])); // wrong parent
4450 sign_aum(&mut orphan, &[1]); // validly signed, but mis-linked
4451 assert_eq!(
4452 VerifiedAumChain::verify(&[g, orphan]).unwrap_err(),
4453 TkaError::BadParent,
4454 "a validly-signed but mis-linked AUM is still rejected"
4455 );
4456 }
4457
4458 // ===== Aum::from_cbor — the decode inverse of Aum::serialize (issue #7 chunk 2, tsr-2dr) =====
4459
4460 /// `Aum::from_cbor(aum.serialize())` reconstructs the exact `Aum` for every message kind and
4461 /// optional-field combination. This is the core round-trip contract the sync/bootstrap path
4462 /// relies on: bytes control sends → `Aum` → verify/replay.
4463 #[test]
4464 fn aum_from_cbor_roundtrips_every_shape() {
4465 let cases: alloc::vec::Vec<(&str, Aum)> = alloc::vec![
4466 (
4467 "RemoveKey, genesis (null prev), key_id",
4468 Aum {
4469 message_kind: AumKind::RemoveKey,
4470 prev_aum_hash: None,
4471 key: None,
4472 key_id: alloc::vec![1, 2],
4473 state: None,
4474 votes: None,
4475 meta: Vec::new(),
4476 signatures: Vec::new(),
4477 },
4478 ),
4479 (
4480 "UpdateKey with votes + meta (text-keyed map)",
4481 Aum {
4482 message_kind: AumKind::UpdateKey,
4483 prev_aum_hash: None,
4484 key: None,
4485 key_id: alloc::vec![1, 2],
4486 state: None,
4487 votes: Some(2),
4488 meta: alloc::vec![("a".into(), "b".into())],
4489 signatures: Vec::new(),
4490 },
4491 ),
4492 (
4493 "AddKey with an embedded Key + non-null prev + signatures",
4494 Aum {
4495 message_kind: AumKind::AddKey,
4496 prev_aum_hash: Some(AumHash([0x11; AUM_HASH_LEN])),
4497 key: Some(AumKey {
4498 kind: KeyKind::Ed25519,
4499 votes: 3,
4500 public: alloc::vec![9, 8, 7],
4501 meta: alloc::vec![("k".into(), "v".into())],
4502 }),
4503 key_id: Vec::new(),
4504 state: None,
4505 votes: None,
4506 meta: Vec::new(),
4507 signatures: alloc::vec![
4508 AumSignature {
4509 key_id: alloc::vec![1],
4510 signature: Vec::new(), // nil → null on the wire
4511 },
4512 AumSignature {
4513 key_id: alloc::vec![2, 3],
4514 signature: alloc::vec![4, 5, 6],
4515 },
4516 ],
4517 },
4518 ),
4519 (
4520 "Checkpoint with full State (null + empty-array + populated arms)",
4521 Aum {
4522 message_kind: AumKind::Checkpoint,
4523 prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
4524 key: None,
4525 key_id: Vec::new(),
4526 state: Some(AumState {
4527 last_aum_hash: Some(AumHash([0xAB; AUM_HASH_LEN])),
4528 disablement_values: Some(alloc::vec![alloc::vec![1, 2], alloc::vec![3]]),
4529 keys: Some(alloc::vec![AumKey {
4530 kind: KeyKind::Ed25519,
4531 votes: 1,
4532 public: alloc::vec![5, 6],
4533 meta: Vec::new(),
4534 }]),
4535 state_id1: 7,
4536 state_id2: 0, // omitted (omitempty)
4537 }),
4538 votes: None,
4539 meta: Vec::new(),
4540 signatures: Vec::new(),
4541 },
4542 ),
4543 (
4544 "Checkpoint with nil State arms (null) and empty disablement array",
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: None, // null
4552 disablement_values: Some(Vec::new()), // empty array 0x80
4553 keys: None, // null
4554 state_id1: 0,
4555 state_id2: 9,
4556 }),
4557 votes: None,
4558 meta: Vec::new(),
4559 signatures: Vec::new(),
4560 },
4561 ),
4562 (
4563 "NoOp, non-null prev, nothing else",
4564 Aum {
4565 message_kind: AumKind::NoOp,
4566 prev_aum_hash: Some(AumHash([0x42; AUM_HASH_LEN])),
4567 key: None,
4568 key_id: Vec::new(),
4569 state: None,
4570 votes: None,
4571 meta: Vec::new(),
4572 signatures: Vec::new(),
4573 },
4574 ),
4575 ];
4576
4577 for (label, aum) in cases {
4578 let bytes = aum.serialize();
4579 let decoded = Aum::from_cbor(&bytes)
4580 .unwrap_or_else(|e| panic!("from_cbor failed for {label:?}: {e}"));
4581 assert_eq!(decoded, aum, "round-trip mismatch for {label:?}");
4582 // And the decoded AUM re-serializes to the identical bytes (canonical-form preserved →
4583 // hash/sig_hash are stable across a decode/encode cycle, which the chain replayer needs).
4584 assert_eq!(
4585 decoded.serialize(),
4586 bytes,
4587 "re-serialize must be byte-identical for {label:?}"
4588 );
4589 assert_eq!(
4590 decoded.hash(),
4591 aum.hash(),
4592 "hash must survive round-trip for {label:?}"
4593 );
4594 }
4595 }
4596
4597 /// Decode the exact frozen Go `TestSerialization` byte vectors (the same literals asserted on the
4598 /// encode side) straight into `Aum`s — proving the decoder consumes real Go-produced bytes, not
4599 /// just our own encoder's output.
4600 #[test]
4601 fn aum_from_cbor_decodes_frozen_go_vectors() {
4602 // RemoveKey: a3 01 02 02 f6 04 42 01 02
4603 let remove_key = Aum::from_cbor(&[0xa3, 0x01, 0x02, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02])
4604 .expect("decode RemoveKey vector");
4605 assert_eq!(remove_key.message_kind, AumKind::RemoveKey);
4606 assert_eq!(remove_key.prev_aum_hash, None);
4607 assert_eq!(remove_key.key_id, alloc::vec![1, 2]);
4608
4609 // UpdateKey: a5 01 04 02 f6 04 42 01 02 06 02 07 a1 61 61 61 62
4610 let update_key = Aum::from_cbor(&[
4611 0xa5, 0x01, 0x04, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02, 0x06, 0x02, 0x07, 0xa1, 0x61,
4612 0x61, 0x61, 0x62,
4613 ])
4614 .expect("decode UpdateKey vector");
4615 assert_eq!(update_key.message_kind, AumKind::UpdateKey);
4616 assert_eq!(update_key.votes, Some(2));
4617 assert_eq!(
4618 update_key.meta,
4619 alloc::vec![(
4620 alloc::string::String::from("a"),
4621 alloc::string::String::from("b")
4622 )],
4623 "the text-keyed Meta map must decode to {{\"a\":\"b\"}}"
4624 );
4625
4626 // Signature: a3 01 01 02 f6 17 81 a2 01 41 01 02 f6
4627 let with_sig = Aum::from_cbor(&[
4628 0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6,
4629 ])
4630 .expect("decode Signature vector");
4631 assert_eq!(with_sig.message_kind, AumKind::AddKey);
4632 assert_eq!(with_sig.signatures.len(), 1);
4633 assert_eq!(with_sig.signatures[0].key_id, alloc::vec![1]);
4634 assert_eq!(
4635 with_sig.signatures[0].signature,
4636 Vec::<u8>::new(),
4637 "the nil Signature (CBOR null) must decode to an empty Vec"
4638 );
4639 // Byte-exact re-encode of every frozen vector.
4640 assert_eq!(
4641 with_sig.serialize(),
4642 alloc::vec![
4643 0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6
4644 ]
4645 );
4646 }
4647
4648 /// The `null` (0xf6) major-7 arm is accepted ONLY for null; other major-7 simple/float values
4649 /// are still rejected (fail-closed), and the `NodeKeySignature` path is unaffected because its
4650 /// `expect_bytes` rejects null where bytes are required.
4651 #[test]
4652 fn decode_value_accepts_only_null_in_major7() {
4653 // Bare null decodes.
4654 assert_eq!(decode_value(&[0xf6], 0).unwrap().0, Value::Null);
4655 // true (0xf5), false (0xf4), undefined (0xf7), a float64 (0xfb …) → rejected.
4656 for bad in [
4657 alloc::vec![0xf5u8],
4658 alloc::vec![0xf4],
4659 alloc::vec![0xf7],
4660 alloc::vec![0xfb, 0x40, 0x09, 0x21, 0xfb, 0x54, 0x44, 0x2d, 0x18],
4661 ] {
4662 assert!(
4663 decode_value(&bad, 0).is_err(),
4664 "major-7 value {bad:02x?} other than null must be rejected"
4665 );
4666 }
4667 }
4668
4669 /// `Aum::from_cbor` fails closed on malformed / adversarial input — never panics, never `Ok` on
4670 /// garbage. Complements the `cbor_decode_smoke` integration test (which targets the signature
4671 /// path) for the AUM path.
4672 #[test]
4673 fn aum_from_cbor_fails_closed() {
4674 // Empty input.
4675 assert!(Aum::from_cbor(&[]).is_err());
4676 // Not a map (a bare uint).
4677 assert!(Aum::from_cbor(&[0x00]).is_err());
4678 // Map missing the non-omitempty prev_aum_hash (only message_kind present): a1 01 03.
4679 assert!(
4680 Aum::from_cbor(&[0xa1, 0x01, 0x03]).is_err(),
4681 "an AUM without key 2 (prev_aum_hash) must be rejected (non-omitempty)"
4682 );
4683 // Unknown field key (99): a2 01 03 18 63 00.
4684 assert!(
4685 Aum::from_cbor(&[0xa2, 0x01, 0x03, 0x18, 0x63, 0x00]).is_err(),
4686 "an unknown AUM field key must be rejected"
4687 );
4688 // Unknown message kind (9): a2 01 09 02 f6.
4689 assert!(
4690 Aum::from_cbor(&[0xa2, 0x01, 0x09, 0x02, 0xf6]).is_err(),
4691 "an unknown message_kind must be rejected"
4692 );
4693 // Trailing byte after a complete AUM: (a2 01 03 02 f6) + 00.
4694 assert!(
4695 Aum::from_cbor(&[0xa2, 0x01, 0x03, 0x02, 0xf6, 0x00]).is_err(),
4696 "trailing bytes after the AUM must be rejected"
4697 );
4698 // prev_aum_hash present but wrong length (31 bytes) → rejected.
4699 let mut short_prev = alloc::vec![0xa2u8, 0x01, 0x03, 0x02, 0x58, 0x1f];
4700 short_prev.extend(core::iter::repeat_n(0u8, 31));
4701 assert!(
4702 Aum::from_cbor(&short_prev).is_err(),
4703 "a prev_aum_hash that is not 32 bytes must be rejected"
4704 );
4705 }
4706
4707 /// A text-keyed map (`Meta`) and an int-keyed map are distinguished on decode, and a mixed-key
4708 /// map is rejected (TKA emits no mixed-key maps).
4709 #[test]
4710 fn decode_map_rejects_mixed_key_types() {
4711 // map(2){ 1: 0, "a": "b" } — int key then text key. a2 01 00 61 61 61 62
4712 assert!(
4713 decode_value(&[0xa2, 0x01, 0x00, 0x61, 0x61, 0x61, 0x62], 0).is_err(),
4714 "a map mixing uint and text keys must be rejected"
4715 );
4716 // A pure text map decodes to TextMap.
4717 let (v, rest) = decode_value(&[0xa1, 0x61, 0x61, 0x61, 0x62], 0).unwrap();
4718 assert!(rest.is_empty());
4719 assert_eq!(
4720 v,
4721 Value::TextMap(alloc::vec![(b"a".to_vec(), Value::Text(b"b".to_vec()))])
4722 );
4723 }
4724
4725 // ===== Review follow-ups (PR #48 review): close decode coverage gaps =====
4726
4727 /// Gap 2 (highest value): decode the authoritative frozen **Go checkpoint** bytes — the most
4728 /// complex AUM shape (null `disablement_values` arm, two nested keys, the second carrying a
4729 /// `Meta`, 32-byte hashes). The encode side asserts these exact bytes
4730 /// (`aum_checkpoint_nil_disablement_matches_go`); here we prove the *decoder* consumes them and
4731 /// round-trips byte-identically (so `hash()` is stable), exercising `AumState::from_value` +
4732 /// nested `AumKey::from_value` against real Go output rather than our own encoder.
4733 #[test]
4734 fn aum_from_cbor_decodes_frozen_go_checkpoint() {
4735 const GO_SERIALIZE: &str = "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f02f60382a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5fa401010203035820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f0ca1616b6176";
4736 const GO_HASH: &str = "cae17cc938c5a954cd4389d83c6afe4d3487edac38b94824bec3312b82f35710";
4737 let bytes = unhex(GO_SERIALIZE);
4738
4739 let aum = Aum::from_cbor(&bytes).expect("decode the frozen Go checkpoint");
4740 assert_eq!(aum.message_kind, AumKind::Checkpoint);
4741 let st = aum.state.as_ref().expect("checkpoint carries a State");
4742 assert_eq!(
4743 st.disablement_values, None,
4744 "nil DisablementValues → the null arm → None"
4745 );
4746 let keys = st.keys.as_ref().expect("State has keys");
4747 assert_eq!(keys.len(), 2, "two nested keys");
4748 assert_eq!(keys[0].votes, 1);
4749 assert_eq!(keys[1].votes, 3);
4750 assert_eq!(
4751 keys[1].meta,
4752 alloc::vec![(
4753 alloc::string::String::from("k"),
4754 alloc::string::String::from("v")
4755 )],
4756 "the second nested key carries Meta {{\"k\":\"v\"}}"
4757 );
4758 // Byte-exact re-encode → the chain-link Hash matches Go's golden hash.
4759 assert_eq!(
4760 aum.serialize(),
4761 bytes,
4762 "re-serialize must be byte-identical to the Go bytes"
4763 );
4764 assert_eq!(
4765 hex(&aum.hash().0),
4766 GO_HASH,
4767 "decoded checkpoint's Hash matches Go golden"
4768 );
4769 }
4770
4771 /// Gap 1: round-trip the field combinations the original cases missed — multi-entry `Meta`
4772 /// (canonical-ordering), both `state_id`s non-zero (key-4/key-5 routing), `votes` at the u32
4773 /// boundary, a both-empty (`null`/`null`) `AumSignature`, and `key`+`key_id`+`signatures`
4774 /// coexisting (key 3/4/23 cross-talk).
4775 #[test]
4776 fn aum_from_cbor_roundtrips_review_gap_shapes() {
4777 let cases: alloc::vec::Vec<(&str, Aum)> = alloc::vec![
4778 (
4779 "multi-entry meta, pre-sorted (serialize() canonicalises key order)",
4780 Aum {
4781 message_kind: AumKind::UpdateKey,
4782 prev_aum_hash: None,
4783 key: None,
4784 key_id: alloc::vec![1],
4785 state: None,
4786 votes: Some(1),
4787 // Pre-sorted: `serialize()` emits TextMap keys in CTAP2 order, so the decoded
4788 // meta is sorted; supplying sorted input keeps the `==` round-trip exact.
4789 meta: alloc::vec![
4790 ("a".into(), "2".into()),
4791 ("mid".into(), "3".into()),
4792 ("zebra".into(), "1".into()),
4793 ],
4794 signatures: Vec::new(),
4795 },
4796 ),
4797 (
4798 "both state_ids non-zero (key 4 and key 5 must not be swapped)",
4799 Aum {
4800 message_kind: AumKind::Checkpoint,
4801 prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
4802 key: None,
4803 key_id: Vec::new(),
4804 state: Some(AumState {
4805 last_aum_hash: None,
4806 disablement_values: None,
4807 keys: Some(Vec::new()),
4808 state_id1: 7,
4809 state_id2: 9,
4810 }),
4811 votes: None,
4812 meta: Vec::new(),
4813 signatures: Vec::new(),
4814 },
4815 ),
4816 (
4817 "votes at u32::MAX + AddKey with key.votes at u32::MAX and multi-meta",
4818 Aum {
4819 message_kind: AumKind::AddKey,
4820 prev_aum_hash: Some(AumHash([0x33; AUM_HASH_LEN])),
4821 key: Some(AumKey {
4822 kind: KeyKind::Ed25519,
4823 votes: u32::MAX,
4824 public: alloc::vec![1, 2, 3],
4825 meta: alloc::vec![("a".into(), "x".into()), ("b".into(), "y".into())],
4826 }),
4827 key_id: Vec::new(),
4828 state: None,
4829 votes: None,
4830 meta: Vec::new(),
4831 signatures: Vec::new(),
4832 },
4833 ),
4834 (
4835 "both-empty AumSignature (key_id null AND signature null)",
4836 Aum {
4837 message_kind: AumKind::AddKey,
4838 prev_aum_hash: None,
4839 key: None,
4840 key_id: Vec::new(),
4841 state: None,
4842 votes: None,
4843 meta: Vec::new(),
4844 signatures: alloc::vec![AumSignature {
4845 key_id: Vec::new(),
4846 signature: Vec::new(),
4847 }],
4848 },
4849 ),
4850 (
4851 "key + key_id + signatures coexisting (keys 3, 4, 23)",
4852 Aum {
4853 message_kind: AumKind::AddKey,
4854 prev_aum_hash: Some(AumHash([0x55; AUM_HASH_LEN])),
4855 key: Some(AumKey {
4856 kind: KeyKind::Ed25519,
4857 votes: 2,
4858 public: alloc::vec![7, 7, 7],
4859 meta: Vec::new(),
4860 }),
4861 key_id: alloc::vec![9, 9],
4862 state: None,
4863 votes: None,
4864 meta: Vec::new(),
4865 signatures: alloc::vec![AumSignature {
4866 key_id: alloc::vec![1],
4867 signature: alloc::vec![2, 3, 4],
4868 }],
4869 },
4870 ),
4871 (
4872 "votes = 0 (boundary; Some(0) must survive, distinct from None)",
4873 Aum {
4874 message_kind: AumKind::UpdateKey,
4875 prev_aum_hash: None,
4876 key: None,
4877 key_id: alloc::vec![1],
4878 state: None,
4879 votes: Some(0),
4880 meta: Vec::new(),
4881 signatures: Vec::new(),
4882 },
4883 ),
4884 ];
4885 for (label, aum) in cases {
4886 let bytes = aum.serialize();
4887 let decoded = Aum::from_cbor(&bytes)
4888 .unwrap_or_else(|e| panic!("from_cbor failed for {label:?}: {e}"));
4889 assert_eq!(decoded, aum, "round-trip mismatch for {label:?}");
4890 assert_eq!(
4891 decoded.serialize(),
4892 bytes,
4893 "re-serialize differs for {label:?}"
4894 );
4895 }
4896 }
4897
4898 /// Gap 3: additional fail-closed guards on the AUM entry point — truncated map (count > entries),
4899 /// a duplicate key at the AUM level, votes > u32::MAX, an unsupported key kind, and a malformed
4900 /// (non-map) `state` value. Each must `Err`, never panic, never `Ok`.
4901 #[test]
4902 fn aum_from_cbor_fails_closed_review_gaps() {
4903 // Truncated map: header claims 3 pairs, only 2 present then EOF.
4904 assert!(
4905 Aum::from_cbor(&[0xa3, 0x01, 0x03, 0x02, 0xf6]).is_err(),
4906 "a map claiming more pairs than present must be rejected"
4907 );
4908 // Duplicate key at the AUM level: key 1 appears twice (a3 01 03 02 f6 01 04).
4909 assert!(
4910 Aum::from_cbor(&[0xa3, 0x01, 0x03, 0x02, 0xf6, 0x01, 0x04]).is_err(),
4911 "a duplicate AUM map key must be rejected"
4912 );
4913 // votes > u32::MAX: 06 1b 0000_0001_0000_0000 (= 2^32).
4914 assert!(
4915 Aum::from_cbor(&[
4916 0xa3, 0x01, 0x04, 0x02, 0xf6, 0x06, 0x1b, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
4917 0x00,
4918 ])
4919 .is_err(),
4920 "votes above u32::MAX must be rejected (fail-closed narrowing)"
4921 );
4922 // Unsupported key kind: AddKey embedding a key with kind=2. a3 01 01 02 f6 03 a3 01 02 02 01 03 41 09
4923 assert!(
4924 Aum::from_cbor(&[
4925 0xa3, 0x01, 0x01, 0x02, 0xf6, 0x03, 0xa3, 0x01, 0x02, 0x02, 0x01, 0x03, 0x41, 0x09,
4926 ])
4927 .is_err(),
4928 "an unsupported key kind must be rejected (not silently treated as Ed25519)"
4929 );
4930 // Malformed state: key 5 is a uint, not a map. a2 01 05 05 00 — but prev (key 2) missing too;
4931 // use a3 with prev null: a3 01 05 02 f6 05 00.
4932 assert!(
4933 Aum::from_cbor(&[0xa3, 0x01, 0x05, 0x02, 0xf6, 0x05, 0x00]).is_err(),
4934 "a non-map `state` value must be rejected"
4935 );
4936 // A deeply-nested array inside an AUM field must error (shared depth cap), not overflow.
4937 let mut nested = alloc::vec![0xa2u8, 0x01, 0x03, 0x02]; // map(2){1:3, 2: <nested>}
4938 nested.extend(core::iter::repeat_n(0x81u8, MAX_SIG_NESTING_DEPTH + 8)); // array(1) per level
4939 nested.push(0x00); // innermost uint
4940 assert!(
4941 Aum::from_cbor(&nested).is_err(),
4942 "an AUM field nested past the depth cap must be rejected, not overflow the stack"
4943 );
4944 }
4945
4946 /// Gap 5 + Finding L2: a NON-canonical encoding decodes to the SAME `Aum` (and thus the same
4947 /// `hash()`) as its canonical form — pinning the property that makes the lenient decode benign
4948 /// (the verify path re-serializes canonically, so wire-form variation can never forge a hash).
4949 #[test]
4950 fn aum_from_cbor_noncanonical_decodes_to_same_hash() {
4951 // Canonical NoOp with null prev: a2 01 03 02 f6.
4952 let canonical = [0xa2u8, 0x01, 0x03, 0x02, 0xf6];
4953 // Non-canonical variants that must decode to the SAME struct:
4954 // (a) message_kind via a non-minimal 2-byte int head (0x18 0x03 instead of 0x03):
4955 let noncanon_int = [0xa2u8, 0x01, 0x18, 0x03, 0x02, 0xf6];
4956 // (b) prev=null via the 2-byte simple-value form (0xf8 0x16 instead of 0xf6):
4957 let noncanon_null = [0xa2u8, 0x01, 0x03, 0x02, 0xf8, 0x16];
4958 // (c) map keys in DESCENDING order (2 before 1):
4959 let noncanon_order = [0xa2u8, 0x02, 0xf6, 0x01, 0x03];
4960
4961 let base = Aum::from_cbor(&canonical).expect("canonical decodes");
4962 for (label, bytes) in [
4963 ("non-minimal int head", &noncanon_int[..]),
4964 ("2-byte null simple value", &noncanon_null[..]),
4965 ("descending key order", &noncanon_order[..]),
4966 ] {
4967 let got = Aum::from_cbor(bytes)
4968 .unwrap_or_else(|e| panic!("non-canonical ({label}) should still decode: {e}"));
4969 assert_eq!(
4970 got, base,
4971 "non-canonical ({label}) must decode to the same Aum"
4972 );
4973 assert_eq!(
4974 got.hash(),
4975 base.hash(),
4976 "non-canonical ({label}) must hash identically (re-serialized canonically)"
4977 );
4978 // And it normalises: re-serialize equals the canonical bytes.
4979 assert_eq!(
4980 got.serialize(),
4981 canonical,
4982 "non-canonical ({label}) must re-serialize to the canonical form"
4983 );
4984 }
4985 }
4986 // ---- StaticValidate cluster (tsr-uvg): Go `AUM/Key/State.StaticValidate` parity ----
4987
4988 /// `Key::static_validate` — votes must be 1..=4096 (Go `Key.StaticValidate`).
4989 #[test]
4990 fn key_static_validate_votes_range() {
4991 let mut k = test_aum_key(1, 1);
4992 assert!(k.static_validate().is_ok(), "votes=1 ok");
4993 k.votes = 4096;
4994 assert!(k.static_validate().is_ok(), "votes=4096 ok (boundary)");
4995 k.votes = 0;
4996 assert_eq!(
4997 k.static_validate().unwrap_err(),
4998 TkaError::BadKeyState,
4999 "votes=0 rejected"
5000 );
5001 k.votes = 4097;
5002 assert_eq!(
5003 k.static_validate().unwrap_err(),
5004 TkaError::BadKeyState,
5005 "votes>4096 rejected"
5006 );
5007 }
5008
5009 /// `Key::static_validate` — metadata byte total must be ≤ MAX_META_BYTES.
5010 #[test]
5011 fn key_static_validate_meta_size() {
5012 let mut k = test_aum_key(2, 1);
5013 // 256-byte key + 256-byte value = 512 total = exactly MAX_META_BYTES → ok.
5014 k.meta = alloc::vec![(
5015 String::from_utf8(alloc::vec![b'k'; 256]).unwrap(),
5016 String::from_utf8(alloc::vec![b'v'; 256]).unwrap(),
5017 )];
5018 assert!(k.static_validate().is_ok(), "512 meta bytes ok (boundary)");
5019 // One more byte → rejected.
5020 k.meta[0].1.push('x');
5021 assert_eq!(
5022 k.static_validate().unwrap_err(),
5023 TkaError::BadKeyState,
5024 "meta>512 rejected"
5025 );
5026 }
5027
5028 /// `Aum::static_validate` — per-kind field allow-lists (Go `AUM.StaticValidate`).
5029 #[test]
5030 fn aum_static_validate_per_kind_field_allow_lists() {
5031 // AddKey must have a key and nothing else.
5032 let mut a = genesis_add(test_aum_key(1, 1));
5033 assert!(a.static_validate().is_ok());
5034 a.key_id = alloc::vec![1, 2, 3]; // foreign field
5035 assert!(
5036 a.static_validate().is_err(),
5037 "AddKey with a stray KeyID rejected"
5038 );
5039
5040 // RemoveKey must have a key_id and nothing else.
5041 let g = signed_genesis_add(1, 1);
5042 let mut rm = child(
5043 &g,
5044 AumKind::RemoveKey,
5045 None,
5046 test_aum_key(2, 1).public.clone(),
5047 );
5048 assert!(rm.static_validate().is_ok());
5049 rm.votes = Some(3); // foreign field
5050 assert!(
5051 rm.static_validate().is_err(),
5052 "RemoveKey with stray Votes rejected"
5053 );
5054
5055 // UpdateKey must have key_id AND (votes or meta).
5056 let mut up = child(
5057 &g,
5058 AumKind::UpdateKey,
5059 None,
5060 test_aum_key(2, 1).public.clone(),
5061 );
5062 assert!(
5063 up.static_validate().is_err(),
5064 "UpdateKey with neither votes nor meta rejected"
5065 );
5066 up.votes = Some(2);
5067 assert!(up.static_validate().is_ok(), "UpdateKey with votes ok");
5068 up.key = Some(test_aum_key(3, 1)); // foreign field
5069 assert!(
5070 up.static_validate().is_err(),
5071 "UpdateKey with a stray Key rejected"
5072 );
5073
5074 // Checkpoint must have state and nothing else.
5075 let mut cp = child(&g, AumKind::Checkpoint, None, Vec::new());
5076 cp.state = Some(AumState {
5077 last_aum_hash: None,
5078 disablement_values: Some(alloc::vec![alloc::vec![0xD5u8; DISABLEMENT_LENGTH]]),
5079 keys: Some(alloc::vec![test_aum_key(1, 1)]),
5080 state_id1: 0,
5081 state_id2: 0,
5082 });
5083 assert!(cp.static_validate().is_ok());
5084 cp.votes = Some(1); // foreign field
5085 assert!(
5086 cp.static_validate().is_err(),
5087 "Checkpoint with stray Votes rejected"
5088 );
5089 }
5090
5091 /// `Aum::static_validate` — every signature must have a 32-byte key_id and 64-byte signature.
5092 #[test]
5093 fn aum_static_validate_signature_lengths() {
5094 let mut a = genesis_add(test_aum_key(1, 1));
5095 a.signatures = alloc::vec![AumSignature {
5096 key_id: alloc::vec![0u8; 31], // wrong length (should be 32)
5097 signature: alloc::vec![0u8; 64],
5098 }];
5099 assert!(a.static_validate().is_err(), "31-byte keyID rejected");
5100 a.signatures[0].key_id = alloc::vec![0u8; 32];
5101 a.signatures[0].signature = alloc::vec![0u8; 63]; // wrong length (should be 64)
5102 assert!(a.static_validate().is_err(), "63-byte signature rejected");
5103 }
5104
5105 /// The last-key guard (Go `aumVerify`): a `RemoveKey` removing the only remaining trusted key is
5106 /// rejected — otherwise the authority would be left with an empty key set (lock disabled).
5107 #[test]
5108 fn verified_chain_rejects_removing_last_key() {
5109 let g = signed_genesis_add(1, 1); // exactly one trusted key (seed 1)
5110 let mut rm = child(
5111 &g,
5112 AumKind::RemoveKey,
5113 None,
5114 test_aum_key(1, 1).public.clone(),
5115 );
5116 sign_aum(&mut rm, &[1]); // validly signed by the trusted key
5117 assert_eq!(
5118 VerifiedAumChain::verify(&[g, rm]).unwrap_err(),
5119 TkaError::BadKeyState,
5120 "removing the last trusted key must be refused"
5121 );
5122 }
5123
5124 /// Removing a non-last key is fine: with two trusted keys, one can be removed.
5125 #[test]
5126 fn verified_chain_allows_removing_non_last_key() {
5127 let g = signed_genesis_add(1, 1);
5128 let mut add = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
5129 sign_aum(&mut add, &[1]);
5130 let mut rm = child(
5131 &add,
5132 AumKind::RemoveKey,
5133 None,
5134 test_aum_key(2, 1).public.clone(),
5135 );
5136 sign_aum(&mut rm, &[1]);
5137 let verified = VerifiedAumChain::verify(&[g, add, rm]).expect("removing a non-last key ok");
5138 let auth = Authority::from_verified_chain(verified);
5139 assert_eq!(
5140 auth.state().keys.len(),
5141 1,
5142 "back to one key after the remove"
5143 );
5144 }
5145
5146 /// `UpdateKey` is re-validated after mutation (Go re-runs `Key.StaticValidate`): an update that
5147 /// sets votes out of range is rejected.
5148 #[test]
5149 fn verified_chain_rejects_updatekey_to_invalid_votes() {
5150 let g = signed_genesis_add(1, 1);
5151 let mut up = child(
5152 &g,
5153 AumKind::UpdateKey,
5154 None,
5155 test_aum_key(1, 1).public.clone(),
5156 );
5157 up.votes = Some(5000); // > 4096 → invalid after mutation
5158 sign_aum(&mut up, &[1]);
5159 assert_eq!(
5160 VerifiedAumChain::verify(&[g, up]).unwrap_err(),
5161 TkaError::BadKeyState,
5162 "an UpdateKey that sets votes > 4096 is rejected (post-mutation re-validate)"
5163 );
5164 }
5165
5166 // ===== AUM-chain sync: store + SyncOffer + MissingAUMs (issue #7 chunk 2, tsr-5po) =====
5167
5168 /// Build a simple linear chain `genesis(AddKey) -> NoOp -> NoOp -> ...` of `len` AUMs, returning
5169 /// the AUMs in parent→child order. The genesis adds `test_aum_key(1, 1)`.
5170 fn linear_chain(len: usize) -> Vec<Aum> {
5171 assert!(len >= 1);
5172 let mut chain = alloc::vec![genesis_add(test_aum_key(1, 1))];
5173 for _ in 1..len {
5174 let parent = chain.last().unwrap();
5175 chain.push(child(parent, AumKind::NoOp, None, Vec::new()));
5176 }
5177 chain
5178 }
5179
5180 /// An [`Authority`] whose head is the last AUM of `chain` (via the structural `from_chain`; the
5181 /// sync layer is signature-agnostic, so unsigned test chains are fine here).
5182 fn authority_at_head(chain: &[Aum]) -> Authority {
5183 Authority::from_chain(chain).expect("linear test chain replays")
5184 }
5185
5186 #[test]
5187 fn mem_store_indexes_by_hash_and_children() {
5188 let chain = linear_chain(3);
5189 let store = MemAumStore::from_aums(chain.clone());
5190 assert_eq!(store.len(), 3);
5191 // by-hash lookup
5192 assert_eq!(store.aum(&chain[1].hash()).as_ref(), Some(&chain[1]));
5193 assert!(store.aum(&AumHash([0xFF; AUM_HASH_LEN])).is_none());
5194 // child index: genesis has one child (chain[1]); the tail has none.
5195 let kids = store.child_aums(&chain[0].hash());
5196 assert_eq!(kids.len(), 1);
5197 assert_eq!(kids[0], chain[1]);
5198 assert!(store.child_aums(&chain[2].hash()).is_empty());
5199 // insert is idempotent on hash + child edge.
5200 let mut s2 = store.clone();
5201 s2.insert(chain[1].clone());
5202 assert_eq!(s2.len(), 3, "re-insert must not grow the store");
5203 assert_eq!(
5204 s2.child_aums(&chain[0].hash()).len(),
5205 1,
5206 "child edge not duplicated"
5207 );
5208 }
5209
5210 #[test]
5211 fn sync_offer_head_and_oldest_bookend() {
5212 let chain = linear_chain(5);
5213 let store = MemAumStore::from_aums(chain.clone());
5214 let auth = authority_at_head(&chain);
5215 let oldest = chain[0].hash();
5216
5217 let offer = auth.sync_offer(&store, oldest).expect("offer");
5218 assert_eq!(offer.head, chain[4].hash(), "offer head is the chain head");
5219 assert_eq!(
5220 *offer.ancestors.last().unwrap(),
5221 oldest,
5222 "the last ancestor is always the oldest AUM"
5223 );
5224 // Every ancestor is a real hash in the chain.
5225 for a in &offer.ancestors {
5226 assert!(
5227 store.aum(a).is_some(),
5228 "ancestor {a:?} must be in the store"
5229 );
5230 }
5231 }
5232
5233 #[test]
5234 fn sync_offer_truncates_on_a_gap() {
5235 // A store missing an interior AUM: the backward walk breaks early, but `oldest` is still
5236 // appended (matching Go's break-then-append). Drop chain[1] so walking back from head hits
5237 // a gap.
5238 let chain = linear_chain(4);
5239 let store = MemAumStore::from_aums(
5240 chain
5241 .iter()
5242 .enumerate()
5243 .filter(|(i, _)| *i != 1)
5244 .map(|(_, a)| a.clone()),
5245 );
5246 let auth = authority_at_head(&chain);
5247 let offer = auth
5248 .sync_offer(&store, chain[0].hash())
5249 .expect("offer despite gap");
5250 assert_eq!(*offer.ancestors.last().unwrap(), chain[0].hash());
5251 }
5252
5253 #[test]
5254 fn missing_aums_empty_when_up_to_date() {
5255 let chain = linear_chain(4);
5256 let store = MemAumStore::from_aums(chain.clone());
5257 let auth = authority_at_head(&chain);
5258 let oldest = chain[0].hash();
5259 // Peer offers the SAME head → nothing missing.
5260 let peer_offer = auth.sync_offer(&store, oldest).expect("offer");
5261 let missing = auth
5262 .missing_aums(&store, &peer_offer, oldest)
5263 .expect("missing");
5264 assert!(missing.is_empty(), "an up-to-date peer is missing nothing");
5265 }
5266
5267 #[test]
5268 fn missing_aums_head_intersection_sends_the_tail() {
5269 // We are at head chain[4]; the peer is behind at chain[2] (their head is an ancestor of
5270 // ours). We must send them chain[3] and chain[4] (everything after the intersection).
5271 let chain = linear_chain(5);
5272 let store = MemAumStore::from_aums(chain.clone());
5273 let oldest = chain[0].hash();
5274 let us = authority_at_head(&chain); // head = chain[4]
5275
5276 // The peer's offer: head = chain[2], ancestors back to oldest. Build it from a peer authority
5277 // whose head is chain[2] over a store holding the prefix [0..=2].
5278 let peer_prefix: Vec<Aum> = chain[0..=2].to_vec();
5279 let peer_store = MemAumStore::from_aums(peer_prefix.clone());
5280 let peer = authority_at_head(&peer_prefix); // head = chain[2]
5281 let peer_offer = peer.sync_offer(&peer_store, oldest).expect("peer offer");
5282
5283 let missing = us
5284 .missing_aums(&store, &peer_offer, oldest)
5285 .expect("missing");
5286 let missing_hashes: Vec<AumHash> = missing.iter().map(Aum::hash).collect();
5287 assert_eq!(
5288 missing_hashes,
5289 alloc::vec![chain[3].hash(), chain[4].hash()],
5290 "must send exactly the AUMs after the peer's head, in order"
5291 );
5292 }
5293
5294 #[test]
5295 fn missing_aums_excludes_the_intersection_itself() {
5296 // The intersection AUM (the peer's head) must NOT be in the sent set — they already have it.
5297 let chain = linear_chain(4);
5298 let store = MemAumStore::from_aums(chain.clone());
5299 let oldest = chain[0].hash();
5300 let us = authority_at_head(&chain);
5301
5302 let peer_prefix: Vec<Aum> = chain[0..=1].to_vec();
5303 let peer = authority_at_head(&peer_prefix);
5304 let peer_offer = peer
5305 .sync_offer(&MemAumStore::from_aums(peer_prefix.clone()), oldest)
5306 .expect("peer offer");
5307
5308 let missing = us
5309 .missing_aums(&store, &peer_offer, oldest)
5310 .expect("missing");
5311 assert!(
5312 !missing.iter().any(|a| a.hash() == chain[1].hash()),
5313 "the intersection AUM (peer's head) must be excluded"
5314 );
5315 assert_eq!(missing.len(), 2, "only chain[2] and chain[3] are missing");
5316 }
5317
5318 #[test]
5319 fn missing_aums_no_intersection_errors() {
5320 // Two totally unrelated chains (different genesis keys → different hashes everywhere): no
5321 // intersection, so `missing_aums` fails closed rather than mis-rooting.
5322 let ours = linear_chain(3);
5323 let store = MemAumStore::from_aums(ours.clone());
5324 let us = authority_at_head(&ours);
5325
5326 // A foreign chain the peer offers; we hold none of it.
5327 let theirs = {
5328 let mut c = alloc::vec![genesis_add(test_aum_key(9, 1))];
5329 c.push(child(&c[0], AumKind::NoOp, None, Vec::new()));
5330 c
5331 };
5332 let foreign_offer = SyncOffer {
5333 head: theirs[1].hash(),
5334 ancestors: alloc::vec![theirs[1].hash(), theirs[0].hash()],
5335 };
5336 assert!(
5337 us.missing_aums(&store, &foreign_offer, ours[0].hash())
5338 .is_err(),
5339 "no intersection must fail closed, not mis-root"
5340 );
5341 }
5342
5343 #[test]
5344 fn compute_state_at_matches_replay_at_each_point() {
5345 // The state computed at an interior AUM via the store walk must equal a direct linear replay
5346 // of the prefix up to that AUM (the verify-only Authority's state).
5347 let chain = linear_chain(4);
5348 let store = MemAumStore::from_aums(chain.clone());
5349 for i in 0..chain.len() {
5350 let want = chain[i].hash();
5351 let via_store = compute_state_at(&store, MAX_SYNC_ITER, want)
5352 .expect("compute_state_at ok")
5353 .expect("hash present");
5354 let via_replay = Authority::from_chain(&chain[0..=i]).expect("prefix replays");
5355 assert_eq!(
5356 via_store.to_state(),
5357 *via_replay.state(),
5358 "computed state at chain[{i}] must match a direct prefix replay"
5359 );
5360 }
5361 }
5362
5363 #[test]
5364 fn sync_offer_ancestors_are_exponentially_spaced() {
5365 // With a long chain the ancestor sampling thins out (skip 4, then 16, ...), so the count is
5366 // far below the chain length — the whole point of the offer.
5367 let chain = linear_chain(60);
5368 let store = MemAumStore::from_aums(chain.clone());
5369 let auth = authority_at_head(&chain);
5370 let offer = auth.sync_offer(&store, chain[0].hash()).expect("offer");
5371 assert!(
5372 offer.ancestors.len() < 12,
5373 "exponential spacing keeps the ancestor list small (got {})",
5374 offer.ancestors.len()
5375 );
5376 // First sampled ancestor is 4 back from head (i=4 is the first i%4==0 with i>0): chain[56].
5377 assert_eq!(offer.ancestors[0], chain[60 - 1 - 4].hash());
5378 assert_eq!(*offer.ancestors.last().unwrap(), chain[0].hash());
5379 }
5380
5381 #[test]
5382 fn linear_chain_from_returns_ordered_chain() {
5383 // A store built from a linear chain returns it genesis→head in order, regardless of insert
5384 // order, so it round-trips through `VerifiedAumChain`/`from_chain`.
5385 let chain = linear_chain(5);
5386 // Insert in reverse to prove ordering is by chain links, not insert order.
5387 let mut store = MemAumStore::new();
5388 for aum in chain.iter().rev() {
5389 store.insert(aum.clone());
5390 }
5391 let ordered = store.linear_chain_from(chain[0].hash()).expect("walk");
5392 let got: Vec<AumHash> = ordered.iter().map(Aum::hash).collect();
5393 let want: Vec<AumHash> = chain.iter().map(Aum::hash).collect();
5394 assert_eq!(got, want, "linear_chain_from must yield genesis→head order");
5395 // And it replays into the same head a direct from_chain produces.
5396 assert_eq!(
5397 Authority::from_chain(&ordered).unwrap().head(),
5398 chain[4].hash()
5399 );
5400 }
5401
5402 #[test]
5403 fn linear_chain_from_missing_genesis_errors() {
5404 let chain = linear_chain(3);
5405 let store = MemAumStore::from_aums(chain.clone());
5406 // A genesis hash not in the store is BadChain, not a panic.
5407 assert_eq!(
5408 store
5409 .linear_chain_from(AumHash([0xEE; AUM_HASH_LEN]))
5410 .unwrap_err(),
5411 TkaError::BadChain
5412 );
5413 }
5414
5415 #[test]
5416 fn linear_chain_from_single_genesis() {
5417 // A store with only the genesis returns just it (the bootstrap case before any sync).
5418 let g = genesis_add(test_aum_key(1, 1));
5419 let store = MemAumStore::from_aums([g.clone()]);
5420 let ordered = store.linear_chain_from(g.hash()).expect("walk");
5421 assert_eq!(ordered.len(), 1);
5422 assert_eq!(ordered[0].hash(), g.hash());
5423 }
5424
5425 /// Consensus regression (tsr-3x4): at a genuine **weight-decided** fork, `linear_chain_from` must
5426 /// pick the branch with the higher signing weight — the branch a Go node picks (Go folds the real
5427 /// trusted-key state before each `pickNextAUM`). The previous code resolved the fork against an
5428 /// empty (zero-key) `ReplayState`, so every candidate scored weight 0 and the tiebreak collapsed
5429 /// to lowest-hash; on a fork where the lowest-hash branch is NOT the highest-weight branch, that
5430 /// diverged from Go = an accept-direction consensus split. This test constructs exactly that
5431 /// adversarial shape (the low-weight branch has the lower hash) and asserts weight wins.
5432 #[test]
5433 fn linear_chain_from_resolves_fork_by_real_weight_not_empty_state() {
5434 use ed25519_dalek::{Signer, SigningKey};
5435
5436 // Two trusted keys with very different vote weights, established by a checkpoint genesis.
5437 let key_light = test_aum_key(0x10, 1); // votes = 1
5438 let key_heavy = test_aum_key(0x20, 100); // votes = 100
5439 let signer_light = SigningKey::from_bytes(&[0x10; 32]);
5440 let signer_heavy = SigningKey::from_bytes(&[0x20; 32]);
5441
5442 let genesis = Aum {
5443 message_kind: AumKind::Checkpoint,
5444 prev_aum_hash: None,
5445 key: None,
5446 key_id: Vec::new(),
5447 state: Some(AumState {
5448 last_aum_hash: None,
5449 disablement_values: Some(alloc::vec![alloc::vec![0x33u8; DISABLEMENT_LENGTH]]),
5450 keys: Some(alloc::vec![key_light.clone(), key_heavy.clone()]),
5451 state_id1: 0,
5452 state_id2: 0,
5453 }),
5454 votes: None,
5455 meta: Vec::new(),
5456 signatures: Vec::new(),
5457 };
5458 let gh = genesis.hash();
5459
5460 // Build a child NoOp signed by the given key. `salt` perturbs the AUM bytes (via meta) so we
5461 // can search for the hash ordering we need without changing which key signs it.
5462 let child_signed_by = |signer: &SigningKey, key: &AumKey, salt: u8| -> Aum {
5463 let mut aum = Aum {
5464 message_kind: AumKind::NoOp,
5465 prev_aum_hash: Some(gh),
5466 key: None,
5467 key_id: Vec::new(),
5468 state: None,
5469 votes: None,
5470 meta: alloc::vec![("s".to_string(), alloc::format!("{salt}"))],
5471 signatures: Vec::new(),
5472 };
5473 let sh = aum.sig_hash();
5474 aum.signatures = alloc::vec![AumSignature {
5475 key_id: key.id().to_vec(),
5476 signature: signer.sign(&sh).to_bytes().to_vec(),
5477 }];
5478 aum
5479 };
5480
5481 // Find a salt pair where the LIGHT-weight branch has the LOWER hash (the adversarial case:
5482 // lowest-hash != highest-weight). With content-derived hashes this is found by probing salts.
5483 let (light_child, heavy_child) = (0u8..64)
5484 .flat_map(|ls| (0u8..64).map(move |hs| (ls, hs)))
5485 .find_map(|(ls, hs)| {
5486 let light = child_signed_by(&signer_light, &key_light, ls);
5487 let heavy = child_signed_by(&signer_heavy, &key_heavy, hs);
5488 (light.hash().0 < heavy.hash().0).then_some((light, heavy))
5489 })
5490 .expect("a salt pair where the light branch sorts lower must exist");
5491
5492 // Sanity: the adversarial precondition actually holds.
5493 assert!(
5494 light_child.hash().0 < heavy_child.hash().0,
5495 "test setup: light (low-weight) branch must have the lower hash"
5496 );
5497
5498 let store =
5499 MemAumStore::from_aums([genesis.clone(), light_child.clone(), heavy_child.clone()]);
5500 let ordered = store.linear_chain_from(gh).expect("walk");
5501
5502 // The walk must pick the HEAVY (weight-100) branch as the genesis's successor — matching Go —
5503 // NOT the light/low-hash branch the old empty-state code would have chosen.
5504 assert_eq!(ordered.len(), 2, "genesis + the chosen branch head");
5505 assert_eq!(ordered[0].hash(), gh);
5506 assert_eq!(
5507 ordered[1].hash(),
5508 heavy_child.hash(),
5509 "fork must resolve to the higher-WEIGHT branch (Go parity), not the lower-HASH one"
5510 );
5511 assert_ne!(
5512 ordered[1].hash(),
5513 light_child.hash(),
5514 "the lower-hash low-weight branch must NOT be chosen (the pre-fix empty-state bug)"
5515 );
5516 }
5517
5518 /// Consensus regression (tsr-3x4), the state-ACCUMULATION case: the fork is resolved by a key
5519 /// that was added by a **mid-chain `AddKey`**, not by the genesis. This is the scenario that
5520 /// distinguishes a walk that folds *every AUM up to the fork* (correct, Go `advanceByPrimary`)
5521 /// from one that only ever reflects the genesis state — the genesis-only test above would pass
5522 /// even if `state` failed to advance past the genesis, so this fork lives two AUMs deep and its
5523 /// weight winner depends on the intervening `AddKey` having been folded in.
5524 #[test]
5525 fn linear_chain_from_resolves_deep_fork_using_mid_chain_added_key_weight() {
5526 use ed25519_dalek::{Signer, SigningKey};
5527
5528 // Genesis trusts only a bootstrap key (votes 1). A mid-chain AddKey then introduces the
5529 // heavy key (votes 100). The fork below the AddKey is decided by that heavy key's weight —
5530 // so it can only resolve correctly if the AddKey was folded into the walk's state.
5531 let key_boot = test_aum_key(0x40, 1);
5532 let key_heavy = test_aum_key(0x50, 100);
5533 let signer_boot = SigningKey::from_bytes(&[0x40; 32]);
5534 let signer_heavy = SigningKey::from_bytes(&[0x50; 32]);
5535
5536 let genesis = Aum {
5537 message_kind: AumKind::Checkpoint,
5538 prev_aum_hash: None,
5539 key: None,
5540 key_id: Vec::new(),
5541 state: Some(AumState {
5542 last_aum_hash: None,
5543 disablement_values: Some(alloc::vec![alloc::vec![0x44u8; DISABLEMENT_LENGTH]]),
5544 keys: Some(alloc::vec![key_boot.clone()]),
5545 state_id1: 0,
5546 state_id2: 0,
5547 }),
5548 votes: None,
5549 meta: Vec::new(),
5550 signatures: Vec::new(),
5551 };
5552 let gh = genesis.hash();
5553
5554 // Mid-chain AddKey introducing the heavy key, signed by the bootstrap key (the only trusted
5555 // key at this point). prev = genesis.
5556 let mut add_heavy = Aum {
5557 message_kind: AumKind::AddKey,
5558 prev_aum_hash: Some(gh),
5559 key: Some(key_heavy.clone()),
5560 key_id: Vec::new(),
5561 state: None,
5562 votes: None,
5563 meta: Vec::new(),
5564 signatures: Vec::new(),
5565 };
5566 let ah_sh = add_heavy.sig_hash();
5567 add_heavy.signatures = alloc::vec![AumSignature {
5568 key_id: key_boot.id().to_vec(),
5569 signature: signer_boot.sign(&ah_sh).to_bytes().to_vec(),
5570 }];
5571 let ah = add_heavy.hash();
5572
5573 // Two competing children of the AddKey: one signed by the heavy key (weight 100), one by the
5574 // bootstrap key (weight 1). Arrange (via salt) so the LIGHT (boot-signed) branch sorts lower.
5575 let child_signed_by = |signer: &SigningKey, key: &AumKey, salt: u8| -> Aum {
5576 let mut aum = Aum {
5577 message_kind: AumKind::NoOp,
5578 prev_aum_hash: Some(ah),
5579 key: None,
5580 key_id: Vec::new(),
5581 state: None,
5582 votes: None,
5583 meta: alloc::vec![("s".to_string(), alloc::format!("{salt}"))],
5584 signatures: Vec::new(),
5585 };
5586 let sh = aum.sig_hash();
5587 aum.signatures = alloc::vec![AumSignature {
5588 key_id: key.id().to_vec(),
5589 signature: signer.sign(&sh).to_bytes().to_vec(),
5590 }];
5591 aum
5592 };
5593 let (light_child, heavy_child) = (0u8..64)
5594 .flat_map(|ls| (0u8..64).map(move |hs| (ls, hs)))
5595 .find_map(|(ls, hs)| {
5596 let light = child_signed_by(&signer_boot, &key_boot, ls);
5597 let heavy = child_signed_by(&signer_heavy, &key_heavy, hs);
5598 (light.hash().0 < heavy.hash().0).then_some((light, heavy))
5599 })
5600 .expect("a salt pair where the light branch sorts lower must exist");
5601
5602 let store = MemAumStore::from_aums([
5603 genesis.clone(),
5604 add_heavy.clone(),
5605 light_child.clone(),
5606 heavy_child.clone(),
5607 ]);
5608 let ordered = store.linear_chain_from(gh).expect("walk");
5609
5610 // genesis → AddKey → the HEAVY branch (resolved by the mid-chain-added key's weight).
5611 assert_eq!(
5612 ordered.len(),
5613 3,
5614 "genesis + AddKey + the chosen branch head"
5615 );
5616 assert_eq!(ordered[0].hash(), gh);
5617 assert_eq!(ordered[1].hash(), ah, "the AddKey is on the walked chain");
5618 assert_eq!(
5619 ordered[2].hash(),
5620 heavy_child.hash(),
5621 "the deep fork must resolve by the mid-chain-added key's weight (state was accumulated past genesis)"
5622 );
5623 }
5624}