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