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