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