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/// The starting number of AUMs to skip between ancestors in a [`SyncOffer`] (Go
1648/// `ancestorsSkipStart`). The gap grows exponentially (`<< ancestorsSkipShift` each step).
1649const ANCESTORS_SKIP_START: u64 = 4;
1650/// How many bits to advance the ancestor skip count each step (Go `ancestorsSkipShift`): `4 << 2 =
1651/// 16`, so after skipping 4 it skips 16, then 64…
1652const ANCESTORS_SKIP_SHIFT: u64 = 2;
1653/// Iteration cap for the backward head-intersection walk + offer ancestor walk (Go
1654/// `maxSyncHeadIntersectionIter`, `tka/limits.go`).
1655const MAX_SYNC_HEAD_INTERSECTION_ITER: u64 = 400;
1656/// Iteration cap for forward fast-forward / `computeStateAt` walks (Go `maxSyncIter` /
1657/// `maxScanIterations`, `tka/limits.go`).
1658const MAX_SYNC_ITER: usize = 2000;
1659
1660/// A read-only store of AUMs keyed by hash, plus the parent→children index the forward walk needs
1661/// (Go's `tka.Chonk`, reduced to the methods the sync/offer path actually calls). The client builds
1662/// one of these from the AUMs it has on hand; the sync RPC populates it with what control sends.
1663///
1664/// "Not found" is signalled by `None` from [`aum`](AumStore::aum) (Go's `os.ErrNotExist` sentinel),
1665/// which the walkers treat as a loop terminator, not an error.
1666pub trait AumStore {
1667 /// Fetch the AUM with this hash, or `None` if the store does not hold it.
1668 fn aum(&self, hash: &AumHash) -> Option<Aum>;
1669 /// The AUMs whose `prev_aum_hash` is `hash` — the forward links out of `hash` (Go
1670 /// `Chonk.ChildAUMs`). Order is unspecified; the caller resolves forks deterministically.
1671 fn child_aums(&self, hash: &AumHash) -> Vec<Aum>;
1672}
1673
1674/// An in-memory [`AumStore`]: a hash→AUM map plus a parent-hash→child-hashes index, both built as
1675/// AUMs are inserted. `no_std`-friendly (`BTreeMap`, not `HashMap`). This is the store a client uses
1676/// to stage the AUMs it knows about while computing a [`SyncOffer`] / the AUMs a peer is missing.
1677#[derive(Debug, Clone, Default)]
1678pub struct MemAumStore {
1679 by_hash: alloc::collections::BTreeMap<AumHash, Aum>,
1680 /// parent hash → child hashes (the forward index). A genesis AUM (no parent) contributes no
1681 /// entry here; it is found only via `by_hash`.
1682 children: alloc::collections::BTreeMap<AumHash, Vec<AumHash>>,
1683}
1684
1685impl MemAumStore {
1686 /// A new, empty store.
1687 pub fn new() -> MemAumStore {
1688 MemAumStore::default()
1689 }
1690
1691 /// Insert an AUM, indexing it by its own hash and (if it has a parent) under its parent's child
1692 /// list. Idempotent: re-inserting the same AUM hash replaces it and does not duplicate the child
1693 /// edge. Returns the inserted AUM's hash.
1694 pub fn insert(&mut self, aum: Aum) -> AumHash {
1695 let hash = aum.hash();
1696 if let Some(parent) = aum.prev_aum_hash {
1697 let kids = self.children.entry(parent).or_default();
1698 if !kids.contains(&hash) {
1699 kids.push(hash);
1700 }
1701 }
1702 self.by_hash.insert(hash, aum);
1703 hash
1704 }
1705
1706 /// Build a store from an iterator of AUMs (e.g. a chain or a sync batch).
1707 pub fn from_aums(aums: impl IntoIterator<Item = Aum>) -> MemAumStore {
1708 let mut store = MemAumStore::new();
1709 for aum in aums {
1710 store.insert(aum);
1711 }
1712 store
1713 }
1714
1715 /// Number of AUMs held.
1716 pub fn len(&self) -> usize {
1717 self.by_hash.len()
1718 }
1719
1720 /// Whether the store holds no AUMs.
1721 pub fn is_empty(&self) -> bool {
1722 self.by_hash.is_empty()
1723 }
1724
1725 /// Walk the chain from `oldest` (the genesis) forward to the head, returning the AUMs in
1726 /// parent→child order — the linear form [`VerifiedAumChain::verify`] / [`Authority::from_chain`]
1727 /// expect. At a fork (a parent with more than one child) the deterministic `pick_next_aum` rule
1728 /// chooses the branch, so the result is the active chain (matching how the chain is replayed).
1729 ///
1730 /// Used by the runtime sync driver to turn the AUMs accumulated in the store (genesis +
1731 /// sync-received) into the ordered chain it re-verifies into an [`Authority`]. Bounded by
1732 /// `MAX_SYNC_ITER` so a malformed/cyclic store cannot loop forever.
1733 ///
1734 /// # Errors
1735 /// [`TkaError::BadChain`] if `oldest` is not in the store, or the walk exceeds the iteration cap
1736 /// (a cycle or an over-long chain). Signature/structure are NOT checked here — that is `verify`'s
1737 /// job; this only orders the AUMs.
1738 pub fn linear_chain_from(&self, oldest: AumHash) -> Result<Vec<Aum>, TkaError> {
1739 let mut out = Vec::new();
1740 let Some(mut curs) = self.aum(&oldest) else {
1741 return Err(TkaError::BadChain);
1742 };
1743 // Fold the chain into a live `ReplayState` as we walk, so a fork is resolved against the
1744 // REAL trusted-key weights at the fork point — exactly as Go's `advanceByPrimary` folds
1745 // state before each `pickNextAUM`. Resolving with an empty (zero-key) state would make every
1746 // candidate's weight 0, collapsing the tiebreak to the lowest-hash rule; a genuinely
1747 // weight-decided fork (signed competing branches with different vote totals) would then pick
1748 // a *different* branch than a Go node — an accept-direction consensus split.
1749 //
1750 // This store is UNVERIFIED (signatures/structure are `verify`'s job, not ours), so a fold can
1751 // fail on a malformed AUM. We tolerate that best-effort: stop advancing the weight state but
1752 // keep ordering the chain, because the authoritative `VerifiedAumChain::verify` runs on the
1753 // result and will reject a malformed chain anyway. We never abort the walk on a fold error.
1754 let mut state = ReplayState::default();
1755 for _ in 0..MAX_SYNC_ITER {
1756 // Apply the current AUM before resolving its children (so the weight at a fork below
1757 // includes every AUM up to and including this one). Best-effort: a fold failure on this
1758 // unverified store leaves `state` as-is rather than aborting the ordering walk.
1759 let _ = state.apply_verified_aum(&curs);
1760
1761 out.push(curs.clone());
1762 let children = self.child_aums(&curs.hash());
1763 if children.is_empty() {
1764 return Ok(out);
1765 }
1766 // Deterministic branch choice at a fork (weight → RemoveKey → lowest-hash), now against
1767 // the real replayed state. For a linear chain there is exactly one child and the state is
1768 // irrelevant; at a fork the weight term is correct.
1769 let next = pick_next_aum(&state, &children).clone();
1770 curs = next;
1771 }
1772 Err(TkaError::BadChain) // iteration cap: cycle or over-long chain
1773 }
1774}
1775
1776impl AumStore for MemAumStore {
1777 fn aum(&self, hash: &AumHash) -> Option<Aum> {
1778 self.by_hash.get(hash).cloned()
1779 }
1780
1781 fn child_aums(&self, hash: &AumHash) -> Vec<Aum> {
1782 self.children
1783 .get(hash)
1784 .map(|kids| {
1785 kids.iter()
1786 .filter_map(|h| self.by_hash.get(h).cloned())
1787 .collect()
1788 })
1789 .unwrap_or_default()
1790 }
1791}
1792
1793/// A node's view of where its chain is, offered to a peer so the peer can work out what to send (Go
1794/// `tka.SyncOffer`): the current `head` plus a sparse, exponentially-spaced sample of `ancestors`
1795/// back to the oldest AUM the node holds. The last entry is always the oldest-known AUM.
1796#[derive(Debug, Clone, PartialEq, Eq)]
1797pub struct SyncOffer {
1798 /// The node's current chain head.
1799 pub head: AumHash,
1800 /// A subset of the chain's ancestors, newest-first, ending with the oldest-known AUM. Used by a
1801 /// peer to find a "tail intersection" when it doesn't recognise the head.
1802 pub ancestors: Vec<AumHash>,
1803}
1804
1805/// The result of comparing two [`SyncOffer`]s (Go `tka.intersection`): where (if anywhere) the two
1806/// chains meet, which tells [`missing_aums`](sync_missing_aums) where to start gathering.
1807#[derive(Debug, Clone, PartialEq, Eq)]
1808struct Intersection {
1809 /// Both heads are equal — nothing to exchange.
1810 up_to_date: bool,
1811 /// The newest common AUM that is the *remote's* head and an ancestor of ours (we have updates
1812 /// building on it to send).
1813 head_intersection: Option<AumHash>,
1814 /// The oldest common AUM, where the chains diverge — a starting point to send from when we don't
1815 /// recognise the remote's head.
1816 tail_intersection: Option<AumHash>,
1817}
1818
1819/// Compute the state at `want_hash` by walking back to a checkpoint or genesis, then forward along
1820/// the taken path (Go `computeStateAt`, `tka/tka.go`). Structural fold only (no signature check) —
1821/// the verify boundary is elsewhere. Returns `None` (not an error) if `want_hash` is not in the
1822/// store, mirroring the `os.ErrNotExist` sentinel Go callers special-case.
1823fn compute_state_at(
1824 storage: &dyn AumStore,
1825 max_iter: usize,
1826 want_hash: AumHash,
1827) -> Result<Option<ReplayState>, TkaError> {
1828 let Some(top) = storage.aum(&want_hash) else {
1829 return Ok(None);
1830 };
1831
1832 // Walk backwards to a starting point: a checkpoint AUM (which carries full state) or a genesis
1833 // AUM (no parent — valid only for NoOp/AddKey/Checkpoint). `path` records every hash on the way
1834 // so the forward pass can follow exactly this branch (which, for a non-primary fork, may differ
1835 // from standard fork resolution).
1836 let mut curs = top;
1837 let mut state = ReplayState::default();
1838 let mut path: alloc::collections::BTreeSet<AumHash> = alloc::collections::BTreeSet::new();
1839 let mut started = false;
1840 for i in 0..=max_iter {
1841 if i == max_iter {
1842 return Err(TkaError::BadChain); // iteration limit exceeded
1843 }
1844 path.insert(curs.hash());
1845
1846 if curs.message_kind == AumKind::Checkpoint {
1847 // A checkpoint encapsulates the state at that point: fold it into an empty state.
1848 let mut s = ReplayState::default();
1849 s.apply_verified_aum(&curs)?;
1850 state = s;
1851 started = true;
1852 break;
1853 }
1854 match curs.prev_aum_hash {
1855 None => {
1856 // Genesis: applies to the empty state. Only NoOp/AddKey reach here (Checkpoint broke
1857 // above); anything else is an invalid genesis. `apply_verified_aum` enforces the
1858 // same kind restriction, so just fold and let it reject a bad genesis.
1859 let mut s = ReplayState::default();
1860 s.apply_verified_aum(&curs)?;
1861 state = s;
1862 started = true;
1863 break;
1864 }
1865 Some(parent) => {
1866 let Some(p) = storage.aum(&parent) else {
1867 return Err(TkaError::BadParent); // dangling parent link
1868 };
1869 curs = p;
1870 }
1871 }
1872 }
1873 debug_assert!(
1874 started,
1875 "compute_state_at must find a checkpoint or genesis"
1876 );
1877
1878 // Fast-forward from the starting point, following only AUMs on `path` (the custom advancer),
1879 // until we reach `want_hash`. No gather side-effect here — we only want the final state.
1880 let (_, end_state) = fast_forward(
1881 storage,
1882 max_iter,
1883 state,
1884 &mut |_: &Aum, _: &mut ReplayState| Ok(false),
1885 Some(&path),
1886 Some(want_hash),
1887 )?;
1888 Ok(Some(end_state))
1889}
1890
1891/// Fast-forward from `start_state` along the chain (Go `fastForwardWithAdvancer` +
1892/// `advanceByPrimary`). At each step it takes the children of the current AUM and advances by:
1893/// - the child on `path` when `path` is `Some` (the `computeStateAt` custom advancer), or
1894/// - [`pick_next_aum`]'s deterministic fork resolution otherwise (the primary advancer).
1895///
1896/// Stops when `stop_at` (when set) is reached — returning that AUM + the state *before* applying it
1897/// for a gather caller, matching Go's `done(curs, state)` check at the top of the loop — or when
1898/// there are no more children. `gather` is invoked for each visited AUM (Go's `done` callback side
1899/// effect) and its boolean return additionally stops the walk when true.
1900///
1901/// Returns the final AUM reached and the folded state at it. Structural fold only.
1902fn fast_forward(
1903 storage: &dyn AumStore,
1904 max_iter: usize,
1905 start_state: ReplayState,
1906 gather: &mut dyn FnMut(&Aum, &mut ReplayState) -> Result<bool, TkaError>,
1907 path: Option<&alloc::collections::BTreeSet<AumHash>>,
1908 stop_at: Option<AumHash>,
1909) -> Result<(Aum, ReplayState), TkaError> {
1910 let start_hash = start_state
1911 .last_aum_hash
1912 .ok_or(TkaError::Decode("fast_forward from a state with no head"))?;
1913 let mut curs = storage.aum(&start_hash).ok_or(TkaError::BadParent)?;
1914 let mut state = start_state;
1915
1916 for _ in 0..max_iter {
1917 // Done check runs BEFORE advancing (Go checks `done(curs, state)` at loop top): for a
1918 // `stop_at` caller this returns the stop AUM with the state *before* applying it.
1919 if Some(curs.hash()) == stop_at {
1920 return Ok((curs, state));
1921 }
1922 // Side-effect callback (the gather closure for `missing_aums`); a `true` return also stops.
1923 if gather(&curs, &mut state)? {
1924 return Ok((curs, state));
1925 }
1926
1927 let children = storage.child_aums(&curs.hash());
1928 let next = match path {
1929 // `computeStateAt` advancer: follow the unique child that is on the recorded path.
1930 Some(p) => children.into_iter().find(|c| p.contains(&c.hash())),
1931 // Primary advancer: deterministic fork resolution.
1932 None => {
1933 if children.is_empty() {
1934 None
1935 } else {
1936 Some(pick_next_aum(&state, &children).clone())
1937 }
1938 }
1939 };
1940 match next {
1941 None => return Ok((curs, state)), // no more children: we are at head
1942 Some(n) => {
1943 state.apply_verified_aum(&n)?;
1944 curs = n;
1945 }
1946 }
1947 }
1948 Err(TkaError::BadChain) // iteration limit exceeded
1949}
1950
1951impl Authority {
1952 /// Build the [`SyncOffer`] this authority would send a peer (Go `Authority.SyncOffer`): its
1953 /// `head` plus an exponentially-spaced sample of ancestors back to `oldest`, ending with
1954 /// `oldest`. `oldest` is the oldest AUM the caller holds (Go `a.oldestAncestor.Hash()`); our
1955 /// verify-only [`Authority`] does not track it, so it is passed in — typically the genesis hash
1956 /// of the chain the caller staged in `storage`.
1957 ///
1958 /// `storage` must contain the chain from `head` back to `oldest`; a gap simply truncates the
1959 /// ancestor list early (the walk breaks on the first missing parent, exactly like Go).
1960 pub fn sync_offer(
1961 &self,
1962 storage: &dyn AumStore,
1963 oldest: AumHash,
1964 ) -> Result<SyncOffer, TkaError> {
1965 let mut out = SyncOffer {
1966 head: self.head,
1967 ancestors: Vec::with_capacity(6),
1968 };
1969 let mut skip_amount = ANCESTORS_SKIP_START;
1970 let mut curs = self.head;
1971 for i in 0..MAX_SYNC_HEAD_INTERSECTION_ITER {
1972 if i > 0 && skip_amount != 0 && i % skip_amount == 0 {
1973 out.ancestors.push(curs);
1974 skip_amount <<= ANCESTORS_SKIP_SHIFT;
1975 }
1976 let Some(parent) = storage.aum(&curs) else {
1977 break; // os.ErrNotExist: stop, don't error
1978 };
1979 // We append `oldest` after the loop, so don't duplicate it.
1980 if parent.hash() == oldest {
1981 break;
1982 }
1983 match parent.prev_aum_hash {
1984 Some(prev) => curs = prev,
1985 None => break, // reached a genesis that isn't `oldest`; nothing earlier to walk
1986 }
1987 }
1988 out.ancestors.push(oldest);
1989 Ok(out)
1990 }
1991
1992 /// Given a peer's [`SyncOffer`], compute the AUMs **they** are missing — the ones to send them so
1993 /// their chain catches up to ours (Go `Authority.MissingAUMs`). `storage` must hold our chain.
1994 /// Returns an empty `Vec` when the peer is already up to date.
1995 ///
1996 /// Mirrors Go: compute our own offer, find the intersection of the two chains, then gather every
1997 /// AUM from the intersection forward to our head (excluding the intersection AUM itself).
1998 pub fn missing_aums(
1999 &self,
2000 storage: &dyn AumStore,
2001 remote_offer: &SyncOffer,
2002 oldest: AumHash,
2003 ) -> Result<Vec<Aum>, TkaError> {
2004 let local_offer = self.sync_offer(storage, oldest)?;
2005 let isect = compute_sync_intersection(storage, &local_offer, remote_offer)?;
2006 if isect.up_to_date {
2007 return Ok(Vec::new());
2008 }
2009 let from = isect
2010 .head_intersection
2011 .or(isect.tail_intersection)
2012 .ok_or(TkaError::BadChain)?; // Go panics "unreachable"; we fail closed instead.
2013
2014 let Some(state) = compute_state_at(storage, MAX_SYNC_ITER, from)? else {
2015 return Err(TkaError::BadParent);
2016 };
2017 let mut out: Vec<Aum> = Vec::with_capacity(12);
2018 fast_forward(
2019 storage,
2020 MAX_SYNC_ITER,
2021 state,
2022 &mut |curs: &Aum, _: &mut ReplayState| -> Result<bool, TkaError> {
2023 // Gather every AUM from the intersection forward, excluding the intersection itself.
2024 if curs.hash() != from {
2025 out.push(curs.clone());
2026 }
2027 Ok(false) // never stop early; walk to head (no more children)
2028 },
2029 None,
2030 None,
2031 )?;
2032 Ok(out)
2033 }
2034}
2035
2036/// Find where two chains meet (Go `computeSyncIntersection`). See [`Intersection`].
2037fn compute_sync_intersection(
2038 storage: &dyn AumStore,
2039 local_offer: &SyncOffer,
2040 remote_offer: &SyncOffer,
2041) -> Result<Intersection, TkaError> {
2042 // Simple case: identical heads → up to date.
2043 if remote_offer.head == local_offer.head {
2044 return Ok(Intersection {
2045 up_to_date: true,
2046 head_intersection: Some(local_offer.head),
2047 tail_intersection: None,
2048 });
2049 }
2050
2051 // Head intersection: if we hold the remote's head, walk back from our head looking for it. If
2052 // found, their head is an ancestor of ours and we have the AUMs that build on it.
2053 if storage.aum(&remote_offer.head).is_some() {
2054 let mut curs = local_offer.head;
2055 for _ in 0..MAX_SYNC_HEAD_INTERSECTION_ITER {
2056 let Some(parent) = storage.aum(&curs) else {
2057 break; // os.ErrNotExist
2058 };
2059 if parent.hash() == remote_offer.head {
2060 return Ok(Intersection {
2061 up_to_date: false,
2062 head_intersection: Some(parent.hash()),
2063 tail_intersection: None,
2064 });
2065 }
2066 match parent.prev_aum_hash {
2067 Some(prev) => curs = prev,
2068 None => break,
2069 }
2070 }
2071 }
2072
2073 // Tail intersection: we don't recognise their head, but if one of the ancestors they offered is
2074 // on our chain, that's a starting point. Iterate in their order (newest-first) so we pick the
2075 // most-recent shared ancestor and send the fewest AUMs.
2076 for ancestor in &remote_offer.ancestors {
2077 let state = match compute_state_at(storage, MAX_SYNC_ITER, *ancestor)? {
2078 Some(s) => s,
2079 None => continue, // os.ErrNotExist: we don't have this ancestor; try the next
2080 };
2081 let (end, _) = fast_forward(
2082 storage,
2083 MAX_SYNC_ITER,
2084 state,
2085 &mut |_: &Aum, _: &mut ReplayState| Ok(false),
2086 None,
2087 Some(local_offer.head),
2088 )?;
2089 // fast_forward can stop early (no more children) before reaching the target, so re-check.
2090 if end.hash() == local_offer.head {
2091 return Ok(Intersection {
2092 up_to_date: false,
2093 head_intersection: None,
2094 tail_intersection: Some(*ancestor),
2095 });
2096 }
2097 }
2098
2099 Err(TkaError::BadChain) // ErrNoIntersection
2100}
2101
2102/// Verify a standard (RFC 8032, non-cofactored) Ed25519 signature.
2103fn verify_ed25519_std(public: &[u8], msg: &[u8], sig: &[u8]) -> Result<(), TkaError> {
2104 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
2105 let pk: [u8; 32] = public
2106 .try_into()
2107 .map_err(|_| TkaError::Decode("bad pubkey len"))?;
2108 let vk = VerifyingKey::from_bytes(&pk).map_err(|_| TkaError::Decode("bad ed25519 pubkey"))?;
2109 let sig: [u8; 64] = sig
2110 .try_into()
2111 .map_err(|_| TkaError::Decode("bad sig len"))?;
2112 vk.verify(msg, &Signature::from_bytes(&sig))
2113 .map_err(|_| TkaError::BadSignature)
2114}
2115
2116/// Verify a ZIP-215 (cofactored) Ed25519 signature, matching Go `ed25519consensus.Verify`.
2117fn verify_ed25519_zip215(public: &[u8], msg: &[u8], sig: &[u8]) -> Result<(), TkaError> {
2118 let pk: [u8; 32] = public
2119 .try_into()
2120 .map_err(|_| TkaError::Decode("bad pubkey len"))?;
2121 let vk = ed25519_zebra::VerificationKey::try_from(pk)
2122 .map_err(|_| TkaError::Decode("bad ed25519 pubkey"))?;
2123 let sig_bytes: [u8; 64] = sig
2124 .try_into()
2125 .map_err(|_| TkaError::Decode("bad sig len"))?;
2126 let sig = ed25519_zebra::Signature::from(sig_bytes);
2127 vk.verify(&sig, msg).map_err(|_| TkaError::BadSignature)
2128}
2129
2130/// Decode a [`NodeKeySignature`] from canonical CBOR. This is a minimal decoder for the exact map
2131/// shape Go emits (integer keys 1..=6); anything else is rejected (fail-closed).
2132fn decode_node_key_signature(buf: &[u8]) -> Result<NodeKeySignature, TkaError> {
2133 let (val, rest) = decode_value(buf, 0)?;
2134 if !rest.is_empty() {
2135 return Err(TkaError::Decode("trailing bytes after signature"));
2136 }
2137 node_key_signature_from_value(val, 0)
2138}
2139
2140fn node_key_signature_from_value(val: Value, depth: usize) -> Result<NodeKeySignature, TkaError> {
2141 if depth > MAX_SIG_NESTING_DEPTH {
2142 return Err(TkaError::Decode("nested signature too deep"));
2143 }
2144 let Value::IntMap(entries) = val else {
2145 return Err(TkaError::Decode("signature is not an int-keyed map"));
2146 };
2147 let mut sig_kind = None;
2148 let mut pubkey = Vec::new();
2149 let mut key_id = Vec::new();
2150 let mut signature = Vec::new();
2151 let mut nested = None;
2152 let mut wrapping_pubkey = Vec::new();
2153
2154 for (k, v) in entries {
2155 match k {
2156 1 => {
2157 let Value::Uint(n) = v else {
2158 return Err(TkaError::Decode("sig kind not uint"));
2159 };
2160 sig_kind = Some(
2161 SigKind::from_u8(
2162 u8::try_from(n).map_err(|_| TkaError::Decode("sig kind range"))?,
2163 )
2164 .ok_or(TkaError::Decode("unknown sig kind"))?,
2165 );
2166 }
2167 2 => pubkey = expect_bytes(v)?,
2168 3 => key_id = expect_bytes(v)?,
2169 4 => signature = expect_bytes(v)?,
2170 5 => {
2171 nested = Some(alloc::boxed::Box::new(node_key_signature_from_value(
2172 v,
2173 depth + 1,
2174 )?))
2175 }
2176 6 => wrapping_pubkey = expect_bytes(v)?,
2177 _ => return Err(TkaError::Decode("unknown signature field")),
2178 }
2179 }
2180
2181 Ok(NodeKeySignature {
2182 sig_kind: sig_kind.ok_or(TkaError::Decode("signature missing kind"))?,
2183 pubkey,
2184 key_id,
2185 signature,
2186 nested,
2187 wrapping_pubkey,
2188 })
2189}
2190
2191fn expect_bytes(v: Value) -> Result<Vec<u8>, TkaError> {
2192 match v {
2193 Value::Bytes(b) => Ok(b),
2194 _ => Err(TkaError::Decode("expected byte string")),
2195 }
2196}
2197
2198/// A byte string, or an empty `Vec` for CBOR `null` — the decode inverse of [`bytes_or_null`]. Go's
2199/// `fxamacker/cbor` encodes a nil *non*-`omitempty` `[]byte` field as CBOR null (`0xf6`); on decode
2200/// that round-trips back to an empty `Vec` (the field's zero value). Any other CBOR type is rejected.
2201fn expect_bytes_or_null(v: Value) -> Result<Vec<u8>, TkaError> {
2202 match v {
2203 Value::Bytes(b) => Ok(b),
2204 Value::Null => Ok(Vec::new()),
2205 _ => Err(TkaError::Decode("expected byte string or null")),
2206 }
2207}
2208
2209fn expect_uint(v: Value) -> Result<u64, TkaError> {
2210 match v {
2211 Value::Uint(n) => Ok(n),
2212 _ => Err(TkaError::Decode("expected unsigned integer")),
2213 }
2214}
2215
2216/// Decode a `map[string]string` (`Meta`) from a [`Value::TextMap`]. Values must be text strings.
2217fn meta_from_value(
2218 v: Value,
2219) -> Result<Vec<(alloc::string::String, alloc::string::String)>, TkaError> {
2220 let Value::TextMap(entries) = v else {
2221 return Err(TkaError::Decode("meta is not a text-keyed map"));
2222 };
2223 let mut out = Vec::with_capacity(entries.len());
2224 for (k, val) in entries {
2225 let Value::Text(vbytes) = val else {
2226 return Err(TkaError::Decode("meta value not text"));
2227 };
2228 let key = alloc::string::String::from_utf8(k)
2229 .map_err(|_| TkaError::Decode("meta key not utf-8"))?;
2230 let value = alloc::string::String::from_utf8(vbytes)
2231 .map_err(|_| TkaError::Decode("meta value not utf-8"))?;
2232 out.push((key, value));
2233 }
2234 Ok(out)
2235}
2236
2237/// Decode a 32-byte [`AumHash`] from a CBOR byte string of exactly 32 bytes.
2238fn aum_hash_from_bytes(b: Vec<u8>) -> Result<AumHash, TkaError> {
2239 let arr: [u8; AUM_HASH_LEN] = b
2240 .try_into()
2241 .map_err(|_| TkaError::Decode("AUM hash not 32 bytes"))?;
2242 Ok(AumHash(arr))
2243}
2244
2245impl AumKey {
2246 /// Decode an [`AumKey`] from its CBOR value (Go `tka.Key`; keymap `kind`=1, `votes`=2,
2247 /// `public`=3, `meta`=12). The inverse of [`AumKey::to_cbor`]. Only `Key25519` (kind `1`) is
2248 /// supported (the sole [`KeyKind`] this fork models); any other kind is rejected (fail-closed).
2249 fn from_value(v: Value) -> Result<AumKey, TkaError> {
2250 let Value::IntMap(entries) = v else {
2251 return Err(TkaError::Decode("key is not an int-keyed map"));
2252 };
2253 let mut kind = None;
2254 let mut votes = None;
2255 let mut public = None;
2256 let mut meta = Vec::new();
2257 for (k, val) in entries {
2258 match k {
2259 1 => {
2260 kind = Some(match expect_uint(val)? {
2261 1 => KeyKind::Ed25519,
2262 _ => return Err(TkaError::Decode("unsupported key kind")),
2263 })
2264 }
2265 2 => {
2266 votes = Some(
2267 u32::try_from(expect_uint(val)?)
2268 .map_err(|_| TkaError::Decode("key votes out of range"))?,
2269 )
2270 }
2271 3 => public = Some(expect_bytes_or_null(val)?),
2272 12 => meta = meta_from_value(val)?,
2273 _ => return Err(TkaError::Decode("unknown key field")),
2274 }
2275 }
2276 Ok(AumKey {
2277 kind: kind.ok_or(TkaError::Decode("key missing kind"))?,
2278 votes: votes.ok_or(TkaError::Decode("key missing votes"))?,
2279 public: public.ok_or(TkaError::Decode("key missing public"))?,
2280 meta,
2281 })
2282 }
2283}
2284
2285impl AumState {
2286 /// Decode an [`AumState`] from its CBOR value (Go `tka.State`; keymap `last_aum_hash`=1,
2287 /// `disablement_values`=2, `keys`=3, `state_id1`=4, `state_id2`=5). The inverse of
2288 /// [`AumState::to_cbor`]: keys 1/2/3 are non-`omitempty`, so a nil one arrives as CBOR null
2289 /// (`None`); a present array arrives as `Some(vec)` (possibly empty). Keys 4/5 are `omitempty`,
2290 /// defaulting to 0 when absent.
2291 fn from_value(v: Value) -> Result<AumState, TkaError> {
2292 let Value::IntMap(entries) = v else {
2293 return Err(TkaError::Decode("state is not an int-keyed map"));
2294 };
2295 let mut state = AumState::default();
2296 for (k, val) in entries {
2297 match k {
2298 1 => {
2299 state.last_aum_hash = match val {
2300 Value::Null => None,
2301 Value::Bytes(b) => Some(aum_hash_from_bytes(b)?),
2302 _ => return Err(TkaError::Decode("last_aum_hash not bytes or null")),
2303 }
2304 }
2305 2 => {
2306 state.disablement_values = match val {
2307 Value::Null => None,
2308 Value::Array(items) => Some(
2309 items
2310 .into_iter()
2311 .map(expect_bytes)
2312 .collect::<Result<Vec<_>, _>>()?,
2313 ),
2314 _ => return Err(TkaError::Decode("disablement_values not array or null")),
2315 }
2316 }
2317 3 => {
2318 state.keys = match val {
2319 Value::Null => None,
2320 Value::Array(items) => Some(
2321 items
2322 .into_iter()
2323 .map(AumKey::from_value)
2324 .collect::<Result<Vec<_>, _>>()?,
2325 ),
2326 _ => return Err(TkaError::Decode("state keys not array or null")),
2327 }
2328 }
2329 4 => state.state_id1 = expect_uint(val)?,
2330 5 => state.state_id2 = expect_uint(val)?,
2331 _ => return Err(TkaError::Decode("unknown state field")),
2332 }
2333 }
2334 Ok(state)
2335 }
2336}
2337
2338impl AumSignature {
2339 /// Decode an [`AumSignature`] from its CBOR value (Go `tkatype.Signature`; keymap `key_id`=1,
2340 /// `signature`=2, both non-`omitempty` → a nil one is CBOR null → empty `Vec`).
2341 fn from_value(v: Value) -> Result<AumSignature, TkaError> {
2342 let Value::IntMap(entries) = v else {
2343 return Err(TkaError::Decode("signature is not an int-keyed map"));
2344 };
2345 let mut key_id = None;
2346 let mut signature = None;
2347 for (k, val) in entries {
2348 match k {
2349 1 => key_id = Some(expect_bytes_or_null(val)?),
2350 2 => signature = Some(expect_bytes_or_null(val)?),
2351 _ => return Err(TkaError::Decode("unknown AUM signature field")),
2352 }
2353 }
2354 Ok(AumSignature {
2355 key_id: key_id.ok_or(TkaError::Decode("AUM signature missing key_id"))?,
2356 signature: signature.ok_or(TkaError::Decode("AUM signature missing signature"))?,
2357 })
2358 }
2359}
2360
2361impl Aum {
2362 /// Decode an [`Aum`] from its canonical CBOR serialization (the inverse of [`Aum::serialize`] /
2363 /// `Aum::to_cbor`). This is the acquisition primitive a sync/bootstrap path uses to turn the
2364 /// raw `MarshaledAUM` bytes control sends into an [`Aum`] before it is verified and replayed
2365 /// (Go `tka.AUM` CBOR unmarshal).
2366 ///
2367 /// The keymap (Go `cbor:"…,keyasint"`): `message_kind`=1 and `prev_aum_hash`=2 are
2368 /// non-`omitempty` (a nil prev arrives as CBOR null → `None`); `key`=3, `key_id`=4, `state`=5,
2369 /// `votes`=6, `meta`=7, `signatures`=23 are `omitempty` (absent ⇒ the field's zero value).
2370 ///
2371 /// Fail-closed: a trailing byte after the AUM, an unknown field key, a wrong value type, an
2372 /// unknown `message_kind`, or any malformed CBOR head all return [`TkaError::Decode`]. The
2373 /// decoder does NOT re-canonicalize or validate chain structure — that is the verifier's job; it
2374 /// only reconstructs the struct the bytes describe.
2375 ///
2376 /// # Errors
2377 ///
2378 /// Returns [`TkaError::Decode`] if `buf` is not exactly one canonical-shaped AUM CBOR map.
2379 pub fn from_cbor(buf: &[u8]) -> Result<Aum, TkaError> {
2380 let (val, rest) = decode_value(buf, 0)?;
2381 if !rest.is_empty() {
2382 return Err(TkaError::Decode("trailing bytes after AUM"));
2383 }
2384 let Value::IntMap(entries) = val else {
2385 return Err(TkaError::Decode("AUM is not an int-keyed map"));
2386 };
2387 let mut message_kind = None;
2388 let mut prev_aum_hash = None;
2389 let mut have_prev = false;
2390 let mut key = None;
2391 let mut key_id = Vec::new();
2392 let mut state = None;
2393 let mut votes = None;
2394 let mut meta = Vec::new();
2395 let mut signatures = Vec::new();
2396 for (k, v) in entries {
2397 match k {
2398 1 => {
2399 message_kind = Some(
2400 AumKind::from_u8(
2401 u8::try_from(expect_uint(v)?)
2402 .map_err(|_| TkaError::Decode("message kind out of range"))?,
2403 )
2404 .ok_or(TkaError::Decode("unknown AUM message kind"))?,
2405 )
2406 }
2407 2 => {
2408 have_prev = true;
2409 prev_aum_hash = match v {
2410 Value::Null => None,
2411 Value::Bytes(b) => Some(aum_hash_from_bytes(b)?),
2412 _ => return Err(TkaError::Decode("prev_aum_hash not bytes or null")),
2413 }
2414 }
2415 3 => key = Some(AumKey::from_value(v)?),
2416 4 => key_id = expect_bytes_or_null(v)?,
2417 5 => state = Some(AumState::from_value(v)?),
2418 6 => {
2419 votes = Some(
2420 u32::try_from(expect_uint(v)?)
2421 .map_err(|_| TkaError::Decode("votes out of range"))?,
2422 )
2423 }
2424 7 => meta = meta_from_value(v)?,
2425 23 => {
2426 let Value::Array(items) = v else {
2427 return Err(TkaError::Decode("signatures not an array"));
2428 };
2429 signatures = items
2430 .into_iter()
2431 .map(AumSignature::from_value)
2432 .collect::<Result<Vec<_>, _>>()?;
2433 }
2434 _ => return Err(TkaError::Decode("unknown AUM field")),
2435 }
2436 }
2437 // `message_kind` (1) and `prev_aum_hash` (2) are non-`omitempty`: both keys must be present
2438 // on the wire (the prev *value* may be null, but the key itself is always emitted).
2439 if !have_prev {
2440 return Err(TkaError::Decode("AUM missing prev_aum_hash"));
2441 }
2442 Ok(Aum {
2443 message_kind: message_kind.ok_or(TkaError::Decode("AUM missing message kind"))?,
2444 prev_aum_hash,
2445 key,
2446 key_id,
2447 state,
2448 votes,
2449 meta,
2450 signatures,
2451 })
2452 }
2453}
2454
2455/// Decode one CBOR value (the subset the encoder produces) from `buf`, returning the value and the
2456/// remaining bytes. Minimal — only the major types TKA uses.
2457fn decode_value(buf: &[u8], depth: usize) -> Result<(Value, &[u8]), TkaError> {
2458 // Bound generic CBOR container nesting so a deeply-nested array/map (even a non-signature one,
2459 // e.g. an AUM with nested arrays) cannot overflow the recursive decoder before shape validation
2460 // runs. Shared by the AUM and node-key-signature paths, so the message is kept neutral (the
2461 // signature-specific depth guard with its own message lives in `node_key_signature_from_value`).
2462 if depth > MAX_SIG_NESTING_DEPTH {
2463 return Err(TkaError::Decode("CBOR nesting too deep"));
2464 }
2465 let (major, arg, rest) = decode_head(buf)?;
2466 match major {
2467 0 => Ok((Value::Uint(arg), rest)),
2468 2 => {
2469 // `usize::try_from` rather than `as usize`: on a 32-bit target a `u64` length above
2470 // `usize::MAX` must fail closed, not silently truncate to a smaller in-bounds length.
2471 let len = usize::try_from(arg).map_err(|_| TkaError::Decode("byte string too long"))?;
2472 if rest.len() < len {
2473 return Err(TkaError::Decode("byte string truncated"));
2474 }
2475 Ok((Value::Bytes(rest[..len].to_vec()), &rest[len..]))
2476 }
2477 3 => {
2478 let len = usize::try_from(arg).map_err(|_| TkaError::Decode("text string too long"))?;
2479 if rest.len() < len {
2480 return Err(TkaError::Decode("text string truncated"));
2481 }
2482 Ok((Value::Text(rest[..len].to_vec()), &rest[len..]))
2483 }
2484 4 => {
2485 let mut items = Vec::new();
2486 let mut cur = rest;
2487 for _ in 0..arg {
2488 let (v, next) = decode_value(cur, depth + 1)?;
2489 items.push(v);
2490 cur = next;
2491 }
2492 Ok((Value::Array(items), cur))
2493 }
2494 5 => {
2495 // A CBOR map decodes to either an `IntMap` (unsigned-integer keys — the `keyasint`
2496 // structs: AUM, Key, State, signatures) or a `TextMap` (text-string keys — Go
2497 // `map[string]string` `Meta` fields). The variant is chosen by the FIRST key's major
2498 // type and every key must match it: TKA never emits a mixed-key map, so a key whose
2499 // type differs from the first is rejected (fail-closed). An empty map decodes to an
2500 // empty `IntMap` (matching the prior behavior; an empty `map[string]string` is
2501 // `omitempty`-dropped by Go, so an empty map on the wire is always a struct).
2502 decode_map(rest, arg, depth)
2503 }
2504 // Major type 7: only the `null` simple value (`0xf6`, argument 22) is accepted. Go's
2505 // `fxamacker/cbor` emits CBOR null for a nil *non*-`omitempty` byte/slice/pointer field —
2506 // an AUM's genesis `prev_aum_hash`, `AumSignature.{key_id,signature}`, and an `AumState`'s
2507 // `last_aum_hash`/`disablement_values`/`keys` (see the encoder's `Value::Null` arm). Any
2508 // other major-7 simple value or float (booleans, undefined, `f16`/`f32`/`f64`) is rejected:
2509 // TKA never emits them, so accepting them would only widen the attack surface. The
2510 // `NodeKeySignature` path is unaffected — its `expect_bytes` rejects `Value::Null`, so a
2511 // null where bytes are required still fails closed there.
2512 7 if arg == 22 => Ok((Value::Null, rest)),
2513 // Major types 1 (negative int) and 6 (tag), and any other major-7 value, are unsupported.
2514 _ => Err(TkaError::Decode("unsupported CBOR major type")),
2515 }
2516}
2517
2518/// Decode the `count` key/value pairs of a CBOR map (major type 5) from `buf`, producing either a
2519/// [`Value::IntMap`] (unsigned-integer keys) or a [`Value::TextMap`] (text-string keys). The key
2520/// type is fixed by the first pair; every subsequent key must use the same major type (TKA emits no
2521/// mixed-key maps). An empty map decodes to an empty `IntMap`. Duplicate keys are rejected
2522/// (fail-closed), matching the CTAP2 / Go "no duplicate map keys" rule. Map *key ordering is not
2523/// enforced* on decode: the verify path re-serializes canonically before hashing, so a non-canonical
2524/// input simply produces a different (still self-consistent) struct, never a hash that silently
2525/// matches Go's for different bytes.
2526fn decode_map(buf: &[u8], count: u64, depth: usize) -> Result<(Value, &[u8]), TkaError> {
2527 if count == 0 {
2528 return Ok((Value::IntMap(Vec::new()), buf));
2529 }
2530 // Peek the first key's major type to pick the map variant.
2531 let (first_major, ..) = decode_head(buf)?;
2532 match first_major {
2533 0 => {
2534 let mut entries: Vec<(u64, Value)> = Vec::new();
2535 let mut cur = buf;
2536 for _ in 0..count {
2537 let (k, next) = decode_head(cur).and_then(|(m, a, r)| {
2538 if m == 0 {
2539 Ok((a, r))
2540 } else {
2541 Err(TkaError::Decode("mixed map key types"))
2542 }
2543 })?;
2544 let (v, next2) = decode_value(next, depth + 1)?;
2545 entries.push((k, v));
2546 cur = next2;
2547 }
2548 reject_duplicate_keys(entries.iter().map(|(k, _)| *k))?;
2549 Ok((Value::IntMap(entries), cur))
2550 }
2551 3 => {
2552 let mut entries: Vec<(Vec<u8>, Value)> = Vec::new();
2553 let mut cur = buf;
2554 for _ in 0..count {
2555 // Decode the text-string key via the shared value decoder so its length/truncation
2556 // checks apply uniformly, then require it to be `Value::Text`.
2557 let (key_val, next) = decode_value(cur, depth + 1)?;
2558 let Value::Text(k) = key_val else {
2559 return Err(TkaError::Decode("mixed map key types"));
2560 };
2561 let (v, next2) = decode_value(next, depth + 1)?;
2562 entries.push((k, v));
2563 cur = next2;
2564 }
2565 reject_duplicate_keys(entries.iter().map(|(k, _)| k.clone()))?;
2566 Ok((Value::TextMap(entries), cur))
2567 }
2568 _ => Err(TkaError::Decode("map key not uint or text string")),
2569 }
2570}
2571
2572/// Reject a CBOR map with duplicate keys (CTAP2 / Go forbid them) in `O(n log n)` via a sort, rather
2573/// than the `O(n²)` per-insert linear scan a naive decoder uses. The map element count is
2574/// attacker-controlled (a CBOR head can claim a large count), so the quadratic form is a latent
2575/// super-linear CPU-DoS on a hostile control-plane blob; the sort keeps it linear-ish. Insertion
2576/// order of the map itself is preserved by the caller (the verify path re-serializes canonically
2577/// before hashing, so wire order never reaches a hash).
2578fn reject_duplicate_keys<K: Ord>(keys: impl Iterator<Item = K>) -> Result<(), TkaError> {
2579 let mut ks: Vec<K> = keys.collect();
2580 ks.sort_unstable();
2581 if ks.windows(2).any(|w| w[0] == w[1]) {
2582 return Err(TkaError::Decode("duplicate map key"));
2583 }
2584 Ok(())
2585}
2586
2587/// Decode a CBOR head: returns `(major, argument, rest)`.
2588fn decode_head(buf: &[u8]) -> Result<(u8, u64, &[u8]), TkaError> {
2589 let first = *buf.first().ok_or(TkaError::Decode("empty CBOR"))?;
2590 let major = first >> 5;
2591 let info = first & 0x1f;
2592 let rest = &buf[1..];
2593 let (arg, rest) = match info {
2594 n @ 0..=23 => (n as u64, rest),
2595 24 => {
2596 let b = *rest.first().ok_or(TkaError::Decode("truncated u8"))?;
2597 (b as u64, &rest[1..])
2598 }
2599 25 => {
2600 if rest.len() < 2 {
2601 return Err(TkaError::Decode("truncated u16"));
2602 }
2603 (u16::from_be_bytes([rest[0], rest[1]]) as u64, &rest[2..])
2604 }
2605 26 => {
2606 if rest.len() < 4 {
2607 return Err(TkaError::Decode("truncated u32"));
2608 }
2609 (
2610 u32::from_be_bytes([rest[0], rest[1], rest[2], rest[3]]) as u64,
2611 &rest[4..],
2612 )
2613 }
2614 27 => {
2615 if rest.len() < 8 {
2616 return Err(TkaError::Decode("truncated u64"));
2617 }
2618 let mut b = [0u8; 8];
2619 b.copy_from_slice(&rest[..8]);
2620 (u64::from_be_bytes(b), &rest[8..])
2621 }
2622 _ => return Err(TkaError::Decode("indefinite/reserved CBOR length")),
2623 };
2624 Ok((major, arg, rest))
2625}
2626
2627// ----- RFC 4648 base32 (standard alphabet, no padding) -----
2628
2629const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
2630
2631fn base32_encode_nopad(data: &[u8]) -> String {
2632 let mut out = String::new();
2633 let mut buffer: u32 = 0;
2634 let mut bits: u32 = 0;
2635 for &b in data {
2636 buffer = (buffer << 8) | b as u32;
2637 bits += 8;
2638 while bits >= 5 {
2639 bits -= 5;
2640 let idx = ((buffer >> bits) & 0x1f) as usize;
2641 out.push(BASE32_ALPHABET[idx] as char);
2642 }
2643 }
2644 if bits > 0 {
2645 let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
2646 out.push(BASE32_ALPHABET[idx] as char);
2647 }
2648 out
2649}
2650
2651fn base32_decode_nopad(text: &str) -> Option<Vec<u8>> {
2652 let mut buffer: u32 = 0;
2653 let mut bits: u32 = 0;
2654 let mut out = Vec::new();
2655 for c in text.chars() {
2656 let val = match c {
2657 'A'..='Z' => c as u32 - 'A' as u32,
2658 '2'..='7' => c as u32 - '2' as u32 + 26,
2659 _ => return None,
2660 };
2661 buffer = (buffer << 5) | val;
2662 bits += 5;
2663 if bits >= 8 {
2664 bits -= 8;
2665 out.push((buffer >> bits) as u8);
2666 }
2667 }
2668 Some(out)
2669}
2670
2671#[cfg(test)]
2672mod tests {
2673 use super::*;
2674
2675 #[test]
2676 fn base32_roundtrip_32_bytes() {
2677 let h = AumHash([0xABu8; 32]);
2678 let text = h.to_base32();
2679 let back = AumHash::from_base32(&text).unwrap();
2680 assert_eq!(h, back);
2681 }
2682
2683 #[test]
2684 fn base32_rejects_wrong_length() {
2685 // "AAAA" decodes to fewer than 32 bytes.
2686 assert!(AumHash::from_base32("AAAA").is_none());
2687 // Lowercase / invalid alphabet rejected.
2688 assert!(AumHash::from_base32("aaaa").is_none());
2689 }
2690
2691 #[test]
2692 fn base32_matches_known_vector() {
2693 // RFC 4648 base32 of "foobar" is "MZXW6YTBOI" (with padding "======"); no-pad drops the pad.
2694 assert_eq!(base32_encode_nopad(b"foobar"), "MZXW6YTBOI");
2695 assert_eq!(base32_decode_nopad("MZXW6YTBOI").unwrap(), b"foobar");
2696 }
2697
2698 #[test]
2699 fn credential_signature_cannot_authorize() {
2700 let auth = Authority::from_state(AumHash([0; 32]), State::default());
2701 let sig = NodeKeySignature {
2702 sig_kind: SigKind::Credential,
2703 pubkey: alloc::vec![1, 2, 3],
2704 key_id: alloc::vec![4, 5, 6],
2705 signature: alloc::vec![0; 64],
2706 nested: None,
2707 wrapping_pubkey: Vec::new(),
2708 };
2709 let cbor = sig.to_cbor(true).to_vec();
2710 let err = auth.node_key_authorized(&[1, 2, 3], &cbor).unwrap_err();
2711 assert_eq!(err, TkaError::CredentialCannotAuthorize);
2712 }
2713
2714 #[test]
2715 fn untrusted_key_denied() {
2716 // A direct signature whose key id is not in the (empty) trusted state.
2717 let auth = Authority::from_state(AumHash([0; 32]), State::default());
2718 let sig = NodeKeySignature {
2719 sig_kind: SigKind::Direct,
2720 pubkey: alloc::vec![9; 32],
2721 key_id: alloc::vec![7; 32],
2722 signature: alloc::vec![0; 64],
2723 nested: None,
2724 wrapping_pubkey: Vec::new(),
2725 };
2726 let cbor = sig.to_cbor(true).to_vec();
2727 let err = auth.node_key_authorized(&[9; 32], &cbor).unwrap_err();
2728 assert_eq!(err, TkaError::UntrustedKey);
2729 }
2730
2731 #[test]
2732 fn key_trusted_reflects_state() {
2733 let trusted_id = alloc::vec![5u8; 32];
2734 let auth = Authority::from_state(
2735 AumHash([0; 32]),
2736 State {
2737 keys: alloc::vec![Key {
2738 kind: KeyKind::Ed25519,
2739 votes: 1,
2740 public: trusted_id.clone(),
2741 }],
2742 },
2743 );
2744 assert!(auth.key_trusted(&trusted_id), "the seeded key is trusted");
2745 assert!(
2746 !auth.key_trusted(&[9u8; 32]),
2747 "an unknown key id is not trusted"
2748 );
2749 // An empty-state authority trusts nothing.
2750 assert!(
2751 !Authority::from_state(AumHash([0; 32]), State::default()).key_trusted(&trusted_id)
2752 );
2753 }
2754
2755 #[test]
2756 fn direct_signature_verifies_end_to_end() {
2757 use ed25519_dalek::{Signer, SigningKey};
2758
2759 // A trusted Ed25519 key signs a node key directly.
2760 let signing = SigningKey::from_bytes(&[42u8; 32]);
2761 let trusted_pub = signing.verifying_key().to_bytes().to_vec();
2762 let node_key = alloc::vec![7u8; 32];
2763
2764 let trusted = Key {
2765 kind: KeyKind::Ed25519,
2766 votes: 1,
2767 public: trusted_pub.clone(),
2768 };
2769 let auth = Authority::from_state(
2770 AumHash([0; 32]),
2771 State {
2772 keys: alloc::vec![trusted],
2773 },
2774 );
2775
2776 // Build the signature, compute its sig-hash preimage, sign, then fill in the signature.
2777 let mut sig = NodeKeySignature {
2778 sig_kind: SigKind::Direct,
2779 pubkey: node_key.clone(),
2780 key_id: trusted_pub.clone(),
2781 signature: Vec::new(),
2782 nested: None,
2783 wrapping_pubkey: Vec::new(),
2784 };
2785 let sig_hash = sig.sig_hash();
2786 // NOTE: Go verifies Direct with ZIP-215; a standard ed25519-dalek signature is accepted by
2787 // ZIP-215 verification (ZIP-215 is a superset), so signing with dalek here is valid.
2788 sig.signature = signing.sign(&sig_hash).to_bytes().to_vec();
2789
2790 let cbor = sig.to_cbor(true).to_vec();
2791 assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
2792
2793 // A different node key must NOT be authorized by this signature.
2794 let other = alloc::vec![8u8; 32];
2795 assert_eq!(
2796 auth.node_key_authorized(&other, &cbor).unwrap_err(),
2797 TkaError::NodeKeyMismatch
2798 );
2799 }
2800
2801 /// Round-trips the public [`NodeKeySignature::sign_direct`] through our own verify path
2802 /// ([`Authority::node_key_authorized`]): a Direct signature produced by the signer is accepted for
2803 /// the node key it authorizes, rejected for any other (`NodeKeyMismatch`), and rejected when the
2804 /// signer is not a trusted key (`UntrustedKey`). This is the production-signer ↔ production-verifier
2805 /// drift pin for node-key signatures — the counterpart of `direct_signature_verifies_end_to_end`,
2806 /// which builds the signature by hand; here the signer builds it.
2807 #[test]
2808 fn sign_direct_round_trips_through_node_key_authorized() {
2809 use ed25519_dalek::SigningKey;
2810
2811 let signing = SigningKey::from_bytes(&[42u8; 32]);
2812 let trusted_pub = signing.verifying_key().to_bytes().to_vec();
2813 let node_key = alloc::vec![7u8; 32];
2814
2815 let auth = Authority::from_state(
2816 AumHash([0; 32]),
2817 State {
2818 keys: alloc::vec![Key {
2819 kind: KeyKind::Ed25519,
2820 votes: 1,
2821 public: trusted_pub.clone(),
2822 }],
2823 },
2824 );
2825
2826 // The signer builds the whole signature (Direct kind, pubkey = node_key, key_id = signer pub).
2827 let sig = NodeKeySignature::sign_direct(&node_key, &signing);
2828 assert_eq!(sig.sig_kind, SigKind::Direct);
2829 assert_eq!(sig.pubkey, node_key, "authorizes the given node key");
2830 assert_eq!(
2831 sig.key_id, trusted_pub,
2832 "key_id is the signer's pubkey verbatim"
2833 );
2834 assert_eq!(sig.signature.len(), 64, "ed25519 signature is 64 bytes");
2835 assert!(
2836 sig.nested.is_none() && sig.wrapping_pubkey.is_empty(),
2837 "no rotation fields"
2838 );
2839
2840 let cbor = sig.to_cbor(true).to_vec();
2841 // Accepted for the node key it authorizes (dalek signature verified cofactored under ZIP-215).
2842 assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
2843 // Rejected for a different node key.
2844 assert_eq!(
2845 auth.node_key_authorized(&alloc::vec![8u8; 32], &cbor)
2846 .unwrap_err(),
2847 TkaError::NodeKeyMismatch
2848 );
2849
2850 // Signed by a key the authority does not trust → UntrustedKey.
2851 let untrusted = SigningKey::from_bytes(&[99u8; 32]);
2852 let untrusted_sig = NodeKeySignature::sign_direct(&node_key, &untrusted);
2853 assert_eq!(
2854 auth.node_key_authorized(&node_key, &untrusted_sig.to_cbor(true).to_vec())
2855 .unwrap_err(),
2856 TkaError::UntrustedKey
2857 );
2858
2859 // Fail-closed: flip a signature byte → BadSignature.
2860 let mut tampered = NodeKeySignature::sign_direct(&node_key, &signing);
2861 tampered.signature[0] ^= 0x01;
2862 assert_eq!(
2863 auth.node_key_authorized(&node_key, &tampered.to_cbor(true).to_vec())
2864 .unwrap_err(),
2865 TkaError::BadSignature
2866 );
2867 }
2868
2869 /// [`NodeKeySignature::serialize`] is the public raw-CBOR form a node submits to control (the
2870 /// `/machine/tka/sign` body). It must equal the (private) `to_cbor(true)` serialization and
2871 /// round-trip through the decoder back to an equal struct — i.e. it is the decoder's inverse.
2872 #[test]
2873 fn node_key_signature_serialize_round_trips() {
2874 use ed25519_dalek::SigningKey;
2875
2876 let signing = SigningKey::from_bytes(&[42u8; 32]);
2877 let node_key = alloc::vec![7u8; 32];
2878 let sig = NodeKeySignature::sign_direct(&node_key, &signing);
2879
2880 // `serialize()` == `to_cbor(true).to_vec()` (the with-signature form).
2881 let bytes = sig.serialize();
2882 assert_eq!(bytes, sig.to_cbor(true).to_vec());
2883
2884 // Round-trips through the decoder to an equal struct (serialize is the decoder's inverse).
2885 let decoded = decode_node_key_signature(&bytes).expect("serialize output must decode");
2886 assert_eq!(decoded, sig);
2887
2888 // And the serialized bytes are exactly what a trusted authority accepts for this node key.
2889 let auth = Authority::from_state(
2890 AumHash([0; 32]),
2891 State {
2892 keys: alloc::vec![Key {
2893 kind: KeyKind::Ed25519,
2894 votes: 1,
2895 public: signing.verifying_key().to_bytes().to_vec(),
2896 }],
2897 },
2898 );
2899 assert!(auth.node_key_authorized(&node_key, &bytes).is_ok());
2900 }
2901
2902 #[test]
2903 fn tampered_signature_denied() {
2904 use ed25519_dalek::{Signer, SigningKey};
2905
2906 let signing = SigningKey::from_bytes(&[42u8; 32]);
2907 let trusted_pub = signing.verifying_key().to_bytes().to_vec();
2908 let node_key = alloc::vec![7u8; 32];
2909 let auth = Authority::from_state(
2910 AumHash([0; 32]),
2911 State {
2912 keys: alloc::vec![Key {
2913 kind: KeyKind::Ed25519,
2914 votes: 1,
2915 public: trusted_pub.clone(),
2916 }],
2917 },
2918 );
2919 let mut sig = NodeKeySignature {
2920 sig_kind: SigKind::Direct,
2921 pubkey: node_key.clone(),
2922 key_id: trusted_pub,
2923 signature: Vec::new(),
2924 nested: None,
2925 wrapping_pubkey: Vec::new(),
2926 };
2927 let sig_hash = sig.sig_hash();
2928 let mut sigbytes = signing.sign(&sig_hash).to_bytes();
2929 sigbytes[0] ^= 0xff; // tamper
2930 sig.signature = sigbytes.to_vec();
2931
2932 let cbor = sig.to_cbor(true).to_vec();
2933 assert_eq!(
2934 auth.node_key_authorized(&node_key, &cbor).unwrap_err(),
2935 TkaError::BadSignature
2936 );
2937 }
2938
2939 #[test]
2940 fn head_matches_check() {
2941 let h = AumHash([5u8; 32]);
2942 let auth = Authority::from_state(h, State::default());
2943 assert!(auth.head_matches(&h));
2944 assert!(!auth.head_matches(&AumHash([6u8; 32])));
2945 }
2946
2947 // ----- Fix 1: depth cap on attacker-controlled nesting -----
2948
2949 #[test]
2950 fn deeply_nested_signature_rejected_without_overflow() {
2951 // Wrap a NodeKeySignature inside `nested` far past MAX_SIG_NESTING_DEPTH. This is cheap to
2952 // construct (a few bytes per level) and would overflow an unbounded recursive decoder. The
2953 // decoder must reject it as a Decode error — never panic / stack-overflow.
2954 let mut sig = NodeKeySignature {
2955 sig_kind: SigKind::Direct,
2956 pubkey: alloc::vec![1u8; 32],
2957 key_id: alloc::vec![2u8; 32],
2958 signature: alloc::vec![3u8; 64],
2959 nested: None,
2960 wrapping_pubkey: Vec::new(),
2961 };
2962 for _ in 0..(MAX_SIG_NESTING_DEPTH + 8) {
2963 sig = NodeKeySignature {
2964 sig_kind: SigKind::Rotation,
2965 pubkey: alloc::vec![1u8; 32],
2966 key_id: Vec::new(),
2967 signature: alloc::vec![3u8; 64],
2968 nested: Some(alloc::boxed::Box::new(sig)),
2969 wrapping_pubkey: alloc::vec![1u8; 32],
2970 };
2971 }
2972 let cbor = sig.to_cbor(true).to_vec();
2973 let err = decode_node_key_signature(&cbor).unwrap_err();
2974 // The shared generic-container depth guard in `decode_value` trips first (the CBOR is
2975 // nested past the cap before the signature-shape walk runs), so the neutral message.
2976 assert_eq!(err, TkaError::Decode("CBOR nesting too deep"));
2977 }
2978
2979 // ----- Fix 5: duplicate CBOR map keys rejected -----
2980
2981 #[test]
2982 fn duplicate_map_key_rejected() {
2983 // Hand-craft a CBOR map with key 1 repeated: map(2) { 1:0, 1:1 } => 0xa2 01 00 01 01.
2984 let blob = [0xa2u8, 0x01, 0x00, 0x01, 0x01];
2985 let err = decode_node_key_signature(&blob).unwrap_err();
2986 assert_eq!(err, TkaError::Decode("duplicate map key"));
2987 }
2988
2989 // ----- Fix 3: rotation-chain happy path + ZIP-215/std split -----
2990
2991 // ZIP-215 vs standard ed25519 in TKA, and why this crate carries BOTH verifiers:
2992 //
2993 // * Direct/Credential signatures are verified with `verify_ed25519_zip215` (ed25519-zebra),
2994 // matching Go `ed25519consensus.Verify` — the *cofactored* ZIP-215 rule the TKA leaf
2995 // signatures are produced under. ZIP-215 is a strict superset of RFC 8032: any standard
2996 // (dalek) signature is accepted by it, which is why the tests below can sign leaves with
2997 // dalek and still verify under zebra.
2998 // * The outer rotation WRAP signature is verified with `verify_ed25519_std` (ed25519-dalek),
2999 // matching Go's plain `ed25519.Verify` for the rotation wrap. Collapsing these two
3000 // verifiers into one would silently diverge from Go on the wire — hence both deps are kept
3001 // (see Cargo.toml comment).
3002 #[test]
3003 fn rotation_chain_verifies_end_to_end() {
3004 use ed25519_dalek::{Signer, SigningKey};
3005
3006 // Trusted key signs the inner Direct over the wrapping (pivot) pubkey.
3007 let trusted = SigningKey::from_bytes(&[7u8; 32]);
3008 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
3009
3010 // The rotation pivot: a fresh keypair whose public key the inner Direct authorizes and
3011 // whose private key signs the outer rotation wrap.
3012 let wrapping = SigningKey::from_bytes(&[9u8; 32]);
3013 let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
3014
3015 let node_key = alloc::vec![5u8; 32];
3016
3017 let auth = Authority::from_state(
3018 AumHash([0; 32]),
3019 State {
3020 keys: alloc::vec![Key {
3021 kind: KeyKind::Ed25519,
3022 votes: 1,
3023 public: trusted_pub.clone(),
3024 }],
3025 },
3026 );
3027
3028 // Inner Direct: trusted key authorizes the wrapping pubkey. Verified ZIP-215, so a dalek
3029 // signature is accepted.
3030 let mut inner = NodeKeySignature {
3031 sig_kind: SigKind::Direct,
3032 pubkey: wrapping_pub.clone(),
3033 key_id: trusted_pub.clone(),
3034 signature: Vec::new(),
3035 nested: None,
3036 wrapping_pubkey: wrapping_pub.clone(),
3037 };
3038 let inner_hash = inner.sig_hash();
3039 inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
3040
3041 // Outer Rotation: signs the node key with the wrapping key (verified STANDARD ed25519).
3042 let mut outer = NodeKeySignature {
3043 sig_kind: SigKind::Rotation,
3044 pubkey: node_key.clone(),
3045 key_id: Vec::new(),
3046 signature: Vec::new(),
3047 nested: Some(alloc::boxed::Box::new(inner)),
3048 wrapping_pubkey: Vec::new(),
3049 };
3050 let outer_hash = outer.sig_hash();
3051 outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
3052
3053 let cbor = outer.to_cbor(true).to_vec();
3054 assert!(auth.node_key_authorized(&node_key, &cbor).is_ok());
3055
3056 // A tampered rotation-wrap signature must be rejected by the STANDARD ed25519 verifier.
3057 let mut tampered = outer.clone();
3058 let mut sb = tampered.signature.clone();
3059 sb[0] ^= 0xff;
3060 tampered.signature = sb;
3061 let cbor_bad = tampered.to_cbor(true).to_vec();
3062 assert_eq!(
3063 auth.node_key_authorized(&node_key, &cbor_bad).unwrap_err(),
3064 TkaError::BadSignature
3065 );
3066 }
3067
3068 // ----- tsr-358: nested-Credential `pubkey` is UNUSED (Go parity) -----
3069
3070 /// A rotation wrapping a nested **Credential** must verify regardless of the credential's
3071 /// `pubkey` field — Go's `SigCredential` "certifies an indirection key rather than a node key,
3072 /// so there's no need to check the node key", and `verifySignature` adds NO `nested.pubkey ==
3073 /// wrappingPublic` bind. The pre-fix code rejected the real Go shape (empty credential `pubkey`)
3074 /// and only accepted a fork-invented `pubkey == wrapping_pub` construction — a deny-direction
3075 /// consensus split (legitimate credential-provisioned peers wrongly denied under enforce). This
3076 /// pins the Go behavior: empty `pubkey` accepted, and an arbitrary (ignored) `pubkey` also
3077 /// accepted; security comes purely from the two signatures verifying, not from the field.
3078 #[test]
3079 fn rotation_nested_credential_pubkey_is_unused() {
3080 use ed25519_dalek::{Signer, SigningKey};
3081
3082 let trusted = SigningKey::from_bytes(&[11u8; 32]);
3083 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
3084 let wrapping = SigningKey::from_bytes(&[13u8; 32]);
3085 let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
3086 let node_key = alloc::vec![6u8; 32];
3087
3088 let auth = Authority::from_state(
3089 AumHash([0; 32]),
3090 State {
3091 keys: alloc::vec![Key {
3092 kind: KeyKind::Ed25519,
3093 votes: 1,
3094 public: trusted_pub.clone(),
3095 }],
3096 },
3097 );
3098
3099 // Build a rotation wrapping a nested Credential whose `pubkey` is `cred_pubkey`. The
3100 // credential is signed by the trusted key over its own sig-hash; the credential's
3101 // `wrapping_pubkey` is the rotation pivot the outer signature is verified against.
3102 let build = |cred_pubkey: Vec<u8>| -> Vec<u8> {
3103 let mut inner = NodeKeySignature {
3104 sig_kind: SigKind::Credential,
3105 pubkey: cred_pubkey,
3106 key_id: trusted_pub.clone(),
3107 signature: Vec::new(),
3108 nested: None,
3109 wrapping_pubkey: wrapping_pub.clone(),
3110 };
3111 let inner_hash = inner.sig_hash();
3112 inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
3113
3114 let mut outer = NodeKeySignature {
3115 sig_kind: SigKind::Rotation,
3116 pubkey: node_key.clone(),
3117 key_id: Vec::new(),
3118 signature: Vec::new(),
3119 nested: Some(alloc::boxed::Box::new(inner)),
3120 wrapping_pubkey: Vec::new(),
3121 };
3122 let outer_hash = outer.sig_hash();
3123 outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
3124 outer.to_cbor(true).to_vec()
3125 };
3126
3127 // The real Go shape: a SigCredential leaves `Pubkey` EMPTY. Must be accepted.
3128 let cbor_empty = build(Vec::new());
3129 assert!(
3130 auth.node_key_authorized(&node_key, &cbor_empty).is_ok(),
3131 "a credential with empty pubkey (the Go shape) must verify"
3132 );
3133
3134 // An arbitrary credential `pubkey` is IGNORED (Go never checks it) — also accepted.
3135 let cbor_arbitrary = build(alloc::vec![0xaau8; 32]);
3136 assert!(
3137 auth.node_key_authorized(&node_key, &cbor_arbitrary).is_ok(),
3138 "a credential's pubkey is unused; an arbitrary value must not change the verdict"
3139 );
3140
3141 // Sanity: tampering the OUTER rotation signature is still rejected (the real security gate).
3142 let mut outer_bad_cbor = {
3143 let mut inner = NodeKeySignature {
3144 sig_kind: SigKind::Credential,
3145 pubkey: Vec::new(),
3146 key_id: trusted_pub.clone(),
3147 signature: Vec::new(),
3148 nested: None,
3149 wrapping_pubkey: wrapping_pub.clone(),
3150 };
3151 let ih = inner.sig_hash();
3152 inner.signature = trusted.sign(&ih).to_bytes().to_vec();
3153 let mut outer = NodeKeySignature {
3154 sig_kind: SigKind::Rotation,
3155 pubkey: node_key.clone(),
3156 key_id: Vec::new(),
3157 signature: Vec::new(),
3158 nested: Some(alloc::boxed::Box::new(inner)),
3159 wrapping_pubkey: Vec::new(),
3160 };
3161 let oh = outer.sig_hash();
3162 let mut sig = wrapping.sign(&oh).to_bytes().to_vec();
3163 sig[0] ^= 0xff;
3164 outer.signature = sig;
3165 outer.to_cbor(true).to_vec()
3166 };
3167 assert_eq!(
3168 auth.node_key_authorized(&node_key, &outer_bad_cbor)
3169 .unwrap_err(),
3170 TkaError::BadSignature,
3171 "a tampered rotation-wrap signature must still be rejected"
3172 );
3173 outer_bad_cbor.clear();
3174 }
3175
3176 /// Multi-level rotation: an intermediate rotation layer omits its own `wrapping_pubkey`, so the
3177 /// outer signature's verify key must be resolved by RECURSING (`wrapping_public`) into the
3178 /// inner-most layer that defines one — Go `NodeKeySignature.wrappingPublic`. The pre-fix code
3179 /// read `nested.wrapping_pubkey` directly and rejected this with "wrapping pubkey wrong length"
3180 /// (the second deny-direction consensus split).
3181 #[test]
3182 fn multi_level_rotation_resolves_wrapping_key_by_recursion() {
3183 use ed25519_dalek::{Signer, SigningKey};
3184
3185 let trusted = SigningKey::from_bytes(&[21u8; 32]);
3186 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
3187 // The single rotation pivot key, carried only on the INNERMOST signature.
3188 let pivot = SigningKey::from_bytes(&[23u8; 32]);
3189 let pivot_pub = pivot.verifying_key().to_bytes().to_vec();
3190 let node_key = alloc::vec![7u8; 32];
3191
3192 let auth = Authority::from_state(
3193 AumHash([0; 32]),
3194 State {
3195 keys: alloc::vec![Key {
3196 kind: KeyKind::Ed25519,
3197 votes: 1,
3198 public: trusted_pub.clone(),
3199 }],
3200 },
3201 );
3202
3203 // Innermost: a Direct signature by the trusted key, carrying the pivot as its wrapping key.
3204 let mut inner = NodeKeySignature {
3205 sig_kind: SigKind::Direct,
3206 pubkey: pivot_pub.clone(),
3207 key_id: trusted_pub.clone(),
3208 signature: Vec::new(),
3209 nested: None,
3210 wrapping_pubkey: pivot_pub.clone(),
3211 };
3212 let inner_hash = inner.sig_hash();
3213 inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
3214
3215 // Middle rotation: OMITS its own wrapping_pubkey (empty) — wrapping_public must recurse to
3216 // `inner`'s pivot. Its outer-of-inner signature is verified under the pivot key.
3217 let mut middle = NodeKeySignature {
3218 sig_kind: SigKind::Rotation,
3219 pubkey: pivot_pub.clone(),
3220 key_id: Vec::new(),
3221 signature: Vec::new(),
3222 nested: Some(alloc::boxed::Box::new(inner)),
3223 wrapping_pubkey: Vec::new(),
3224 };
3225 let middle_hash = middle.sig_hash();
3226 middle.signature = pivot.sign(&middle_hash).to_bytes().to_vec();
3227
3228 // Outer rotation over `middle`: also resolves its verify key by recursing to the pivot.
3229 let mut outer = NodeKeySignature {
3230 sig_kind: SigKind::Rotation,
3231 pubkey: node_key.clone(),
3232 key_id: Vec::new(),
3233 signature: Vec::new(),
3234 nested: Some(alloc::boxed::Box::new(middle)),
3235 wrapping_pubkey: Vec::new(),
3236 };
3237 let outer_hash = outer.sig_hash();
3238 outer.signature = pivot.sign(&outer_hash).to_bytes().to_vec();
3239
3240 let cbor = outer.to_cbor(true).to_vec();
3241 assert!(
3242 auth.node_key_authorized(&node_key, &cbor).is_ok(),
3243 "a multi-level rotation with an intermediate omitting wrapping_pubkey must verify via recursion"
3244 );
3245 }
3246
3247 // ----- CTAP2-CBOR byte-exactness FROZEN regression vector -----
3248
3249 /// A small hex helper for embedding captured bytes in a failure message.
3250 fn hex(bytes: &[u8]) -> String {
3251 let mut s = String::new();
3252 for b in bytes {
3253 s.push_str(&alloc::format!("{b:02x}"));
3254 }
3255 s
3256 }
3257
3258 /// FROZEN CTAP2-CBOR byte-exactness vector for the wire/signing serialization.
3259 ///
3260 /// The crate docs (and the `cbor` module) state the CTAP2-canonical CBOR encoding is asserted by
3261 /// construction but NOT cross-validated against Go's `fxamacker/cbor` (CTAP2 mode) in this fork.
3262 /// The existing TKA tests build a signature, sign it, and verify round-trip — so they would all
3263 /// still pass if the canonical encoding silently changed (int-map key ordering, smallest-int
3264 /// rule, omitempty), because both sides of the round-trip use the same encoder. That class of
3265 /// change would, however, break wire-compat with a live Go TKA.
3266 ///
3267 /// This pins the EXACT bytes for a fixed `NodeKeySignature` (Direct, deterministic key material):
3268 /// the full `to_cbor(true)` serialization, the `to_cbor(false)` SigHash preimage, the resulting
3269 /// `sig_hash` (BLAKE2s-256 of the preimage), and the `aum_hash` over the full serialization. ANY
3270 /// accidental change to canonical-CBOR encoding or the BLAKE2s digest breaks this test.
3271 ///
3272 /// NOTE: this is a regression-FREEZE vector captured from the current encoder, NOT a Go-sourced
3273 /// cross-vector. It should be replaced with a real `fxamacker/cbor` CTAP2 vector (the same
3274 /// `NodeKeySignature` encoded by a live Go `tka`) once one can be captured.
3275 #[test]
3276 fn node_key_signature_cbor_frozen_vector() {
3277 // Deterministic, fixed key material — NOT random. byte i = i, so the bytes are obvious.
3278 let pubkey: Vec<u8> = (0u8..32).collect();
3279 let key_id: Vec<u8> = (32u8..64).collect();
3280 let signature: Vec<u8> = (64u8..128).collect();
3281
3282 let sig = NodeKeySignature {
3283 sig_kind: SigKind::Direct,
3284 pubkey,
3285 key_id,
3286 signature,
3287 nested: None,
3288 wrapping_pubkey: Vec::new(), // empty -> omitted (omitempty), key 6 must NOT appear
3289 };
3290
3291 // 1. Full serialization (include_signature = true): keys 1,2,3,4 present, 5/6 omitted.
3292 let full = sig.to_cbor(true).to_vec();
3293 const EXPECTED_FULL: &[u8] = &[
3294 0xa4, 0x01, 0x01, 0x02, 0x58, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
3295 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
3296 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x03, 0x58, 0x20, 0x20,
3297 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e,
3298 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
3299 0x3d, 0x3e, 0x3f, 0x04, 0x58, 0x40, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47,
3300 0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55,
3301 0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, 0x61, 0x62, 0x63,
3302 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71,
3303 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f,
3304 ];
3305 assert_eq!(
3306 full,
3307 EXPECTED_FULL,
3308 "full CBOR serialization changed (canonical-CBOR encoding drift). actual: {}",
3309 hex(&full)
3310 );
3311
3312 // 2. SigHash preimage (include_signature = false): key 4 (signature) omitted.
3313 let preimage = sig.to_cbor(false).to_vec();
3314 const EXPECTED_PREIMAGE: &[u8] = &[
3315 0xa3, 0x01, 0x01, 0x02, 0x58, 0x20, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
3316 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
3317 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x03, 0x58, 0x20, 0x20,
3318 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e,
3319 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
3320 0x3d, 0x3e, 0x3f,
3321 ];
3322 assert_eq!(
3323 preimage,
3324 EXPECTED_PREIMAGE,
3325 "SigHash preimage CBOR changed. actual: {}",
3326 hex(&preimage)
3327 );
3328
3329 // 3. sig_hash = BLAKE2s-256(preimage) — pinned.
3330 let sig_hash = sig.sig_hash();
3331 const EXPECTED_SIG_HASH: [u8; AUM_HASH_LEN] = [
3332 0x22, 0x6f, 0x9c, 0xbc, 0x63, 0x73, 0x92, 0x75, 0x2e, 0x0e, 0xb1, 0x32, 0x9c, 0xc4,
3333 0x99, 0x07, 0x01, 0x4a, 0xb6, 0x4f, 0x8e, 0x5d, 0x82, 0x85, 0xc2, 0x91, 0x42, 0x62,
3334 0xf6, 0xa6, 0xa8, 0x33,
3335 ];
3336 assert_eq!(
3337 sig_hash,
3338 EXPECTED_SIG_HASH,
3339 "sig_hash (BLAKE2s-256 of preimage) changed. actual: {}",
3340 hex(&sig_hash)
3341 );
3342
3343 // 4. aum_hash over the full serialization — pinned (exercises the public `aum_hash` helper
3344 // + BLAKE2s digest over a frozen input).
3345 let aum = aum_hash(&full);
3346 const EXPECTED_AUM_HASH: [u8; AUM_HASH_LEN] = [
3347 0xa4, 0x40, 0x71, 0xa3, 0x7a, 0xbf, 0x80, 0x92, 0xd6, 0xff, 0x23, 0x84, 0xb2, 0xb0,
3348 0xa3, 0x50, 0xc7, 0xcb, 0x48, 0x41, 0xed, 0x68, 0x99, 0x62, 0x41, 0x7c, 0xd4, 0x23,
3349 0x68, 0xdc, 0x72, 0x49,
3350 ];
3351 assert_eq!(
3352 aum.0,
3353 EXPECTED_AUM_HASH,
3354 "aum_hash over full serialization changed. actual: {}",
3355 hex(&aum.0)
3356 );
3357 }
3358
3359 // ----- ed25519-speccheck KAT: dual-verifier (dalek std vs zebra ZIP-215) -----
3360
3361 /// Decode an ASCII hex string to bytes. Panics on malformed input (test-only).
3362 fn unhex(s: &str) -> Vec<u8> {
3363 assert!(s.len().is_multiple_of(2), "odd hex length");
3364 let nib = |c: u8| -> u8 {
3365 match c {
3366 b'0'..=b'9' => c - b'0',
3367 b'a'..=b'f' => c - b'a' + 10,
3368 b'A'..=b'F' => c - b'A' + 10,
3369 _ => panic!("bad hex nibble"),
3370 }
3371 };
3372 let b = s.as_bytes();
3373 let mut out = Vec::with_capacity(s.len() / 2);
3374 let mut i = 0;
3375 while i < b.len() {
3376 out.push((nib(b[i]) << 4) | nib(b[i + 1]));
3377 i += 2;
3378 }
3379 out
3380 }
3381
3382 /// The 12 adversarial Ed25519 vectors from `novifinancial/ed25519-speccheck`.
3383 ///
3384 /// Provenance: `cases.json` at commit `65519336fda78a3d016e947df6d82848aca0c9da`
3385 /// (<https://github.com/novifinancial/ed25519-speccheck/blob/main/cases.json>), the canonical
3386 /// generated vectors backing the "Taming the many EdDSAs" paper (IACR 2020/1244, Table 6c).
3387 /// The hex below is copied byte-for-byte from that file; the `message` field is itself hex
3388 /// (the speccheck driver hex-decodes it before verifying), so we decode it the same way.
3389 ///
3390 /// Tuple layout: `(message_hex, pubkey_hex, signature_hex)`.
3391 const SPECCHECK_VECTORS: [(&str, &str, &str); 12] = [
3392 // 0: S = 0; both A and R small-order.
3393 (
3394 "8c93255d71dcab10e8f379c26200f3c7bd5f09d9bc3068d3ef4edeb4853022b6",
3395 "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
3396 "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a0000000000000000000000000000000000000000000000000000000000000000",
3397 ),
3398 // 1: 0 < S < L; small A only.
3399 (
3400 "9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79",
3401 "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
3402 "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43a5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
3403 ),
3404 // 2: 0 < S < L; small R only.
3405 (
3406 "aebf3f2601a0c8c5d39cc7d8911642f740b78168218da8471772b35f9d35b9ab",
3407 "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
3408 "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa8c4bd45aecaca5b24fb97bc10ac27ac8751a7dfe1baff8b953ec9f5833ca260e",
3409 ),
3410 // 3: A and R mixed-order; passes both (unless full-order checked).
3411 (
3412 "9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79",
3413 "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
3414 "9046a64750444938de19f227bb80485e92b83fdb4b6506c160484c016cc1852f87909e14428a7a1d62e9f22f3d3ad7802db02eb2e688b6c52fcd6648a98bd009",
3415 ),
3416 // 4: A and R mixed; passes cofactored, FAILS cofactorless — the cofactored discriminator.
3417 (
3418 "e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c",
3419 "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
3420 "160a1cb0dc9c0258cd0a7d23e94d8fa878bcb1925f2c64246b2dee1796bed5125ec6bc982a269b723e0668e540911a9a6a58921d6925e434ab10aa7940551a09",
3421 ),
3422 // 5: A mixed, R order L; "fails cofactored iff (8h) prereduced".
3423 (
3424 "e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c",
3425 "cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d",
3426 "21122a84e0b5fca4052f5b1235c80a537878b38f3142356b2c2384ebad4668b7e40bc836dac0f71076f9abe3a53f9c03c1ceeeddb658d0030494ace586687405",
3427 ),
3428 // 6: S > L (out of bounds) — malleability vector; std verifier MUST reject.
3429 (
3430 "85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40",
3431 "442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623",
3432 "e96f66be976d82e60150baecff9906684aebb1ef181f67a7189ac78ea23b6c0e547f7690a0e2ddcd04d87dbc3490dc19b3b3052f7ff0538cb68afb369ba3a514",
3433 ),
3434 // 7: S >> L (no canonical serialization with null high bit) — std verifier MUST reject.
3435 (
3436 "85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40",
3437 "442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623",
3438 "8ce5b96c8f26d0ab6c47958c9e68b937104cd36e13c33566acd2fe8d38aa19427e71f98a473474f2f13f06f97c20d58cc3f54b8bd0d272f42b695dd7e89a8c22",
3439 ),
3440 // 8: 0 < S < L; non-canonical R, reduced for hash.
3441 (
3442 "9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41",
3443 "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
3444 "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff03be9678ac102edcd92b0210bb34d7428d12ffc5df5f37e359941266a4e35f0f",
3445 ),
3446 // 9: 0 < S < L; non-canonical R, NOT reduced for hash.
3447 (
3448 "9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41",
3449 "f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43",
3450 "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffca8c5b64cd208982aa38d4936621a4775aa233aa0505711d8fdcfdaa943d4908",
3451 ),
3452 // 10: 0 < S < L; non-canonical A, reduced for hash.
3453 (
3454 "e96b7021eb39c1a163b6da4e3093dcd3f21387da4cc4572be588fafae23c155b",
3455 "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
3456 "a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
3457 ),
3458 // 11: 0 < S < L; non-canonical A, NOT reduced for hash.
3459 (
3460 "39a591f5321bbe07fd5a23dc2f39d025d74526615746727ceefd6e82ae65c06f",
3461 "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
3462 "a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04",
3463 ),
3464 ];
3465
3466 /// Accept(`true`)/reject(`false`) matrix for [`SPECCHECK_VECTORS`] (vectors 0..=11), the SINGLE
3467 /// source of truth shared by BOTH `ed25519_speccheck_dual_verifier_kat` (which asserts the pinned
3468 /// Rust crates produce it) and `ed25519_dual_verifier_matches_go_verdicts` (which asserts Go's
3469 /// real verifiers produce the same matrix). Sharing one const is deliberate: it makes the
3470 /// "crate-observed" and "Go-pinned" expectations PHYSICALLY THE SAME bytes, so a dependency bump
3471 /// that changes crate behavior cannot be silenced by editing the crate-side array to match the
3472 /// new behavior while leaving the Go-side untouched — there is only one array, and changing it
3473 /// re-asserts BOTH the crates AND Go.
3474 ///
3475 /// REGENERATION CONTRACT — read before touching these arrays. They are pinned to `ed25519-dalek
3476 /// 2.2.0` (std, cofactorless) == Go `crypto/ed25519.Verify`, and `ed25519-zebra 4.2.0` (ZIP-215,
3477 /// cofactored) == Go `github.com/hdevalence/ed25519consensus v0.2.0` (toolchain go1.26.4),
3478 /// cross-validated by the generator at `tests/vectors/gen/zip215`. If a `cargo update` bumps
3479 /// `ed25519-dalek` or `ed25519-zebra` and a verdict on any vector changes (most plausibly the
3480 /// non-canonical cases 8..=11), you MUST RE-RUN that Go generator and confirm Go's verdicts STILL
3481 /// MATCH the new crate behavior before editing these constants — never edit them merely to make
3482 /// the Rust side pass, or the Go-equivalence proof becomes a Rust-vs-itself tautology and a
3483 /// Tailnet-Lock consensus split could ship undetected. The SECURITY-CRITICAL rows are NOT
3484 /// version-tunable regardless: STD must reject the S>=L malleability vectors (6,7), and STD/ZIP215
3485 /// MUST disagree on the cofactored discriminator (vector 4) — asserted separately below.
3486 // 0 1 2 3 4 5 6 7 8 9 10 11
3487 const SPECCHECK_STD_ACCEPT: [bool; 12] = [
3488 true, true, true, true, false, false, false, false, false, false, false, true,
3489 ];
3490 const SPECCHECK_ZIP215_ACCEPT: [bool; 12] = [
3491 true, true, true, true, true, true, false, false, false, true, true, true,
3492 ];
3493
3494 /// Known-answer test guarding the dual-verifier split that backs TKA consensus correctness.
3495 ///
3496 /// `verify_ed25519_std` wraps `ed25519-dalek 2.x` (standard RFC-8032-ish, cofactorless) and is
3497 /// used for SigRotation WRAPPING signatures. `verify_ed25519_zip215` wraps `ed25519-zebra 4.x`
3498 /// (ZIP-215 cofactored) and is used for Direct/Credential signatures to match Go
3499 /// `ed25519consensus`. If these two ever collapse to identical behavior, Go wire-compat for
3500 /// Tailnet-Lock silently breaks — this test proves they remain distinct on the adversarial set.
3501 ///
3502 /// The accept/reject matrix is asserted **as actually observed** from the pinned crate versions
3503 /// (`ed25519-dalek 2.2.0`, `ed25519-zebra 4.2.0`). These are newer than the versions tabulated
3504 /// in the "Taming the many EdDSAs" paper (Table 5: dalek 1.0.0-pre.4, zebra 2.1.1), so the
3505 /// non-canonical cases (8–11) may differ from the paper; we lock in current behavior as a
3506 /// regression guard. The SECURITY-CRITICAL invariants are NOT version-tunable: the standard
3507 /// verifier MUST reject the S >= L malleability vectors (6, 7), and the two verifiers MUST
3508 /// disagree on the cofactored discriminator (vector 4). Those are hard, separate assertions.
3509 #[test]
3510 fn ed25519_speccheck_dual_verifier_kat() {
3511 // The accept/reject matrix is the SHARED [`SPECCHECK_STD_ACCEPT`] / [`SPECCHECK_ZIP215_ACCEPT`]
3512 // const — the SAME bytes `ed25519_dual_verifier_matches_go_verdicts` pins to Go. This test
3513 // asserts the pinned Rust crates produce that matrix; that test asserts Go does too. One array,
3514 // so the two proofs can never silently diverge on a dependency bump (see the regeneration
3515 // contract on the const).
3516 const STD_EXPECT: [bool; 12] = SPECCHECK_STD_ACCEPT;
3517 const ZIP215_EXPECT: [bool; 12] = SPECCHECK_ZIP215_ACCEPT;
3518
3519 for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
3520 let msg = unhex(msg_hex);
3521 let pk = unhex(pk_hex);
3522 let sig = unhex(sig_hex);
3523 assert_eq!(pk.len(), 32, "vector {i}: pubkey not 32 bytes");
3524 assert_eq!(sig.len(), 64, "vector {i}: signature not 64 bytes");
3525
3526 let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
3527 let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
3528
3529 assert_eq!(
3530 std_ok, STD_EXPECT[i],
3531 "speccheck vector {i}: verify_ed25519_std accept={std_ok}, expected {}",
3532 STD_EXPECT[i]
3533 );
3534 assert_eq!(
3535 zip_ok, ZIP215_EXPECT[i],
3536 "speccheck vector {i}: verify_ed25519_zip215 accept={zip_ok}, expected {}",
3537 ZIP215_EXPECT[i]
3538 );
3539 }
3540
3541 // SECURITY-CRITICAL invariant (NOT version-tunable): the standard verifier must reject
3542 // signatures whose scalar S is out of range (S >= L). These are vectors 6 and 7 — the
3543 // EdDSA malleability guard. If either ACCEPTS, that is a real security finding.
3544 for &i in &[6usize, 7usize] {
3545 let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[i];
3546 let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
3547 assert!(
3548 verify_ed25519_std(&pk, &msg, &sig).is_err(),
3549 "SECURITY: verify_ed25519_std ACCEPTED S>=L malleability vector {i}"
3550 );
3551 }
3552
3553 // KEY DISCRIMINATOR (vector 4): cofactored (ZIP-215/zebra) accepts, cofactorless
3554 // (standard/dalek) rejects, on the SAME (pk, msg, sig). This proves the dual-verifier
3555 // split is real and not accidentally identical.
3556 {
3557 let (msg_hex, pk_hex, sig_hex) = SPECCHECK_VECTORS[4];
3558 let (msg, pk, sig) = (unhex(msg_hex), unhex(pk_hex), unhex(sig_hex));
3559 assert!(
3560 verify_ed25519_zip215(&pk, &msg, &sig).is_ok(),
3561 "vector 4: ZIP-215 (zebra) should ACCEPT the cofactored discriminator"
3562 );
3563 assert!(
3564 verify_ed25519_std(&pk, &msg, &sig).is_err(),
3565 "vector 4: standard (dalek) should REJECT the cofactored discriminator"
3566 );
3567 }
3568 }
3569
3570 // ----- Cross-implementation KATs against real Go `tailscale.com/tka` v1.100.0 -----
3571
3572 /// Cross-implementation Known-Answer-Test: the CTAP2-CBOR serialization and BLAKE2s-256
3573 /// `SigHash` of three `NodeKeySignature` shapes must byte-match the REAL Go
3574 /// `tailscale.com/tka` package, version **v1.100.0** (toolchain **go1.26.4**).
3575 ///
3576 /// Provenance: the golden bytes below were produced by a Go generator that imports the real
3577 /// upstream `tailscale.com/tka` and calls `NodeKeySignature.Serialize()` (full CBOR including
3578 /// the signature field) and `NodeKeySignature.SigHash()` (BLAKE2s-256 of the CBOR with the
3579 /// `Signature` field nil'd). They are authoritative upstream output, NOT this fork's own
3580 /// encoder echoed back — this is the cross-validation the `node_key_signature_cbor_frozen_vector`
3581 /// freeze-test could not provide. The generator lives alongside the speccheck generator under
3582 /// `tests/vectors/gen` (Go module pinned to `tailscale.com v1.100.0`).
3583 ///
3584 /// Three shapes are covered: a `Direct` leaf, a `Credential` leaf (same fields, different
3585 /// `sigKind`), and a `Rotation` wrapping a nested `Direct` (the rotation-chain wire form). The
3586 /// int-map keys are 1=sigKind, 2=pubkey, 3=keyID, 4=signature, 5=nested, 6=wrappingPubkey;
3587 /// empty byte fields are omitted (`omitempty`).
3588 #[test]
3589 fn tka_cbor_matches_go_golden() {
3590 // Common fixed field material (real Go generator inputs).
3591 let pubkey32 = unhex("a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf");
3592 let key_id32 = unhex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f");
3593 let sig64 = unhex(
3594 "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
3595 );
3596 let wrap32 = unhex("101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f");
3597 let rot_sig64 = unhex(
3598 "55565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f9091929394",
3599 );
3600
3601 // GOLDEN 1 — Direct.
3602 {
3603 let sig = NodeKeySignature {
3604 sig_kind: SigKind::Direct,
3605 pubkey: pubkey32.clone(),
3606 key_id: key_id32.clone(),
3607 signature: sig64.clone(),
3608 nested: None,
3609 wrapping_pubkey: Vec::new(),
3610 };
3611 let full = sig.to_cbor(true).to_vec();
3612 let expected_full = unhex(
3613 "a40101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
3614 );
3615 assert_eq!(
3616 full,
3617 expected_full,
3618 "GOLDEN 1 (Direct) full CBOR diverged from Go tka v1.100.0. actual: {}",
3619 hex(&full)
3620 );
3621 let expected_hash =
3622 unhex("7e9653c97d35485b37b9bf942b1861cd2f3cb0663b5bb154f1178cca72101e74");
3623 assert_eq!(
3624 sig.sig_hash().as_slice(),
3625 expected_hash.as_slice(),
3626 "GOLDEN 1 (Direct) sig_hash diverged from Go tka v1.100.0. actual: {}",
3627 hex(&sig.sig_hash())
3628 );
3629 }
3630
3631 // GOLDEN 2 — Credential (same fields as Direct, sigKind=3).
3632 {
3633 let sig = NodeKeySignature {
3634 sig_kind: SigKind::Credential,
3635 pubkey: pubkey32.clone(),
3636 key_id: key_id32.clone(),
3637 signature: sig64.clone(),
3638 nested: None,
3639 wrapping_pubkey: Vec::new(),
3640 };
3641 let full = sig.to_cbor(true).to_vec();
3642 let expected_full = unhex(
3643 "a40103025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf",
3644 );
3645 assert_eq!(
3646 full,
3647 expected_full,
3648 "GOLDEN 2 (Credential) full CBOR diverged from Go tka v1.100.0. actual: {}",
3649 hex(&full)
3650 );
3651 let expected_hash =
3652 unhex("b6070ea8bc7ae8989ef4293f5031bedaa4a499803ade99f9e2f34dc2898ac03f");
3653 assert_eq!(
3654 sig.sig_hash().as_slice(),
3655 expected_hash.as_slice(),
3656 "GOLDEN 2 (Credential) sig_hash diverged from Go tka v1.100.0. actual: {}",
3657 hex(&sig.sig_hash())
3658 );
3659 }
3660
3661 // GOLDEN 3 — Rotation wrapping a nested Direct.
3662 //
3663 // Decoded from the authoritative Go bytes, the OUTER map is `a4` = 4 entries: keys
3664 // 1=sigKind(Rotation), 2=pubkey(wrap32), 4=signature(rotSig64), 5=nested. The outer has NO
3665 // key 6 (its `wrapping_pubkey` is EMPTY → omitted) and NO key 3 (its `key_id` is EMPTY →
3666 // omitted). The trailing `065820<wrap32>` in the hex belongs to the NESTED Direct map
3667 // (`a5` = 5 entries: keys 1,2,3,4,6), whose `wrapping_pubkey` IS set to wrap32. Constructing
3668 // the structs this way (outer wrapping_pubkey empty, nested wrapping_pubkey=wrap32)
3669 // reproduces the Go bytes exactly.
3670 {
3671 let nested = NodeKeySignature {
3672 sig_kind: SigKind::Direct,
3673 pubkey: pubkey32.clone(),
3674 key_id: key_id32.clone(),
3675 signature: sig64.clone(),
3676 nested: None,
3677 wrapping_pubkey: wrap32.clone(),
3678 };
3679 let sig = NodeKeySignature {
3680 sig_kind: SigKind::Rotation,
3681 pubkey: wrap32.clone(),
3682 key_id: Vec::new(),
3683 signature: rot_sig64.clone(),
3684 nested: Some(alloc::boxed::Box::new(nested)),
3685 wrapping_pubkey: Vec::new(),
3686 };
3687 let full = sig.to_cbor(true).to_vec();
3688 let expected_full = unhex(
3689 "a40102025820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f04584055565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939405a50101025820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf035820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f045840f0f1f2f3f4f5f6f7f8f9fafbfcfdfeffe0e1e2e3e4e5e6e7e8e9eaebecedeeefd0d1d2d3d4d5d6d7d8d9dadbdcdddedfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf065820101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f",
3690 );
3691 assert_eq!(
3692 full,
3693 expected_full,
3694 "GOLDEN 3 (Rotation) full CBOR diverged from Go tka v1.100.0. actual: {}",
3695 hex(&full)
3696 );
3697 let expected_hash =
3698 unhex("fac0a5a6781bb945369c28a0b3d3eea04e1648b60ec1a990a1ff68a9a566e6a7");
3699 assert_eq!(
3700 sig.sig_hash().as_slice(),
3701 expected_hash.as_slice(),
3702 "GOLDEN 3 (Rotation) sig_hash diverged from Go tka v1.100.0. actual: {}",
3703 hex(&sig.sig_hash())
3704 );
3705 }
3706 }
3707
3708 /// Cross-bind the dual Ed25519 verifier accept/reject matrix to the verdicts produced by the
3709 /// REAL Go implementations on the adversarial speccheck set (see [`SPECCHECK_VECTORS`]).
3710 ///
3711 /// Provenance of the Go verdicts: Go `crypto/ed25519.Verify` (standard, cofactorless) and
3712 /// `github.com/hdevalence/ed25519consensus v0.2.0` (ZIP-215, cofactored), toolchain
3713 /// **go1.26.4**, driven by the generator under `tests/vectors/gen/zip215`. These are the SAME
3714 /// verdicts the pinned Rust crates produce — proving `ed25519-dalek 2.x` == Go-std and
3715 /// `ed25519-zebra 4.x` == Go-`ed25519consensus` on the adversarial set. The arrays below MUST
3716 /// therefore equal `STD_EXPECT` / `ZIP215_EXPECT` asserted in
3717 /// `ed25519_speccheck_dual_verifier_kat`; this test additionally pins them to Go's behavior.
3718 ///
3719 /// NOTE: [`SPECCHECK_VECTORS`] is duplicated (byte-for-byte) in the Go generator at
3720 /// `tests/vectors/gen/zip215/main.go`. Both copies derive from the same upstream
3721 /// `cases.json` commit; if you edit one you MUST edit the other, or this proof would compare
3722 /// inputs the Go verdicts were never computed over.
3723 #[test]
3724 fn ed25519_dual_verifier_matches_go_verdicts() {
3725 // Go's verdicts ARE the shared matrix (the SAME const the crate-observed KAT asserts). The
3726 // bindings below alias the shared const rather than re-listing literals, so the Go-pinning
3727 // and the crate-observed expectation are physically one array — a dependency bump cannot
3728 // change one without the other, forcing the regeneration contract (re-run the zip215
3729 // generator) instead of a silent edit. See the const's doc.
3730 const GO_STD_ACCEPT: [bool; 12] = SPECCHECK_STD_ACCEPT;
3731 const GO_ZIP215_ACCEPT: [bool; 12] = SPECCHECK_ZIP215_ACCEPT;
3732
3733 for (i, (msg_hex, pk_hex, sig_hex)) in SPECCHECK_VECTORS.iter().enumerate() {
3734 let msg = unhex(msg_hex);
3735 let pk = unhex(pk_hex);
3736 let sig = unhex(sig_hex);
3737
3738 let std_ok = verify_ed25519_std(&pk, &msg, &sig).is_ok();
3739 let zip_ok = verify_ed25519_zip215(&pk, &msg, &sig).is_ok();
3740
3741 assert_eq!(
3742 std_ok, GO_STD_ACCEPT[i],
3743 "vector {i}: Rust verify_ed25519_std accept={std_ok} disagrees with Go \
3744 crypto/ed25519.Verify={}",
3745 GO_STD_ACCEPT[i]
3746 );
3747 assert_eq!(
3748 zip_ok, GO_ZIP215_ACCEPT[i],
3749 "vector {i}: Rust verify_ed25519_zip215 accept={zip_ok} disagrees with Go \
3750 ed25519consensus.Verify={}",
3751 GO_ZIP215_ACCEPT[i]
3752 );
3753 }
3754 }
3755
3756 /// Byte-exact cross-validation of [`Aum::serialize`] against the literal `[]byte` vectors in Go
3757 /// `tka/aum_test.go` `TestSerialization` (tailscale v1.100.0, fxamacker/cbor v2.9.2 CTAP2 mode).
3758 /// These are the authoritative oracle: if our CTAP2 CBOR diverges from Go by a single byte, the
3759 /// `AUM.Hash` chain links and every signature digest break. Each case reproduces the exact Go
3760 /// `AUM{…}` literal and asserts identical canonical bytes.
3761 #[test]
3762 fn aum_serialize_matches_go_test_serialization_vectors() {
3763 // AddKey: AUM{MessageKind: AUMAddKey, Key: &Key{}}. Go's *zero* Key{} has Kind=0
3764 // (KeyInvalid) and Public=nil, which our `AumKey` (always a valid KeyKind + Vec) cannot
3765 // model — that zero-Key encoding (`03 a3 01 00 02 00 03 f6`) is asserted directly at the
3766 // CBOR layer here, while the AUM keymap around it (map3, kind=AddKey, null prev, Key at
3767 // key 3) is covered by the structural assertions plus the three full vectors below.
3768 let add_key_inner_zero_key = cbor::Value::IntMap(alloc::vec![
3769 (1, cbor::Value::Uint(0)), // Kind = KeyInvalid(0)
3770 (2, cbor::Value::Uint(0)), // Votes = 0
3771 (3, cbor::Value::Null), // Public = nil -> null
3772 ]);
3773 assert_eq!(
3774 add_key_inner_zero_key.to_vec(),
3775 alloc::vec![0xa3, 0x01, 0x00, 0x02, 0x00, 0x03, 0xf6],
3776 "Go's zero Key{{}} encodes as map(3){{kind=0, votes=0, public=null}}"
3777 );
3778
3779 // RemoveKey: AUM{MessageKind: AUMRemoveKey, KeyID: []byte{1, 2}}
3780 let remove_key = Aum {
3781 message_kind: AumKind::RemoveKey,
3782 prev_aum_hash: None,
3783 key: None,
3784 key_id: alloc::vec![1, 2],
3785 state: None,
3786 votes: None,
3787 meta: Vec::new(),
3788 signatures: Vec::new(),
3789 };
3790 assert_eq!(
3791 remove_key.serialize(),
3792 // a3 (map3) 01 02 (kind=RemoveKey) 02 f6 (prev=null) 04 42 01 02 (KeyID=bytes{1,2})
3793 alloc::vec![0xa3, 0x01, 0x02, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02],
3794 "RemoveKey AUM serialization must match Go TestSerialization byte-for-byte"
3795 );
3796
3797 // UpdateKey: AUM{MessageKind: AUMUpdateKey, Votes: &uint(2), KeyID: []byte{1,2},
3798 // Meta: map[string]string{"a":"b"}}
3799 let update_key = Aum {
3800 message_kind: AumKind::UpdateKey,
3801 prev_aum_hash: None,
3802 key: None,
3803 key_id: alloc::vec![1, 2],
3804 state: None,
3805 votes: Some(2),
3806 meta: alloc::vec![("a".into(), "b".into())],
3807 signatures: Vec::new(),
3808 };
3809 assert_eq!(
3810 update_key.serialize(),
3811 // a5 (map5) 01 04 (UpdateKey) 02 f6 (prev null) 04 42 01 02 (KeyID) 06 02 (Votes=2)
3812 // 07 a1 61 61 61 62 (Meta = {"a":"b"}) — keys ascending 1,2,4,6,7
3813 alloc::vec![
3814 0xa5, 0x01, 0x04, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02, 0x06, 0x02, 0x07, 0xa1, 0x61,
3815 0x61, 0x61, 0x62
3816 ],
3817 "UpdateKey AUM serialization must match Go TestSerialization byte-for-byte"
3818 );
3819
3820 // Signature: AUM{MessageKind: AUMAddKey, Signatures: []tkatype.Signature{{KeyID: []byte{1}}}}
3821 let with_sig = Aum {
3822 message_kind: AumKind::AddKey,
3823 prev_aum_hash: None,
3824 key: None,
3825 key_id: Vec::new(),
3826 state: None,
3827 votes: None,
3828 meta: Vec::new(),
3829 signatures: alloc::vec![AumSignature {
3830 key_id: alloc::vec![1],
3831 signature: Vec::new(),
3832 }],
3833 };
3834 assert_eq!(
3835 with_sig.serialize(),
3836 // a3 (map3) 01 01 (AddKey) 02 f6 (prev null) 17 (key 23 = Signatures) 81 (array1)
3837 // a2 (map2) 01 41 01 (Signature.KeyID = bytes{1}) 02 f6 (Signature.Signature = null)
3838 alloc::vec![
3839 0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6
3840 ],
3841 "Signature AUM serialization must match Go TestSerialization (key 23 + nil sig = null)"
3842 );
3843
3844 // sig_hash must drop key 23 (Go SigHash nils Signatures → omitempty): the with_sig AUM's
3845 // sig_hash equals the BLAKE2s of the same AUM with no signatures.
3846 let no_sig = Aum {
3847 signatures: Vec::new(),
3848 ..with_sig.clone()
3849 };
3850 assert_eq!(
3851 with_sig.sig_hash(),
3852 blake2s_256(&no_sig.serialize()),
3853 "SigHash preimage must omit key 23 (Signatures), matching Go AUM.SigHash"
3854 );
3855 // And the full Hash differs from the SigHash (signatures are in the chain-link hash).
3856 assert_ne!(
3857 with_sig.hash().0,
3858 with_sig.sig_hash(),
3859 "Hash (incl. signatures) must differ from SigHash (excl.) when signatures are present"
3860 );
3861 }
3862
3863 /// Checkpoint AUM with an embedded `State`: exercises [`AumState`]/[`AumKey`] CBOR (the 32-byte
3864 /// `LastAUMHash` as a definite-length byte string, the `DisablementValues`/`Keys` arrays, and the
3865 /// `Key.Public` at key 3). Mirrors the structure of Go's `TestSerialization` Checkpoint case.
3866 #[test]
3867 fn aum_checkpoint_state_serialization() {
3868 let checkpoint = Aum {
3869 message_kind: AumKind::Checkpoint,
3870 prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
3871 key: None,
3872 key_id: Vec::new(),
3873 state: Some(AumState {
3874 last_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
3875 disablement_values: Some(Vec::new()),
3876 keys: Some(alloc::vec![AumKey {
3877 kind: KeyKind::Ed25519,
3878 votes: 1,
3879 public: alloc::vec![5, 6],
3880 meta: Vec::new(),
3881 }]),
3882 state_id1: 0,
3883 state_id2: 0,
3884 }),
3885 votes: None,
3886 meta: Vec::new(),
3887 signatures: Vec::new(),
3888 };
3889 let bytes = checkpoint.serialize();
3890 // Spot-check the structurally-load-bearing pieces (full-vector parity is covered by the
3891 // three exact vectors above; here we pin the State/Key encoding shape):
3892 // map3: key1=Checkpoint(5), key2=prev(32-byte bytestring 0x58 0x20 …), key5=State.
3893 assert_eq!(
3894 &bytes[0..3],
3895 &[0xa3, 0x01, 0x05],
3896 "map(3), MessageKind=Checkpoint(5)"
3897 );
3898 assert_eq!(
3899 &bytes[3..6],
3900 &[0x02, 0x58, 0x20],
3901 "key2 prev = 32-byte byte string head"
3902 );
3903 // The embedded State map (key 5) must contain: LastAUMHash (1) as 32-byte bytes, an empty
3904 // DisablementValues array (2 → 0x80), and a Keys array (3 → 0x81 with one Key map).
3905 // Locate the State map head (key 5) after the 32-byte prev hash: 3 + 3 + 32 = offset 38.
3906 assert_eq!(bytes[38], 0x05, "key 5 = State");
3907 // State is a map; its first entry is key1 (LastAUMHash) = 32-byte byte string.
3908 assert_eq!(
3909 &bytes[39..42],
3910 &[0xa3, 0x01, 0x58],
3911 "State map(3), key1 LastAUMHash bytes"
3912 );
3913 // The Key inside Keys carries Public={5,6} at key 3 (…03 42 05 06) and Votes=1 at key 2.
3914 let tail = &bytes[bytes.len() - 4..];
3915 assert_eq!(
3916 tail,
3917 &[0x03, 0x42, 0x05, 0x06],
3918 "Key.Public (key 3) = bytes{{5,6}}"
3919 );
3920 // Round-trips deterministically (hash is stable).
3921 assert_eq!(checkpoint.hash(), checkpoint.hash());
3922 }
3923
3924 // ---- AUM-chain replay (chunk 1B) -----------------------------------------------------------
3925
3926 /// A test trusted key from a seed byte (deterministic public key + given votes).
3927 fn test_aum_key(seed: u8, votes: u32) -> AumKey {
3928 use ed25519_dalek::SigningKey;
3929 let pubk = SigningKey::from_bytes(&[seed; 32])
3930 .verifying_key()
3931 .to_bytes()
3932 .to_vec();
3933 AumKey {
3934 kind: KeyKind::Ed25519,
3935 votes,
3936 public: pubk,
3937 meta: Vec::new(),
3938 }
3939 }
3940
3941 /// A genesis `AUMAddKey` (no parent) adding `key`.
3942 fn genesis_add(key: AumKey) -> Aum {
3943 Aum {
3944 message_kind: AumKind::AddKey,
3945 prev_aum_hash: None,
3946 key: Some(key),
3947 key_id: Vec::new(),
3948 state: None,
3949 votes: None,
3950 meta: Vec::new(),
3951 signatures: Vec::new(),
3952 }
3953 }
3954
3955 /// A child AUM of `parent` of the given kind, optionally carrying a key / key_id.
3956 fn child(parent: &Aum, kind: AumKind, key: Option<AumKey>, key_id: Vec<u8>) -> Aum {
3957 Aum {
3958 message_kind: kind,
3959 prev_aum_hash: Some(parent.hash()),
3960 key,
3961 key_id,
3962 state: None,
3963 votes: None,
3964 meta: Vec::new(),
3965 signatures: Vec::new(),
3966 }
3967 }
3968
3969 /// Linear replay applies each kind: genesis AddKey(k0), AddKey(k1), UpdateKey(k1 votes), then
3970 /// RemoveKey(k0). The final state has only k1 with its updated votes, and head = last AUM hash.
3971 #[test]
3972 fn replay_linear_chain_folds_all_kinds() {
3973 let k0 = test_aum_key(1, 1);
3974 let k1 = test_aum_key(2, 1);
3975
3976 let a0 = genesis_add(k0.clone());
3977 let a1 = child(&a0, AumKind::AddKey, Some(k1.clone()), Vec::new());
3978 let mut a2 = child(&a1, AumKind::UpdateKey, None, k1.public.clone());
3979 a2.votes = Some(5);
3980 let a3 = child(&a2, AumKind::RemoveKey, None, k0.public.clone());
3981
3982 let auth = Authority::from_chain(&[a0, a1, a2, a3.clone()]).unwrap();
3983
3984 // Only k1 remains, with the updated vote weight.
3985 assert_eq!(auth.state().keys.len(), 1, "k0 removed, k1 remains");
3986 let remaining = &auth.state().keys[0];
3987 assert_eq!(remaining.public, k1.public, "k1 is the surviving key");
3988 assert_eq!(remaining.votes, 5, "UpdateKey raised k1's votes to 5");
3989 // Head is the hash of the last applied AUM.
3990 assert_eq!(auth.head(), a3.hash(), "head = last AUM hash");
3991 }
3992
3993 /// Round-trips [`Aum::sign`] through the signature-verifying trust boundary: a genesis `AddKey`
3994 /// AUM self-signed with a raw dalek key (exactly what `Aum::sign` does) is accepted by
3995 /// [`VerifiedAumChain::verify`], whose AUM-leaf check is the cofactored ZIP-215 verifier
3996 /// (`ed25519-zebra`). This pins the relationship the verify path relies on — a standard RFC-8032
3997 /// dalek signature is a valid ZIP-215 signature — but now exercised from OUR signer, so the
3998 /// sign→verify pair cannot silently drift apart. A second, chained `AddKey` signed by the genesis
3999 /// key proves a non-genesis link verifies against the state its parent seeded. Tampering a
4000 /// signature byte fails closed with `BadSignature`; a genesis signed by a key it does not seed is
4001 /// `UntrustedKey`.
4002 ///
4003 /// Goes through `VerifiedAumChain::verify` (the trust boundary), NOT the structural-only
4004 /// `from_chain` — `from_chain` never inspects signatures, so it could not witness `Aum::sign`.
4005 ///
4006 /// The negative assertions here are public-API **smoke-checks**: they prove `Aum::sign`'s output
4007 /// is gated fail-closed by the real verifier. The canonical, exhaustive error-path coverage lives
4008 /// in the dedicated `verified_chain_rejects_*` tests (tampered / forged-untrusted / mis-linked)
4009 /// and in the raw-verifier KATs (`ed25519_speccheck_dual_verifier_kat`, the wycheproof suite);
4010 /// this test is the production-signer↔production-verifier drift pin, not a replacement for those.
4011 #[test]
4012 fn aum_sign_round_trips_through_verified_chain() {
4013 use ed25519_dalek::SigningKey;
4014
4015 // k0 bootstraps the lock: `test_aum_key(1, _)`'s public is the dalek pubkey for seed 1, so
4016 // the matching signing key is `SigningKey::from_bytes(&[1; 32])`.
4017 let k0 = test_aum_key(1, 1);
4018 let sk0 = SigningKey::from_bytes(&[1u8; 32]);
4019 let k1 = test_aum_key(2, 1);
4020 let sk1 = SigningKey::from_bytes(&[2u8; 32]);
4021
4022 // Genesis AddKey(k0), self-signed by k0 — a bootstrapping AddKey is verified against the keys
4023 // it itself seeds, so it must be signed by the key it introduces (see `VerifiedAumChain`).
4024 let mut a0 = genesis_add(k0.clone());
4025 a0.sign(&sk0);
4026 // Child AddKey(k1), signed by the now-trusted k0.
4027 let mut a1 = child(&a0, AumKind::AddKey, Some(k1.clone()), Vec::new());
4028 a1.sign(&sk0);
4029
4030 // The full chain verifies through the trust boundary (dalek signatures accepted by the
4031 // ZIP-215 leaf verifier) and folds to a state trusting both keys.
4032 let chain = VerifiedAumChain::verify(&[a0.clone(), a1.clone()])
4033 .expect("dalek-signed AUM chain must verify under the zebra ZIP-215 leaf verifier");
4034 let auth = Authority::from_verified_chain(chain);
4035 assert_eq!(auth.state().keys.len(), 2, "both k0 and k1 are trusted");
4036 assert_eq!(auth.head(), a1.hash(), "head = last AUM hash");
4037
4038 // A lone self-signed genesis AddKey also round-trips.
4039 VerifiedAumChain::verify(&[a0.clone()]).expect("self-signed genesis AddKey verifies");
4040
4041 // Fail-closed: flip one signature byte → BadSignature, chain rejected.
4042 let mut tampered = a0.clone();
4043 tampered.signatures[0].signature[0] ^= 0x01;
4044 assert_eq!(
4045 VerifiedAumChain::verify(&[tampered]).unwrap_err(),
4046 TkaError::BadSignature,
4047 "a tampered AUM signature must fail the ZIP-215 verify"
4048 );
4049
4050 // A genesis AddKey(k0) signed by k1 (a key it does not seed) is untrusted: the signer's
4051 // key_id is absent from the state the AUM establishes.
4052 let mut wrong_signer = genesis_add(k0.clone());
4053 wrong_signer.sign(&sk1);
4054 assert_eq!(
4055 VerifiedAumChain::verify(&[wrong_signer]).unwrap_err(),
4056 TkaError::UntrustedKey,
4057 "a genesis AddKey signed by a key it does not seed is untrusted"
4058 );
4059
4060 // Pin the two classic adversarial signature SHAPES at the AUM layer (not just at the
4061 // raw-verifier KATs), so a future refactor of `verify_aum_signatures`/`static_validate` can't
4062 // silently weaken the trust boundary. A hand-forged signature with the right key_id but a
4063 // 64-byte all-zero body passes the length gate and fails closed in the ZIP-215 verifier.
4064 let mut zero_sig = genesis_add(k0.clone());
4065 zero_sig.signatures.push(AumSignature {
4066 key_id: k0.public.clone(),
4067 signature: alloc::vec![0u8; 64],
4068 });
4069 assert_eq!(
4070 VerifiedAumChain::verify(&[zero_sig]).unwrap_err(),
4071 TkaError::BadSignature,
4072 "an all-zero 64-byte signature must not verify"
4073 );
4074 // A wrong-length signature is rejected structurally (static_validate) before any crypto.
4075 let mut short_sig = genesis_add(k0.clone());
4076 short_sig.signatures.push(AumSignature {
4077 key_id: k0.public.clone(),
4078 signature: alloc::vec![0u8; 63],
4079 });
4080 assert!(
4081 matches!(
4082 VerifiedAumChain::verify(&[short_sig]).unwrap_err(),
4083 TkaError::Decode(_)
4084 ),
4085 "a malformed (non-64-byte) signature is rejected by static_validate"
4086 );
4087 }
4088
4089 /// A broken chain link (wrong `prev_aum_hash`) is rejected with `BadParent`.
4090 #[test]
4091 fn replay_rejects_broken_parent_link() {
4092 let k0 = test_aum_key(1, 1);
4093 let k1 = test_aum_key(2, 1);
4094 let a0 = genesis_add(k0);
4095 // a1 claims a bogus parent, not a0's hash.
4096 let mut a1 = child(&a0, AumKind::AddKey, Some(k1), Vec::new());
4097 a1.prev_aum_hash = Some(AumHash([0xab; 32]));
4098 assert_eq!(
4099 Authority::from_chain(&[a0, a1]).unwrap_err(),
4100 TkaError::BadParent
4101 );
4102 }
4103
4104 /// AddKey of an already-trusted key, and Remove/Update of an absent key, are rejected.
4105 #[test]
4106 fn replay_rejects_bad_key_state() {
4107 let k0 = test_aum_key(1, 1);
4108 let a0 = genesis_add(k0.clone());
4109 // Duplicate add of k0.
4110 let dup = child(&a0, AumKind::AddKey, Some(k0.clone()), Vec::new());
4111 assert_eq!(
4112 Authority::from_chain(&[a0.clone(), dup]).unwrap_err(),
4113 TkaError::BadKeyState
4114 );
4115 // Remove of a key that was never added.
4116 let absent = test_aum_key(9, 1);
4117 let rm = child(&a0, AumKind::RemoveKey, None, absent.public.clone());
4118 assert_eq!(
4119 Authority::from_chain(&[a0, rm]).unwrap_err(),
4120 TkaError::BadKeyState
4121 );
4122 }
4123
4124 /// An empty chain is rejected.
4125 #[test]
4126 fn replay_empty_chain_is_bad_chain() {
4127 assert_eq!(Authority::from_chain(&[]).unwrap_err(), TkaError::BadChain);
4128 }
4129
4130 /// `weight` sums the votes of distinct trusted signing keys: an unknown signer contributes 0, and
4131 /// a key that signs twice counts once (Go `TestAUMWeight` "Double use" → its votes, not double).
4132 #[test]
4133 fn replay_weight_dedups_and_ignores_unknown() {
4134 let k0 = test_aum_key(1, 2);
4135 let k1 = test_aum_key(2, 3);
4136 let state = ReplayState {
4137 keys: alloc::vec![k0.clone(), k1.clone()],
4138 last_aum_hash: None,
4139 state_id: None,
4140 };
4141
4142 // Empty signatures → 0.
4143 let mut aum = genesis_add(test_aum_key(5, 1));
4144 assert_eq!(state.weight(&aum), 0);
4145
4146 // One known signer (k0, votes 2).
4147 aum.signatures = alloc::vec![AumSignature {
4148 key_id: k0.public.clone(),
4149 signature: Vec::new()
4150 }];
4151 assert_eq!(state.weight(&aum), 2);
4152
4153 // Two distinct known signers → 2 + 3 = 5.
4154 aum.signatures = alloc::vec![
4155 AumSignature {
4156 key_id: k0.public.clone(),
4157 signature: Vec::new()
4158 },
4159 AumSignature {
4160 key_id: k1.public.clone(),
4161 signature: Vec::new()
4162 },
4163 ];
4164 assert_eq!(state.weight(&aum), 5);
4165
4166 // Double-use of k0 → counted once (2), not 4.
4167 aum.signatures = alloc::vec![
4168 AumSignature {
4169 key_id: k0.public.clone(),
4170 signature: Vec::new()
4171 },
4172 AumSignature {
4173 key_id: k0.public.clone(),
4174 signature: Vec::new()
4175 },
4176 ];
4177 assert_eq!(state.weight(&aum), 2, "a key signing twice counts once");
4178
4179 // Unknown signer → 0.
4180 aum.signatures = alloc::vec![AumSignature {
4181 key_id: alloc::vec![0xff; 32],
4182 signature: Vec::new()
4183 }];
4184 assert_eq!(
4185 state.weight(&aum),
4186 0,
4187 "an untrusted signing key contributes no weight"
4188 );
4189 }
4190
4191 /// `pick_next_aum` rule 3 (the deterministic tiebreak): with equal weight (0, no signatures) and
4192 /// neither a RemoveKey, the candidate with the lexicographically-lowest `Hash()` wins —
4193 /// regardless of input order, so two nodes select the same branch.
4194 #[test]
4195 fn pick_next_aum_lowest_hash_tiebreak_is_order_independent() {
4196 let k = test_aum_key(1, 1);
4197 let a0 = genesis_add(k);
4198 // Two distinct NoOp children of a0 (differ by key_id so their hashes differ).
4199 let c1 = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
4200 let c2 = child(&a0, AumKind::NoOp, None, alloc::vec![2]);
4201 let state = ReplayState::default();
4202
4203 let lower = if c1.hash().0 < c2.hash().0 {
4204 c1.hash()
4205 } else {
4206 c2.hash()
4207 };
4208 let ab = [c1.clone(), c2.clone()];
4209 let ba = [c2, c1];
4210 let pick_ab = pick_next_aum(&state, &ab).hash();
4211 let pick_ba = pick_next_aum(&state, &ba).hash();
4212 assert_eq!(pick_ab, lower, "lowest hash wins");
4213 assert_eq!(
4214 pick_ab, pick_ba,
4215 "selection is independent of candidate order"
4216 );
4217 }
4218
4219 /// `pick_next_aum` rule 1 (weight) dominates rule 3 (hash): a signed child with real weight beats
4220 /// an unsigned child even if the unsigned one has a lower hash.
4221 #[test]
4222 fn pick_next_aum_weight_beats_hash() {
4223 use ed25519_dalek::SigningKey;
4224 let signer_seed = 3u8;
4225 let signer_pub = SigningKey::from_bytes(&[signer_seed; 32])
4226 .verifying_key()
4227 .to_bytes()
4228 .to_vec();
4229 let state = ReplayState {
4230 keys: alloc::vec![AumKey {
4231 kind: KeyKind::Ed25519,
4232 votes: 4,
4233 public: signer_pub.clone(),
4234 meta: Vec::new(),
4235 }],
4236 last_aum_hash: None,
4237 state_id: None,
4238 };
4239
4240 let a0 = genesis_add(test_aum_key(1, 1));
4241 let unsigned = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
4242 let mut signed = child(&a0, AumKind::NoOp, None, alloc::vec![2]);
4243 signed.signatures = alloc::vec![AumSignature {
4244 key_id: signer_pub,
4245 signature: Vec::new(),
4246 }];
4247
4248 // The signed child wins on weight (4 > 0) no matter the hash order.
4249 let candidates = [unsigned.clone(), signed.clone()];
4250 let winner = pick_next_aum(&state, &candidates);
4251 assert_eq!(
4252 winner.hash(),
4253 signed.hash(),
4254 "higher weight wins over lower hash"
4255 );
4256 }
4257
4258 /// `from_forked_chain`: a shared genesis, then two competing RemoveKey vs NoOp branches at equal
4259 /// weight — rule 2 prefers the RemoveKey branch. The resulting state reflects the chosen branch.
4260 #[test]
4261 fn forked_chain_prefers_removekey_branch() {
4262 let k0 = test_aum_key(1, 1);
4263 let k1 = test_aum_key(2, 1);
4264 // Genesis adds both keys (two AUMs).
4265 let a0 = genesis_add(k0.clone());
4266 let a1 = child(&a0, AumKind::AddKey, Some(k1.clone()), Vec::new());
4267 // Fork at a1: branch A removes k0; branch B is a NoOp. Equal weight (0 sigs).
4268 let branch_remove = child(&a1, AumKind::RemoveKey, None, k0.public.clone());
4269 let branch_noop = child(&a1, AumKind::NoOp, None, alloc::vec![9]);
4270
4271 let noop_branch = [branch_noop.clone()];
4272 let remove_branch = [branch_remove.clone()];
4273 let auth = Authority::from_forked_chain(&[a0, a1], &[&noop_branch[..], &remove_branch[..]])
4274 .unwrap();
4275
4276 // RemoveKey branch wins → k0 gone, only k1 remains; head = the RemoveKey AUM.
4277 assert_eq!(auth.state().keys.len(), 1);
4278 assert_eq!(auth.state().keys[0].public, k1.public);
4279 assert_eq!(
4280 auth.head(),
4281 branch_remove.hash(),
4282 "active head = RemoveKey branch"
4283 );
4284 }
4285
4286 /// End-to-end: replay a chain to an `Authority`, then verify it authorizes a node key signed by a
4287 /// trusted key — proving the replayed state drives `node_key_authorized` identically to
4288 /// `from_state`. A key removed by the chain no longer authorizes.
4289 #[test]
4290 fn replayed_authority_authorizes_node_end_to_end() {
4291 use ed25519_dalek::{Signer, SigningKey};
4292
4293 let signing = SigningKey::from_bytes(&[77u8; 32]);
4294 let trusted_pub = signing.verifying_key().to_bytes().to_vec();
4295 let trusted = AumKey {
4296 kind: KeyKind::Ed25519,
4297 votes: 1,
4298 public: trusted_pub.clone(),
4299 meta: Vec::new(),
4300 };
4301 // A second key we'll add then remove, to show a removed key can't authorize.
4302 let revoked_signing = SigningKey::from_bytes(&[88u8; 32]);
4303 let revoked_pub = revoked_signing.verifying_key().to_bytes().to_vec();
4304 let revoked = AumKey {
4305 kind: KeyKind::Ed25519,
4306 votes: 1,
4307 public: revoked_pub.clone(),
4308 meta: Vec::new(),
4309 };
4310
4311 let a0 = genesis_add(trusted);
4312 let a1 = child(&a0, AumKind::AddKey, Some(revoked), Vec::new());
4313 let a2 = child(&a1, AumKind::RemoveKey, None, revoked_pub.clone());
4314 let auth = Authority::from_chain(&[a0, a1, a2]).unwrap();
4315
4316 let node_key = alloc::vec![7u8; 32];
4317 // Signature from the still-trusted key authorizes.
4318 let mut sig = NodeKeySignature {
4319 sig_kind: SigKind::Direct,
4320 pubkey: node_key.clone(),
4321 key_id: trusted_pub.clone(),
4322 signature: Vec::new(),
4323 nested: None,
4324 wrapping_pubkey: Vec::new(),
4325 };
4326 sig.signature = signing.sign(&sig.sig_hash()).to_bytes().to_vec();
4327 assert!(
4328 auth.node_key_authorized(&node_key, &sig.to_cbor(true).to_vec())
4329 .is_ok(),
4330 "the replayed authority must authorize a node signed by a still-trusted key"
4331 );
4332
4333 // The same node key signed by the REVOKED key must be rejected (key no longer in state).
4334 let mut bad = NodeKeySignature {
4335 sig_kind: SigKind::Direct,
4336 pubkey: node_key.clone(),
4337 key_id: revoked_pub.clone(),
4338 signature: Vec::new(),
4339 nested: None,
4340 wrapping_pubkey: Vec::new(),
4341 };
4342 bad.signature = revoked_signing.sign(&bad.sig_hash()).to_bytes().to_vec();
4343 assert_eq!(
4344 auth.node_key_authorized(&node_key, &bad.to_cbor(true).to_vec())
4345 .unwrap_err(),
4346 TkaError::UntrustedKey,
4347 "a key the chain removed must not authorize"
4348 );
4349 }
4350
4351 /// Genesis-kind guard (Go `computeStateAt` "invalid genesis update"): a chain whose first AUM is
4352 /// a `RemoveKey`/`UpdateKey` is rejected. (A genesis `NoOp`/`AddKey`/`Checkpoint` is allowed.)
4353 #[test]
4354 fn replay_rejects_invalid_genesis_kind() {
4355 // A bare RemoveKey as genesis: no key to remove → today this is BadKeyState, but the genesis
4356 // guard catches an UpdateKey before the key lookup. Use UpdateKey to exercise the guard arm.
4357 let mut g = genesis_add(test_aum_key(1, 1));
4358 g.message_kind = AumKind::UpdateKey;
4359 g.key = None;
4360 g.key_id = test_aum_key(1, 1).public.clone();
4361 assert_eq!(
4362 Authority::from_chain(&[g]).unwrap_err(),
4363 TkaError::BadChain,
4364 "an UpdateKey cannot be a genesis AUM"
4365 );
4366 }
4367
4368 /// Genesis must carry no parent: a first AUM with a non-None `prev_aum_hash` (i.e. a chain
4369 /// *suffix* mis-supplied as a whole chain) is rejected as `BadParent`, not silently re-rooted.
4370 #[test]
4371 fn replay_rejects_genesis_with_parent() {
4372 let mut g = genesis_add(test_aum_key(1, 1));
4373 g.prev_aum_hash = Some(AumHash([0x11; 32])); // names a parent not in the slice
4374 assert_eq!(
4375 Authority::from_chain(&[g]).unwrap_err(),
4376 TkaError::BadParent,
4377 "a genesis AUM that names a parent must be rejected (not treated as genesis)"
4378 );
4379 }
4380
4381 /// Checkpoint StateID guard (Go "checkpointed state has an incorrect stateID"): a genesis
4382 /// checkpoint seeds the StateID; a later checkpoint with a different StateID is rejected.
4383 #[test]
4384 fn replay_rejects_checkpoint_stateid_mismatch() {
4385 let k = test_aum_key(1, 1);
4386 // Genesis checkpoint seeds StateID (7, 0).
4387 let genesis = Aum {
4388 message_kind: AumKind::Checkpoint,
4389 prev_aum_hash: None,
4390 key: None,
4391 key_id: Vec::new(),
4392 state: Some(AumState {
4393 last_aum_hash: None,
4394 disablement_values: Some(Vec::new()),
4395 keys: Some(alloc::vec![k.clone()]),
4396 state_id1: 7,
4397 state_id2: 0,
4398 }),
4399 votes: None,
4400 meta: Vec::new(),
4401 signatures: Vec::new(),
4402 };
4403 // A second checkpoint, correctly chained, but with a FOREIGN StateID (8, 0).
4404 let bad = Aum {
4405 message_kind: AumKind::Checkpoint,
4406 prev_aum_hash: Some(genesis.hash()),
4407 key: None,
4408 key_id: Vec::new(),
4409 state: Some(AumState {
4410 last_aum_hash: Some(genesis.hash()),
4411 disablement_values: Some(Vec::new()),
4412 keys: Some(alloc::vec![k.clone()]),
4413 state_id1: 8, // ← mismatch
4414 state_id2: 0,
4415 }),
4416 votes: None,
4417 meta: Vec::new(),
4418 signatures: Vec::new(),
4419 };
4420 assert_eq!(
4421 Authority::from_chain(&[genesis.clone(), bad]).unwrap_err(),
4422 TkaError::BadKeyState,
4423 "a checkpoint with a foreign StateID belongs to another authority and must be rejected"
4424 );
4425 // A matching-StateID second checkpoint is accepted.
4426 let ok = Aum {
4427 message_kind: AumKind::Checkpoint,
4428 prev_aum_hash: Some(genesis.hash()),
4429 key: None,
4430 key_id: Vec::new(),
4431 state: Some(AumState {
4432 last_aum_hash: Some(genesis.hash()),
4433 disablement_values: Some(Vec::new()),
4434 keys: Some(alloc::vec![k]),
4435 state_id1: 7,
4436 state_id2: 0,
4437 }),
4438 votes: None,
4439 meta: Vec::new(),
4440 signatures: Vec::new(),
4441 };
4442 assert!(Authority::from_chain(&[genesis, ok]).is_ok());
4443 }
4444
4445 /// `from_forked_chain` rejects a multi-step branch rather than mis-resolving it (Go re-runs
4446 /// pickNextAUM per link; judging a whole branch by its first AUM could diverge).
4447 #[test]
4448 fn forked_chain_rejects_multistep_branch() {
4449 let k0 = test_aum_key(1, 1);
4450 let a0 = genesis_add(k0.clone());
4451 let b1 = child(&a0, AumKind::NoOp, None, alloc::vec![1]);
4452 // A two-AUM branch (b1 → b2): must be rejected as BadChain.
4453 let b2 = child(&b1, AumKind::NoOp, None, alloc::vec![2]);
4454 let single = [child(&a0, AumKind::NoOp, None, alloc::vec![3])];
4455 let multi = [b1, b2];
4456 assert_eq!(
4457 Authority::from_forked_chain(&[a0], &[&single[..], &multi[..]]).unwrap_err(),
4458 TkaError::BadChain,
4459 "a multi-step branch must be rejected, not judged by its first AUM"
4460 );
4461 }
4462
4463 /// Cross-implementation Known-Answer-Test for the **AUM** type: [`Aum::serialize`],
4464 /// [`Aum::hash`] (Go `AUM.Hash`), and [`Aum::sig_hash`] (Go `AUM.SigHash`) must byte-match the
4465 /// REAL `tailscale.com/tka` package, version **v1.100.0** (toolchain **go1.26.3+**).
4466 ///
4467 /// Provenance: every golden below is authoritative upstream output produced by the Go generator
4468 /// at `tests/vectors/gen/tka/main.go` (which imports the real `tailscale.com/tka`, builds one
4469 /// `tka.AUM` per `MessageKind`, and dumps `AUM.Serialize()`/`AUM.Hash()`/`AUM.SigHash()` hex).
4470 /// The same values are committed for provenance at `tests/vectors/tka_aum_hash_golden.json`.
4471 /// This is the missing half of axis-B for AUM: the sibling
4472 /// [`aum_serialize_matches_go_test_serialization_vectors`] test pins Go's *Serialize()* literals
4473 /// from `tka/aum_test.go`, but no Go-produced *AUM.Hash()* digest was pinned until now — so an
4474 /// error in the BLAKE2s-over-canonical-CBOR digest (the value that links the whole chain and is
4475 /// signed) would have gone undetected. Here `hash`/`sig_hash` are pinned to Go directly.
4476 ///
4477 /// Covered kinds: AddKey (genesis, with a real Key25519 + meta), RemoveKey, UpdateKey
4478 /// (votes+meta), a signed AddKey (Signatures at CBOR key 23), and a Checkpoint with a populated
4479 /// `State`. The signed AUM additionally proves `hash() != sig_hash()` — i.e. `Hash()` covers the
4480 /// signatures and `SigHash()` excludes them, exactly as Go's `AUM.SigHash` nils `Signatures`
4481 /// before serializing.
4482 #[test]
4483 fn aum_hash_sighash_matches_go_golden() {
4484 // Deterministic field material — identical to the Go generator's inputs.
4485 let prev = AumHash({
4486 let mut a = [0u8; AUM_HASH_LEN];
4487 let mut i = 0;
4488 while i < AUM_HASH_LEN {
4489 a[i] = 0x20u8.wrapping_add(i as u8);
4490 i += 1;
4491 }
4492 a
4493 });
4494 let key_pub: Vec<u8> = (0..32u16).map(|i| 0x40u8.wrapping_add(i as u8)).collect();
4495 let key_pub2: Vec<u8> = (0..32u16).map(|i| 0x60u8.wrapping_add(i as u8)).collect();
4496 let sig_bytes: Vec<u8> = (0..64u16).map(|i| 0x80u8.wrapping_add(i as u8)).collect();
4497
4498 // Assert one AUM's serialize/hash/sig_hash against the authoritative Go hex.
4499 let check = |label: &str, aum: &Aum, ser_hex: &str, hash_hex: &str, sig_hash_hex: &str| {
4500 assert_eq!(
4501 hex(&aum.serialize()),
4502 ser_hex,
4503 "{label}: Aum::serialize diverged from Go tka v1.100.0"
4504 );
4505 assert_eq!(
4506 hex(&aum.hash().0),
4507 hash_hex,
4508 "{label}: Aum::hash (Go AUM.Hash) diverged from Go tka v1.100.0"
4509 );
4510 assert_eq!(
4511 hex(&aum.sig_hash()),
4512 sig_hash_hex,
4513 "{label}: Aum::sig_hash (Go AUM.SigHash) diverged from Go tka v1.100.0"
4514 );
4515 };
4516
4517 // (a) AddKey genesis (nil prev) with a real Key25519 + meta {"name":"alpha"}.
4518 let add_key = Aum {
4519 message_kind: AumKind::AddKey,
4520 prev_aum_hash: None,
4521 key: Some(AumKey {
4522 kind: KeyKind::Ed25519,
4523 votes: 7,
4524 public: key_pub.clone(),
4525 meta: alloc::vec![("name".into(), "alpha".into())],
4526 }),
4527 key_id: Vec::new(),
4528 state: None,
4529 votes: None,
4530 meta: Vec::new(),
4531 signatures: Vec::new(),
4532 };
4533 check(
4534 "AddKey",
4535 &add_key,
4536 "a3010102f603a401010207035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f0ca1646e616d6565616c706861",
4537 "921ca301077ae2b892ca8c40b3315e5f2a9ccc9ac99eec784e93b323577e1e14",
4538 "921ca301077ae2b892ca8c40b3315e5f2a9ccc9ac99eec784e93b323577e1e14",
4539 );
4540
4541 // (b) RemoveKey with a non-nil prev.
4542 let remove_key = Aum {
4543 message_kind: AumKind::RemoveKey,
4544 prev_aum_hash: Some(prev),
4545 key: None,
4546 key_id: key_pub.clone(),
4547 state: None,
4548 votes: None,
4549 meta: Vec::new(),
4550 signatures: Vec::new(),
4551 };
4552 check(
4553 "RemoveKey",
4554 &remove_key,
4555 "a30102025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f045820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
4556 "46be17c398760d2e649147b06a68e8576ee37c59cf0558046182f48ba20aa912",
4557 "46be17c398760d2e649147b06a68e8576ee37c59cf0558046182f48ba20aa912",
4558 );
4559
4560 // (c) UpdateKey with votes=2 + meta {"role":"ci"}.
4561 let update_key = Aum {
4562 message_kind: AumKind::UpdateKey,
4563 prev_aum_hash: Some(prev),
4564 key: None,
4565 key_id: key_pub.clone(),
4566 state: None,
4567 votes: Some(2),
4568 meta: alloc::vec![("role".into(), "ci".into())],
4569 signatures: Vec::new(),
4570 };
4571 check(
4572 "UpdateKey",
4573 &update_key,
4574 "a50104025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f045820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f060207a164726f6c65626369",
4575 "42d160d81a0922511b4ce60050dba76569f79423b6e721c5040faf414c250e43",
4576 "42d160d81a0922511b4ce60050dba76569f79423b6e721c5040faf414c250e43",
4577 );
4578
4579 // (e) AddKey carrying one Signature (CBOR key 23). hash (incl sigs) MUST differ from
4580 // sig_hash (excl sigs) — the property the whole signing scheme depends on.
4581 let signed = Aum {
4582 message_kind: AumKind::AddKey,
4583 prev_aum_hash: Some(prev),
4584 key: Some(AumKey {
4585 kind: KeyKind::Ed25519,
4586 votes: 1,
4587 public: key_pub.clone(),
4588 meta: Vec::new(),
4589 }),
4590 key_id: Vec::new(),
4591 state: None,
4592 votes: None,
4593 meta: Vec::new(),
4594 signatures: alloc::vec![AumSignature {
4595 key_id: key_pub2.clone(),
4596 signature: sig_bytes.clone(),
4597 }],
4598 };
4599 check(
4600 "AddKey+Signature",
4601 &signed,
4602 "a40101025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f03a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f1781a2015820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f025840808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
4603 "e70332d9a03b205577204f1896bb8dcb7c8f8894cc87a5b5c4d5dabcdf6ef135",
4604 "0a7a0ecdf854ad99e8728a1de89ac23c1f08457132a537a3add9594749a7f536",
4605 );
4606 assert_ne!(
4607 hex(&signed.hash().0),
4608 hex(&signed.sig_hash()),
4609 "Hash() must cover Signatures while SigHash() excludes them (Go AUM.SigHash nils them)"
4610 );
4611
4612 // (f) Checkpoint carrying a State with a POPULATED DisablementValues [{0xaa,0xbb}] + 1 key.
4613 // This is the common real shape and matches Go byte-for-byte (the array encoding is correct).
4614 let checkpoint = Aum {
4615 message_kind: AumKind::Checkpoint,
4616 prev_aum_hash: Some(prev),
4617 key: None,
4618 key_id: Vec::new(),
4619 state: Some(AumState {
4620 last_aum_hash: Some(prev),
4621 disablement_values: Some(alloc::vec![alloc::vec![0xaa, 0xbb]]),
4622 keys: Some(alloc::vec![AumKey {
4623 kind: KeyKind::Ed25519,
4624 votes: 1,
4625 public: key_pub.clone(),
4626 meta: Vec::new(),
4627 }]),
4628 state_id1: 0,
4629 state_id2: 0,
4630 }),
4631 votes: None,
4632 meta: Vec::new(),
4633 signatures: Vec::new(),
4634 };
4635 check(
4636 "Checkpoint(populated DisablementValues)",
4637 &checkpoint,
4638 "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f028142aabb0381a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
4639 "38c35c51580dcb75212d79c07695c8ec8b399b59ba552ea93f080b126fdaa0ae",
4640 "38c35c51580dcb75212d79c07695c8ec8b399b59ba552ea93f080b126fdaa0ae",
4641 );
4642 }
4643
4644 /// Go-match golden for the nil-`DisablementValues` checkpoint — the case that was a recorded
4645 /// interop bug (Rust forced an empty array `0x80` where Go emits CBOR null `0xf6`) and is now
4646 /// FIXED by making `AumState.{disablement_values,keys}` `Option` (None = Go nil = `0xf6`).
4647 ///
4648 /// [`disablement_value`] (Go `tka.DisablementKDF`, Argon2i) byte-for-byte vs the authoritative Go
4649 /// goldens emitted from the real `tailscale.com/tka.DisablementKDF` (v1.100.0). The same values are
4650 /// committed for provenance at `tests/vectors/tka_disablement_golden.json`. A divergence means a
4651 /// lock this node creates via `tka_init` could never be disabled (the operator's secret would
4652 /// hash to a value not in the stored `DisablementValues`) — so this is the load-bearing parity
4653 /// guard for the disablement KDF. Notably proves we use Argon2**i** (RustCrypto defaults to
4654 /// Argon2id, which would produce entirely different digests).
4655 #[test]
4656 fn disablement_value_matches_go_golden() {
4657 let check = |label: &str, secret: &[u8], want_hex: &str| {
4658 assert_eq!(
4659 hex(&disablement_value(secret)),
4660 want_hex,
4661 "{label}: disablement_value diverged from Go tka.DisablementKDF v1.100.0"
4662 );
4663 };
4664 // Goldens straight from `tka.DisablementKDF` (tests/vectors/gen/tka → tka_disablement_golden.json).
4665 check(
4666 "all-0xA5 (tka test-helper secret)",
4667 &[0xA5u8; 32],
4668 "c3fea8a0d70ede2555990ca60d70a8a03cbe627d2c9f3cb0e2ba7093d0884e2f",
4669 );
4670 check(
4671 "all-zero 32B",
4672 &[0u8; 32],
4673 "f56df7e85d257a51c0aa17d2600502182359a1224b892ff4667002a7bc71aa56",
4674 );
4675 check(
4676 "all-0xFF 32B",
4677 &[0xFFu8; 32],
4678 "fe74d82e0971202e69143984381f1834f0f3364e61e239a7d935c218e321811f",
4679 );
4680 // Short secret — the KDF accepts any length (Go feeds 32B but imposes no bound).
4681 check(
4682 "short secret \"hello\"",
4683 b"hello",
4684 "61d3beb9f247d9bc31a677599a919e67332af3cced412722256d6f1cf2adbf8d",
4685 );
4686 // The derived value is exactly DISABLEMENT_LENGTH (32) bytes — the checkpoint validator's
4687 // per-value length requirement is satisfied by construction.
4688 assert_eq!(disablement_value(b"x").len(), DISABLEMENT_LENGTH);
4689 }
4690
4691 /// [`Aum::new_genesis_checkpoint`] builds a valid genesis Checkpoint that, signed by a key it
4692 /// contains (the "lock yourself in" case `tka_init` uses), self-certifies through the trust
4693 /// boundary [`VerifiedAumChain::verify`] — and folds to an authority trusting that key. Also
4694 /// pins the structural guards: empty keys / a wrong-length disablement value are rejected by the
4695 /// builder (it static_validates before returning), and a genesis signed by a key it does NOT
4696 /// contain is UntrustedKey.
4697 #[test]
4698 fn genesis_checkpoint_builds_signs_and_self_certifies() {
4699 use ed25519_dalek::SigningKey;
4700
4701 let sk = SigningKey::from_bytes(&[1u8; 32]);
4702 let pubk = sk.verifying_key().to_bytes().to_vec();
4703 let key = AumKey {
4704 kind: KeyKind::Ed25519,
4705 votes: 1,
4706 public: pubk.clone(),
4707 meta: Vec::new(),
4708 };
4709 let dval = disablement_value(b"the-operator-disablement-secret").to_vec();
4710
4711 // Build + self-sign the genesis with the key it establishes.
4712 let mut genesis =
4713 Aum::new_genesis_checkpoint(alloc::vec![key.clone()], alloc::vec![dval.clone()])
4714 .expect("a 1-key, 1-disablement-value checkpoint is valid");
4715 assert_eq!(genesis.message_kind, AumKind::Checkpoint);
4716 assert!(genesis.prev_aum_hash.is_none(), "genesis has no parent");
4717 // Parity guard for the secret-vs-hash distinction tka_init relies on: the genesis stores the
4718 // Argon2i DISABLEMENT VALUE (the hash), never the raw secret. A swap (storing the raw secret)
4719 // would make the lock undisablable; this pins it. The stored value must equal
4720 // disablement_value(secret) AND differ from the raw secret bytes.
4721 let secret = b"the-operator-disablement-secret";
4722 let stored = genesis
4723 .state
4724 .as_ref()
4725 .unwrap()
4726 .disablement_values
4727 .as_ref()
4728 .unwrap();
4729 assert_eq!(stored.len(), 1);
4730 assert_eq!(
4731 stored[0],
4732 disablement_value(secret).to_vec(),
4733 "genesis must store the Argon2i disablement VALUE (hash), not the raw secret"
4734 );
4735 assert_ne!(
4736 stored[0].as_slice(),
4737 secret.as_slice(),
4738 "the stored value must be the hash, never the raw secret"
4739 );
4740 genesis.sign(&sk);
4741
4742 // Self-certifies through the trust boundary (genesis Checkpoint is verified against the keys
4743 // it embeds), folding to an authority that trusts the key.
4744 let chain = VerifiedAumChain::verify(&[genesis.clone()])
4745 .expect("a genesis checkpoint self-signed by an embedded key must verify");
4746 let auth = Authority::from_verified_chain(chain);
4747 assert_eq!(auth.head(), genesis.hash());
4748 assert!(auth.key_trusted(&pubk), "the seeded key is trusted");
4749
4750 // A genesis signed by a key it does NOT establish is untrusted.
4751 let other = SigningKey::from_bytes(&[2u8; 32]);
4752 let mut wrong = Aum::new_genesis_checkpoint(alloc::vec![key], alloc::vec![dval]).unwrap();
4753 wrong.sign(&other);
4754 assert_eq!(
4755 VerifiedAumChain::verify(&[wrong]).unwrap_err(),
4756 TkaError::UntrustedKey
4757 );
4758
4759 // Builder rejects structurally-invalid genesis up front (static_validate).
4760 assert!(
4761 Aum::new_genesis_checkpoint(Vec::new(), alloc::vec![disablement_value(b"s").to_vec()])
4762 .is_err(),
4763 "a checkpoint with no trusted keys is rejected"
4764 );
4765 assert!(
4766 Aum::new_genesis_checkpoint(
4767 alloc::vec![AumKey {
4768 kind: KeyKind::Ed25519,
4769 votes: 1,
4770 public: alloc::vec![9u8; 32],
4771 meta: Vec::new(),
4772 }],
4773 alloc::vec![alloc::vec![0u8; 31]] // 31 bytes ≠ DISABLEMENT_LENGTH
4774 )
4775 .is_err(),
4776 "a wrong-length disablement value is rejected"
4777 );
4778 }
4779
4780 /// When an `AUMCheckpoint`'s embedded `State` has a **nil** `DisablementValues` (Go's zero value,
4781 /// the overwhelmingly common case), Go's `fxamacker/cbor` CTAP2 encoder emits the field as
4782 /// **CBOR null `0xf6`**; a populated slice encodes as an array (proven by the populated case in
4783 /// [`aum_hash_sighash_matches_go_golden`]). This test pins the Go bytes + Hash for the nil case
4784 /// and asserts the Rust output now byte-matches — guarding the fix against regression.
4785 #[test]
4786 fn aum_checkpoint_nil_disablement_matches_go() {
4787 let prev = AumHash({
4788 let mut a = [0u8; AUM_HASH_LEN];
4789 let mut i = 0;
4790 while i < AUM_HASH_LEN {
4791 a[i] = 0x20u8.wrapping_add(i as u8);
4792 i += 1;
4793 }
4794 a
4795 });
4796 let key_pub: Vec<u8> = (0..32u16).map(|i| 0x40u8.wrapping_add(i as u8)).collect();
4797 let key_pub2: Vec<u8> = (0..32u16).map(|i| 0x60u8.wrapping_add(i as u8)).collect();
4798
4799 // Checkpoint with a State whose DisablementValues is EMPTY (== Go nil zero value), 2 keys.
4800 let checkpoint = Aum {
4801 message_kind: AumKind::Checkpoint,
4802 prev_aum_hash: Some(prev),
4803 key: None,
4804 key_id: Vec::new(),
4805 state: Some(AumState {
4806 last_aum_hash: Some(prev),
4807 disablement_values: Some(Vec::new()),
4808 keys: Some(alloc::vec![
4809 AumKey {
4810 kind: KeyKind::Ed25519,
4811 votes: 1,
4812 public: key_pub.clone(),
4813 meta: Vec::new(),
4814 },
4815 AumKey {
4816 kind: KeyKind::Ed25519,
4817 votes: 3,
4818 public: key_pub2.clone(),
4819 meta: alloc::vec![("k".into(), "v".into())],
4820 },
4821 ]),
4822 state_id1: 0,
4823 state_id2: 0,
4824 }),
4825 votes: None,
4826 meta: Vec::new(),
4827 signatures: Vec::new(),
4828 };
4829
4830 // Authoritative Go bytes (generator case "checkpoint: State w/ nil DisablementValues"):
4831 // the State map is `…02 f6 03 82 …` — a NIL DisablementValues encodes as CBOR null (0xf6).
4832 // FIXED: `AumState.disablement_values` is now `Option`, so the nil case (`None`) is
4833 // representable and encodes as null, byte-matching Go. (Was a recorded interop bug where the
4834 // `Vec` type forced an empty array `0x80` and diverged the checkpoint Hash from Go.)
4835 const GO_SERIALIZE: &str = "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f02f60382a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5fa401010203035820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f0ca1616b6176";
4836 const GO_HASH: &str = "cae17cc938c5a954cd4389d83c6afe4d3487edac38b94824bec3312b82f35710";
4837
4838 // Re-point the checkpoint's State to a genuinely-nil DisablementValues (`None`), which is the
4839 // case the Go golden above was generated from.
4840 let checkpoint = {
4841 let mut c = checkpoint;
4842 if let Some(state) = c.state.as_mut() {
4843 state.disablement_values = None;
4844 }
4845 c
4846 };
4847
4848 assert_eq!(
4849 hex(&checkpoint.serialize()),
4850 GO_SERIALIZE,
4851 "nil DisablementValues must encode as CBOR null (0xf6), byte-matching Go"
4852 );
4853 assert_eq!(
4854 hex(&checkpoint.hash().0),
4855 GO_HASH,
4856 "with the nil-vs-empty fix, the checkpoint chain-link Hash matches Go"
4857 );
4858 }
4859
4860 // =======================================================================================
4861 // MUST-1: AUM signature verification (`VerifiedAumChain` / Go `aumVerify`). The trust
4862 // boundary for a control-supplied chain — an AUM may advance the trusted-key state only if
4863 // every signature on it verifies against a key already trusted at its parent.
4864 // =======================================================================================
4865
4866 /// The signing key whose public key `test_aum_key(seed, _)` derives — so a key trusted via
4867 /// `test_aum_key(seed, v)` can be made to actually sign an AUM.
4868 fn signer_for(seed: u8) -> ed25519_dalek::SigningKey {
4869 ed25519_dalek::SigningKey::from_bytes(&[seed; 32])
4870 }
4871
4872 /// Sign `aum` with each `(seed)` signer, appending a real `AumSignature` over `aum.sig_hash()`.
4873 /// The signer's public key is `test_aum_key(seed, _).public`, so signing with `seed` produces a
4874 /// signature that a state trusting `test_aum_key(seed, _)` will accept.
4875 fn sign_aum(aum: &mut Aum, seeds: &[u8]) {
4876 // Delegate to the production `Aum::sign` so this helper and the public signer can never
4877 // drift. `Aum::sign` recomputes `sig_hash()` per call, which is identical for every signer
4878 // because the preimage omits ALL signatures (CBOR key 23) — appending one signer's signature
4879 // never perturbs the next signer's preimage. Behaviourally identical to the old inline loop.
4880 for &seed in seeds {
4881 aum.sign(&signer_for(seed));
4882 }
4883 }
4884
4885 /// A genesis `AddKey` that adds `test_aum_key(seed, votes)` and is self-signed by that very key
4886 /// — the bootstrapping shape (Go verifies a genesis against the keys it itself establishes).
4887 fn signed_genesis_add(seed: u8, votes: u32) -> Aum {
4888 let mut g = genesis_add(test_aum_key(seed, votes));
4889 sign_aum(&mut g, &[seed]);
4890 g
4891 }
4892
4893 /// Happy path: a self-signed genesis followed by a child signed by the trusted genesis key
4894 /// verifies, and `from_verified_chain` yields the same state as the structural `from_chain`.
4895 #[test]
4896 fn verified_chain_accepts_properly_signed_chain() {
4897 let g = signed_genesis_add(1, 1);
4898 // Child adds a second key, signed by the trusted key from the genesis (seed 1).
4899 let mut a1 = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4900 sign_aum(&mut a1, &[1]);
4901
4902 let chain = [g.clone(), a1.clone()];
4903 let verified = VerifiedAumChain::verify(&chain).expect("a properly signed chain verifies");
4904 let auth = Authority::from_verified_chain(verified);
4905
4906 assert_eq!(auth.head(), a1.hash(), "head = last AUM");
4907 assert_eq!(auth.state().keys.len(), 2, "both keys trusted");
4908 // The verified-path state must equal the structural-path state for an authentic chain.
4909 let structural = Authority::from_chain(&chain).unwrap();
4910 assert_eq!(auth.state(), structural.state());
4911 assert_eq!(auth.head(), structural.head());
4912 }
4913
4914 /// An unsigned AUM (no signatures at all) is rejected — Go `aumVerify` "unsigned AUM". This
4915 /// holds even for the genesis.
4916 #[test]
4917 fn verified_chain_rejects_unsigned_aum() {
4918 // Unsigned genesis.
4919 let g = genesis_add(test_aum_key(1, 1));
4920 assert_eq!(
4921 VerifiedAumChain::verify(core::slice::from_ref(&g)).unwrap_err(),
4922 TkaError::UnsignedAum,
4923 "an unsigned genesis must be rejected"
4924 );
4925
4926 // Signed genesis, but an unsigned child.
4927 let sg = signed_genesis_add(1, 1);
4928 let a1 = child(&sg, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4929 assert_eq!(
4930 VerifiedAumChain::verify(&[sg, a1]).unwrap_err(),
4931 TkaError::UnsignedAum,
4932 "an unsigned non-genesis AUM must be rejected"
4933 );
4934 }
4935
4936 /// THE headline security property: a malicious control plane inserts an `AddKey` that adds the
4937 /// attacker's own key, signed by the attacker (a key NOT trusted in the current state). MUST-1
4938 /// rejects it as `UntrustedKey` — so the forged key never reaches a live `Authority`. Without
4939 /// the signature gate, `from_chain` would happily fold it (demonstrated) — which is exactly the
4940 /// tailnet-lock-defeating forgery the type-enforced `VerifiedAumChain` prevents.
4941 #[test]
4942 fn verified_chain_rejects_forged_addkey_from_untrusted_signer() {
4943 let g = signed_genesis_add(1, 1); // only key seed=1 is trusted
4944 // Attacker forges an AddKey inserting their own key (seed 9), signed by seed 9 (untrusted).
4945 let mut forged = child(&g, AumKind::AddKey, Some(test_aum_key(9, 99)), Vec::new());
4946 sign_aum(&mut forged, &[9]);
4947
4948 assert_eq!(
4949 VerifiedAumChain::verify(&[g.clone(), forged.clone()]).unwrap_err(),
4950 TkaError::UntrustedKey,
4951 "an AddKey signed only by an untrusted key must be rejected"
4952 );
4953 // Contrast: the structural-only `from_chain` (NOT a trust boundary) DOES fold the forgery,
4954 // proving why the type-enforced verified path is necessary.
4955 let structural = Authority::from_chain(&[g, forged]).unwrap();
4956 assert_eq!(
4957 structural.state().keys.len(),
4958 2,
4959 "structural from_chain folds the forged key — exactly why it is not a trust boundary"
4960 );
4961 }
4962
4963 /// A signature whose `key_id` IS trusted but whose bytes were produced over different content
4964 /// (here: signed by the wrong private key but labelled with the trusted key's id) fails the
4965 /// cryptographic check → `BadSignature`.
4966 #[test]
4967 fn verified_chain_rejects_tampered_signature() {
4968 let g = signed_genesis_add(1, 1);
4969 let mut a1 = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4970 // Label the signature with the trusted key's id (seed 1) but sign with the WRONG key.
4971 use ed25519_dalek::Signer;
4972 let wrong = signer_for(42);
4973 a1.signatures.push(AumSignature {
4974 key_id: signer_for(1).verifying_key().to_bytes().to_vec(),
4975 signature: wrong.sign(&a1.sig_hash()).to_bytes().to_vec(),
4976 });
4977 assert_eq!(
4978 VerifiedAumChain::verify(&[g, a1]).unwrap_err(),
4979 TkaError::BadSignature,
4980 "a signature that doesn't verify under the named trusted key is rejected"
4981 );
4982 }
4983
4984 /// Every signature must verify (Go loops over all, failing on the first bad one): a child with
4985 /// one valid trusted signature AND one bad/untrusted signature is still rejected.
4986 #[test]
4987 fn verified_chain_requires_all_signatures_valid() {
4988 let g = signed_genesis_add(1, 1);
4989 let mut a1 = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
4990 // First a valid signature by the trusted key, then a second by an untrusted key.
4991 sign_aum(&mut a1, &[1]); // valid (seed 1 trusted)
4992 sign_aum(&mut a1, &[7]); // untrusted (seed 7 not in state)
4993 assert_eq!(
4994 VerifiedAumChain::verify(&[g, a1]).unwrap_err(),
4995 TkaError::UntrustedKey,
4996 "a single untrusted signature rejects the AUM even alongside a valid one"
4997 );
4998 }
4999
5000 /// A genesis `Checkpoint` self-certifies against the keys it embeds (Go
5001 /// `aumVerify(bootstrap, *bootstrap.State, true)`): the checkpoint's signature must verify
5002 /// against a key inside its own `State`. The embedded `State` must itself be Go-valid (≥1
5003 /// disablement value of 32 bytes, ≥1 key) — `static_validate_checkpoint` enforces that.
5004 #[test]
5005 fn verified_chain_genesis_checkpoint_self_certifies() {
5006 let trusted = test_aum_key(1, 1);
5007 let mut g = Aum {
5008 message_kind: AumKind::Checkpoint,
5009 prev_aum_hash: None,
5010 key: None,
5011 key_id: Vec::new(),
5012 state: Some(AumState {
5013 last_aum_hash: None,
5014 // A valid checkpoint needs ≥1 disablement value, each exactly 32 bytes.
5015 disablement_values: Some(alloc::vec![alloc::vec![0xD5u8; DISABLEMENT_LENGTH]]),
5016 keys: Some(alloc::vec![trusted.clone()]),
5017 state_id1: 0,
5018 state_id2: 0,
5019 }),
5020 votes: None,
5021 meta: Vec::new(),
5022 signatures: Vec::new(),
5023 };
5024 // Unsigned → rejected.
5025 assert_eq!(
5026 VerifiedAumChain::verify(&[g.clone()]).unwrap_err(),
5027 TkaError::UnsignedAum
5028 );
5029 // Signed by the key embedded in its own State → accepted.
5030 sign_aum(&mut g, &[1]);
5031 let verified = VerifiedAumChain::verify(&[g.clone()])
5032 .expect("a checkpoint signed by an embedded key self-certifies");
5033 let auth = Authority::from_verified_chain(verified);
5034 assert_eq!(auth.state().keys.len(), 1);
5035 assert_eq!(auth.head(), g.hash());
5036 }
5037
5038 /// A genesis `Checkpoint` whose embedded `State` is malformed is rejected by
5039 /// `static_validate_checkpoint` (Go `staticValidateCheckpoint`), before any signature check.
5040 #[test]
5041 fn verified_chain_rejects_malformed_checkpoint_state() {
5042 let trusted = test_aum_key(1, 1);
5043 let mk = |state: AumState| {
5044 let mut g = Aum {
5045 message_kind: AumKind::Checkpoint,
5046 prev_aum_hash: None,
5047 key: None,
5048 key_id: Vec::new(),
5049 state: Some(state),
5050 votes: None,
5051 meta: Vec::new(),
5052 signatures: Vec::new(),
5053 };
5054 sign_aum(&mut g, &[1]);
5055 g
5056 };
5057 let base = AumState {
5058 last_aum_hash: None,
5059 disablement_values: Some(alloc::vec![alloc::vec![0xD5u8; DISABLEMENT_LENGTH]]),
5060 keys: Some(alloc::vec![trusted.clone()]),
5061 state_id1: 0,
5062 state_id2: 0,
5063 };
5064
5065 // No disablement values → rejected.
5066 let no_disable = AumState {
5067 disablement_values: None,
5068 ..base.clone()
5069 };
5070 assert_eq!(
5071 VerifiedAumChain::verify(&[mk(no_disable)]).unwrap_err(),
5072 TkaError::BadKeyState,
5073 "a checkpoint with no disablement value is rejected"
5074 );
5075
5076 // Disablement value of the wrong length → rejected.
5077 let bad_len = AumState {
5078 disablement_values: Some(alloc::vec![alloc::vec![0u8; 16]]),
5079 ..base.clone()
5080 };
5081 assert_eq!(
5082 VerifiedAumChain::verify(&[mk(bad_len)]).unwrap_err(),
5083 TkaError::BadKeyState,
5084 "a disablement value of the wrong length is rejected"
5085 );
5086
5087 // No keys → rejected.
5088 let no_keys = AumState {
5089 keys: Some(Vec::new()),
5090 ..base.clone()
5091 };
5092 assert_eq!(
5093 VerifiedAumChain::verify(&[mk(no_keys)]).unwrap_err(),
5094 TkaError::BadKeyState,
5095 "a checkpoint with no keys is rejected"
5096 );
5097
5098 // Duplicate keys → rejected.
5099 let dup_keys = AumState {
5100 keys: Some(alloc::vec![trusted.clone(), trusted.clone()]),
5101 ..base.clone()
5102 };
5103 assert_eq!(
5104 VerifiedAumChain::verify(&[mk(dup_keys)]).unwrap_err(),
5105 TkaError::BadKeyState,
5106 "a checkpoint with duplicate key ids is rejected"
5107 );
5108
5109 // F5: a NON-adjacent duplicate ([a, b, a]) is also caught (the prefix-scan dedup checks all
5110 // earlier elements, not just the neighbor).
5111 let nonadjacent_dup = AumState {
5112 keys: Some(alloc::vec![
5113 test_aum_key(1, 1),
5114 test_aum_key(2, 1),
5115 test_aum_key(1, 1)
5116 ]),
5117 ..base.clone()
5118 };
5119 assert_eq!(
5120 VerifiedAumChain::verify(&[mk(nonadjacent_dup)]).unwrap_err(),
5121 TkaError::BadKeyState,
5122 "a non-adjacent duplicate key id is rejected"
5123 );
5124
5125 // F4: more than MAX_KEYS (512) keys → rejected. Use distinct 32-byte public keys (index-
5126 // encoded) so there are no duplicate ids — only the `> MAX_KEYS` cap can be the failure.
5127 let distinct_over_cap: alloc::vec::Vec<AumKey> = (0u32..=(MAX_KEYS as u32))
5128 .map(|i| AumKey {
5129 kind: KeyKind::Ed25519,
5130 votes: 1,
5131 // Distinct 32-byte public keys by encoding the index — no dup, so only the >512 cap trips.
5132 public: {
5133 let mut p = alloc::vec![0u8; 32];
5134 p[0..4].copy_from_slice(&i.to_le_bytes());
5135 p
5136 },
5137 meta: Vec::new(),
5138 })
5139 .collect();
5140 assert_eq!(distinct_over_cap.len(), MAX_KEYS + 1);
5141 let over_keys = AumState {
5142 keys: Some(distinct_over_cap),
5143 ..base.clone()
5144 };
5145 assert_eq!(
5146 VerifiedAumChain::verify(&[mk(over_keys)]).unwrap_err(),
5147 TkaError::BadKeyState,
5148 "a checkpoint with > MAX_KEYS keys is rejected"
5149 );
5150
5151 // F4: more than MAX_DISABLEMENT_VALUES (32) disablement values → rejected (each distinct).
5152 let over_disablements = AumState {
5153 disablement_values: Some(
5154 (0u8..=(MAX_DISABLEMENT_VALUES as u8))
5155 .map(|i| {
5156 let mut d = alloc::vec![0u8; DISABLEMENT_LENGTH];
5157 d[0] = i;
5158 d
5159 })
5160 .collect(),
5161 ),
5162 ..base
5163 };
5164 assert_eq!(
5165 VerifiedAumChain::verify(&[mk(over_disablements)]).unwrap_err(),
5166 TkaError::BadKeyState,
5167 "a checkpoint with > MAX_DISABLEMENT_VALUES disablement values is rejected"
5168 );
5169 }
5170
5171 /// A broken parent link is still caught on the verified path (the structural fold runs after the
5172 /// signature check for non-genesis AUMs).
5173 #[test]
5174 fn verified_chain_rejects_broken_parent_link() {
5175 let g = signed_genesis_add(1, 1);
5176 let mut orphan = child(&g, AumKind::NoOp, None, alloc::vec![9]);
5177 orphan.prev_aum_hash = Some(AumHash([0xAB; 32])); // wrong parent
5178 sign_aum(&mut orphan, &[1]); // validly signed, but mis-linked
5179 assert_eq!(
5180 VerifiedAumChain::verify(&[g, orphan]).unwrap_err(),
5181 TkaError::BadParent,
5182 "a validly-signed but mis-linked AUM is still rejected"
5183 );
5184 }
5185
5186 // ===== Aum::from_cbor — the decode inverse of Aum::serialize (issue #7 chunk 2, tsr-2dr) =====
5187
5188 /// `Aum::from_cbor(aum.serialize())` reconstructs the exact `Aum` for every message kind and
5189 /// optional-field combination. This is the core round-trip contract the sync/bootstrap path
5190 /// relies on: bytes control sends → `Aum` → verify/replay.
5191 #[test]
5192 fn aum_from_cbor_roundtrips_every_shape() {
5193 let cases: alloc::vec::Vec<(&str, Aum)> = alloc::vec![
5194 (
5195 "RemoveKey, genesis (null prev), key_id",
5196 Aum {
5197 message_kind: AumKind::RemoveKey,
5198 prev_aum_hash: None,
5199 key: None,
5200 key_id: alloc::vec![1, 2],
5201 state: None,
5202 votes: None,
5203 meta: Vec::new(),
5204 signatures: Vec::new(),
5205 },
5206 ),
5207 (
5208 "UpdateKey with votes + meta (text-keyed map)",
5209 Aum {
5210 message_kind: AumKind::UpdateKey,
5211 prev_aum_hash: None,
5212 key: None,
5213 key_id: alloc::vec![1, 2],
5214 state: None,
5215 votes: Some(2),
5216 meta: alloc::vec![("a".into(), "b".into())],
5217 signatures: Vec::new(),
5218 },
5219 ),
5220 (
5221 "AddKey with an embedded Key + non-null prev + signatures",
5222 Aum {
5223 message_kind: AumKind::AddKey,
5224 prev_aum_hash: Some(AumHash([0x11; AUM_HASH_LEN])),
5225 key: Some(AumKey {
5226 kind: KeyKind::Ed25519,
5227 votes: 3,
5228 public: alloc::vec![9, 8, 7],
5229 meta: alloc::vec![("k".into(), "v".into())],
5230 }),
5231 key_id: Vec::new(),
5232 state: None,
5233 votes: None,
5234 meta: Vec::new(),
5235 signatures: alloc::vec![
5236 AumSignature {
5237 key_id: alloc::vec![1],
5238 signature: Vec::new(), // nil → null on the wire
5239 },
5240 AumSignature {
5241 key_id: alloc::vec![2, 3],
5242 signature: alloc::vec![4, 5, 6],
5243 },
5244 ],
5245 },
5246 ),
5247 (
5248 "Checkpoint with full State (null + empty-array + populated arms)",
5249 Aum {
5250 message_kind: AumKind::Checkpoint,
5251 prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
5252 key: None,
5253 key_id: Vec::new(),
5254 state: Some(AumState {
5255 last_aum_hash: Some(AumHash([0xAB; AUM_HASH_LEN])),
5256 disablement_values: Some(alloc::vec![alloc::vec![1, 2], alloc::vec![3]]),
5257 keys: Some(alloc::vec![AumKey {
5258 kind: KeyKind::Ed25519,
5259 votes: 1,
5260 public: alloc::vec![5, 6],
5261 meta: Vec::new(),
5262 }]),
5263 state_id1: 7,
5264 state_id2: 0, // omitted (omitempty)
5265 }),
5266 votes: None,
5267 meta: Vec::new(),
5268 signatures: Vec::new(),
5269 },
5270 ),
5271 (
5272 "Checkpoint with nil State arms (null) and empty disablement array",
5273 Aum {
5274 message_kind: AumKind::Checkpoint,
5275 prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
5276 key: None,
5277 key_id: Vec::new(),
5278 state: Some(AumState {
5279 last_aum_hash: None, // null
5280 disablement_values: Some(Vec::new()), // empty array 0x80
5281 keys: None, // null
5282 state_id1: 0,
5283 state_id2: 9,
5284 }),
5285 votes: None,
5286 meta: Vec::new(),
5287 signatures: Vec::new(),
5288 },
5289 ),
5290 (
5291 "NoOp, non-null prev, nothing else",
5292 Aum {
5293 message_kind: AumKind::NoOp,
5294 prev_aum_hash: Some(AumHash([0x42; AUM_HASH_LEN])),
5295 key: None,
5296 key_id: Vec::new(),
5297 state: None,
5298 votes: None,
5299 meta: Vec::new(),
5300 signatures: Vec::new(),
5301 },
5302 ),
5303 ];
5304
5305 for (label, aum) in cases {
5306 let bytes = aum.serialize();
5307 let decoded = Aum::from_cbor(&bytes)
5308 .unwrap_or_else(|e| panic!("from_cbor failed for {label:?}: {e}"));
5309 assert_eq!(decoded, aum, "round-trip mismatch for {label:?}");
5310 // And the decoded AUM re-serializes to the identical bytes (canonical-form preserved →
5311 // hash/sig_hash are stable across a decode/encode cycle, which the chain replayer needs).
5312 assert_eq!(
5313 decoded.serialize(),
5314 bytes,
5315 "re-serialize must be byte-identical for {label:?}"
5316 );
5317 assert_eq!(
5318 decoded.hash(),
5319 aum.hash(),
5320 "hash must survive round-trip for {label:?}"
5321 );
5322 }
5323 }
5324
5325 /// Decode the exact frozen Go `TestSerialization` byte vectors (the same literals asserted on the
5326 /// encode side) straight into `Aum`s — proving the decoder consumes real Go-produced bytes, not
5327 /// just our own encoder's output.
5328 #[test]
5329 fn aum_from_cbor_decodes_frozen_go_vectors() {
5330 // RemoveKey: a3 01 02 02 f6 04 42 01 02
5331 let remove_key = Aum::from_cbor(&[0xa3, 0x01, 0x02, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02])
5332 .expect("decode RemoveKey vector");
5333 assert_eq!(remove_key.message_kind, AumKind::RemoveKey);
5334 assert_eq!(remove_key.prev_aum_hash, None);
5335 assert_eq!(remove_key.key_id, alloc::vec![1, 2]);
5336
5337 // UpdateKey: a5 01 04 02 f6 04 42 01 02 06 02 07 a1 61 61 61 62
5338 let update_key = Aum::from_cbor(&[
5339 0xa5, 0x01, 0x04, 0x02, 0xf6, 0x04, 0x42, 0x01, 0x02, 0x06, 0x02, 0x07, 0xa1, 0x61,
5340 0x61, 0x61, 0x62,
5341 ])
5342 .expect("decode UpdateKey vector");
5343 assert_eq!(update_key.message_kind, AumKind::UpdateKey);
5344 assert_eq!(update_key.votes, Some(2));
5345 assert_eq!(
5346 update_key.meta,
5347 alloc::vec![(
5348 alloc::string::String::from("a"),
5349 alloc::string::String::from("b")
5350 )],
5351 "the text-keyed Meta map must decode to {{\"a\":\"b\"}}"
5352 );
5353
5354 // Signature: a3 01 01 02 f6 17 81 a2 01 41 01 02 f6
5355 let with_sig = Aum::from_cbor(&[
5356 0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6,
5357 ])
5358 .expect("decode Signature vector");
5359 assert_eq!(with_sig.message_kind, AumKind::AddKey);
5360 assert_eq!(with_sig.signatures.len(), 1);
5361 assert_eq!(with_sig.signatures[0].key_id, alloc::vec![1]);
5362 assert_eq!(
5363 with_sig.signatures[0].signature,
5364 Vec::<u8>::new(),
5365 "the nil Signature (CBOR null) must decode to an empty Vec"
5366 );
5367 // Byte-exact re-encode of every frozen vector.
5368 assert_eq!(
5369 with_sig.serialize(),
5370 alloc::vec![
5371 0xa3, 0x01, 0x01, 0x02, 0xf6, 0x17, 0x81, 0xa2, 0x01, 0x41, 0x01, 0x02, 0xf6
5372 ]
5373 );
5374 }
5375
5376 /// The `null` (0xf6) major-7 arm is accepted ONLY for null; other major-7 simple/float values
5377 /// are still rejected (fail-closed), and the `NodeKeySignature` path is unaffected because its
5378 /// `expect_bytes` rejects null where bytes are required.
5379 #[test]
5380 fn decode_value_accepts_only_null_in_major7() {
5381 // Bare null decodes.
5382 assert_eq!(decode_value(&[0xf6], 0).unwrap().0, Value::Null);
5383 // true (0xf5), false (0xf4), undefined (0xf7), a float64 (0xfb …) → rejected.
5384 for bad in [
5385 alloc::vec![0xf5u8],
5386 alloc::vec![0xf4],
5387 alloc::vec![0xf7],
5388 alloc::vec![0xfb, 0x40, 0x09, 0x21, 0xfb, 0x54, 0x44, 0x2d, 0x18],
5389 ] {
5390 assert!(
5391 decode_value(&bad, 0).is_err(),
5392 "major-7 value {bad:02x?} other than null must be rejected"
5393 );
5394 }
5395 }
5396
5397 /// `Aum::from_cbor` fails closed on malformed / adversarial input — never panics, never `Ok` on
5398 /// garbage. Complements the `cbor_decode_smoke` integration test (which targets the signature
5399 /// path) for the AUM path.
5400 #[test]
5401 fn aum_from_cbor_fails_closed() {
5402 // Empty input.
5403 assert!(Aum::from_cbor(&[]).is_err());
5404 // Not a map (a bare uint).
5405 assert!(Aum::from_cbor(&[0x00]).is_err());
5406 // Map missing the non-omitempty prev_aum_hash (only message_kind present): a1 01 03.
5407 assert!(
5408 Aum::from_cbor(&[0xa1, 0x01, 0x03]).is_err(),
5409 "an AUM without key 2 (prev_aum_hash) must be rejected (non-omitempty)"
5410 );
5411 // Unknown field key (99): a2 01 03 18 63 00.
5412 assert!(
5413 Aum::from_cbor(&[0xa2, 0x01, 0x03, 0x18, 0x63, 0x00]).is_err(),
5414 "an unknown AUM field key must be rejected"
5415 );
5416 // Unknown message kind (9): a2 01 09 02 f6.
5417 assert!(
5418 Aum::from_cbor(&[0xa2, 0x01, 0x09, 0x02, 0xf6]).is_err(),
5419 "an unknown message_kind must be rejected"
5420 );
5421 // Trailing byte after a complete AUM: (a2 01 03 02 f6) + 00.
5422 assert!(
5423 Aum::from_cbor(&[0xa2, 0x01, 0x03, 0x02, 0xf6, 0x00]).is_err(),
5424 "trailing bytes after the AUM must be rejected"
5425 );
5426 // prev_aum_hash present but wrong length (31 bytes) → rejected.
5427 let mut short_prev = alloc::vec![0xa2u8, 0x01, 0x03, 0x02, 0x58, 0x1f];
5428 short_prev.extend(core::iter::repeat_n(0u8, 31));
5429 assert!(
5430 Aum::from_cbor(&short_prev).is_err(),
5431 "a prev_aum_hash that is not 32 bytes must be rejected"
5432 );
5433 }
5434
5435 /// A text-keyed map (`Meta`) and an int-keyed map are distinguished on decode, and a mixed-key
5436 /// map is rejected (TKA emits no mixed-key maps).
5437 #[test]
5438 fn decode_map_rejects_mixed_key_types() {
5439 // map(2){ 1: 0, "a": "b" } — int key then text key. a2 01 00 61 61 61 62
5440 assert!(
5441 decode_value(&[0xa2, 0x01, 0x00, 0x61, 0x61, 0x61, 0x62], 0).is_err(),
5442 "a map mixing uint and text keys must be rejected"
5443 );
5444 // A pure text map decodes to TextMap.
5445 let (v, rest) = decode_value(&[0xa1, 0x61, 0x61, 0x61, 0x62], 0).unwrap();
5446 assert!(rest.is_empty());
5447 assert_eq!(
5448 v,
5449 Value::TextMap(alloc::vec![(b"a".to_vec(), Value::Text(b"b".to_vec()))])
5450 );
5451 }
5452
5453 // ===== Review follow-ups (PR #48 review): close decode coverage gaps =====
5454
5455 /// Gap 2 (highest value): decode the authoritative frozen **Go checkpoint** bytes — the most
5456 /// complex AUM shape (null `disablement_values` arm, two nested keys, the second carrying a
5457 /// `Meta`, 32-byte hashes). The encode side asserts these exact bytes
5458 /// (`aum_checkpoint_nil_disablement_matches_go`); here we prove the *decoder* consumes them and
5459 /// round-trips byte-identically (so `hash()` is stable), exercising `AumState::from_value` +
5460 /// nested `AumKey::from_value` against real Go output rather than our own encoder.
5461 #[test]
5462 fn aum_from_cbor_decodes_frozen_go_checkpoint() {
5463 const GO_SERIALIZE: &str = "a30105025820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f05a3015820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f02f60382a301010201035820404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5fa401010203035820606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f0ca1616b6176";
5464 const GO_HASH: &str = "cae17cc938c5a954cd4389d83c6afe4d3487edac38b94824bec3312b82f35710";
5465 let bytes = unhex(GO_SERIALIZE);
5466
5467 let aum = Aum::from_cbor(&bytes).expect("decode the frozen Go checkpoint");
5468 assert_eq!(aum.message_kind, AumKind::Checkpoint);
5469 let st = aum.state.as_ref().expect("checkpoint carries a State");
5470 assert_eq!(
5471 st.disablement_values, None,
5472 "nil DisablementValues → the null arm → None"
5473 );
5474 let keys = st.keys.as_ref().expect("State has keys");
5475 assert_eq!(keys.len(), 2, "two nested keys");
5476 assert_eq!(keys[0].votes, 1);
5477 assert_eq!(keys[1].votes, 3);
5478 assert_eq!(
5479 keys[1].meta,
5480 alloc::vec![(
5481 alloc::string::String::from("k"),
5482 alloc::string::String::from("v")
5483 )],
5484 "the second nested key carries Meta {{\"k\":\"v\"}}"
5485 );
5486 // Byte-exact re-encode → the chain-link Hash matches Go's golden hash.
5487 assert_eq!(
5488 aum.serialize(),
5489 bytes,
5490 "re-serialize must be byte-identical to the Go bytes"
5491 );
5492 assert_eq!(
5493 hex(&aum.hash().0),
5494 GO_HASH,
5495 "decoded checkpoint's Hash matches Go golden"
5496 );
5497 }
5498
5499 /// Gap 1: round-trip the field combinations the original cases missed — multi-entry `Meta`
5500 /// (canonical-ordering), both `state_id`s non-zero (key-4/key-5 routing), `votes` at the u32
5501 /// boundary, a both-empty (`null`/`null`) `AumSignature`, and `key`+`key_id`+`signatures`
5502 /// coexisting (key 3/4/23 cross-talk).
5503 #[test]
5504 fn aum_from_cbor_roundtrips_review_gap_shapes() {
5505 let cases: alloc::vec::Vec<(&str, Aum)> = alloc::vec![
5506 (
5507 "multi-entry meta, pre-sorted (serialize() canonicalises key order)",
5508 Aum {
5509 message_kind: AumKind::UpdateKey,
5510 prev_aum_hash: None,
5511 key: None,
5512 key_id: alloc::vec![1],
5513 state: None,
5514 votes: Some(1),
5515 // Pre-sorted: `serialize()` emits TextMap keys in CTAP2 order, so the decoded
5516 // meta is sorted; supplying sorted input keeps the `==` round-trip exact.
5517 meta: alloc::vec![
5518 ("a".into(), "2".into()),
5519 ("mid".into(), "3".into()),
5520 ("zebra".into(), "1".into()),
5521 ],
5522 signatures: Vec::new(),
5523 },
5524 ),
5525 (
5526 "both state_ids non-zero (key 4 and key 5 must not be swapped)",
5527 Aum {
5528 message_kind: AumKind::Checkpoint,
5529 prev_aum_hash: Some(AumHash([0u8; AUM_HASH_LEN])),
5530 key: None,
5531 key_id: Vec::new(),
5532 state: Some(AumState {
5533 last_aum_hash: None,
5534 disablement_values: None,
5535 keys: Some(Vec::new()),
5536 state_id1: 7,
5537 state_id2: 9,
5538 }),
5539 votes: None,
5540 meta: Vec::new(),
5541 signatures: Vec::new(),
5542 },
5543 ),
5544 (
5545 "votes at u32::MAX + AddKey with key.votes at u32::MAX and multi-meta",
5546 Aum {
5547 message_kind: AumKind::AddKey,
5548 prev_aum_hash: Some(AumHash([0x33; AUM_HASH_LEN])),
5549 key: Some(AumKey {
5550 kind: KeyKind::Ed25519,
5551 votes: u32::MAX,
5552 public: alloc::vec![1, 2, 3],
5553 meta: alloc::vec![("a".into(), "x".into()), ("b".into(), "y".into())],
5554 }),
5555 key_id: Vec::new(),
5556 state: None,
5557 votes: None,
5558 meta: Vec::new(),
5559 signatures: Vec::new(),
5560 },
5561 ),
5562 (
5563 "both-empty AumSignature (key_id null AND signature null)",
5564 Aum {
5565 message_kind: AumKind::AddKey,
5566 prev_aum_hash: None,
5567 key: None,
5568 key_id: Vec::new(),
5569 state: None,
5570 votes: None,
5571 meta: Vec::new(),
5572 signatures: alloc::vec![AumSignature {
5573 key_id: Vec::new(),
5574 signature: Vec::new(),
5575 }],
5576 },
5577 ),
5578 (
5579 "key + key_id + signatures coexisting (keys 3, 4, 23)",
5580 Aum {
5581 message_kind: AumKind::AddKey,
5582 prev_aum_hash: Some(AumHash([0x55; AUM_HASH_LEN])),
5583 key: Some(AumKey {
5584 kind: KeyKind::Ed25519,
5585 votes: 2,
5586 public: alloc::vec![7, 7, 7],
5587 meta: Vec::new(),
5588 }),
5589 key_id: alloc::vec![9, 9],
5590 state: None,
5591 votes: None,
5592 meta: Vec::new(),
5593 signatures: alloc::vec![AumSignature {
5594 key_id: alloc::vec![1],
5595 signature: alloc::vec![2, 3, 4],
5596 }],
5597 },
5598 ),
5599 (
5600 "votes = 0 (boundary; Some(0) must survive, distinct from None)",
5601 Aum {
5602 message_kind: AumKind::UpdateKey,
5603 prev_aum_hash: None,
5604 key: None,
5605 key_id: alloc::vec![1],
5606 state: None,
5607 votes: Some(0),
5608 meta: Vec::new(),
5609 signatures: Vec::new(),
5610 },
5611 ),
5612 ];
5613 for (label, aum) in cases {
5614 let bytes = aum.serialize();
5615 let decoded = Aum::from_cbor(&bytes)
5616 .unwrap_or_else(|e| panic!("from_cbor failed for {label:?}: {e}"));
5617 assert_eq!(decoded, aum, "round-trip mismatch for {label:?}");
5618 assert_eq!(
5619 decoded.serialize(),
5620 bytes,
5621 "re-serialize differs for {label:?}"
5622 );
5623 }
5624 }
5625
5626 /// Gap 3: additional fail-closed guards on the AUM entry point — truncated map (count > entries),
5627 /// a duplicate key at the AUM level, votes > u32::MAX, an unsupported key kind, and a malformed
5628 /// (non-map) `state` value. Each must `Err`, never panic, never `Ok`.
5629 #[test]
5630 fn aum_from_cbor_fails_closed_review_gaps() {
5631 // Truncated map: header claims 3 pairs, only 2 present then EOF.
5632 assert!(
5633 Aum::from_cbor(&[0xa3, 0x01, 0x03, 0x02, 0xf6]).is_err(),
5634 "a map claiming more pairs than present must be rejected"
5635 );
5636 // Duplicate key at the AUM level: key 1 appears twice (a3 01 03 02 f6 01 04).
5637 assert!(
5638 Aum::from_cbor(&[0xa3, 0x01, 0x03, 0x02, 0xf6, 0x01, 0x04]).is_err(),
5639 "a duplicate AUM map key must be rejected"
5640 );
5641 // votes > u32::MAX: 06 1b 0000_0001_0000_0000 (= 2^32).
5642 assert!(
5643 Aum::from_cbor(&[
5644 0xa3, 0x01, 0x04, 0x02, 0xf6, 0x06, 0x1b, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
5645 0x00,
5646 ])
5647 .is_err(),
5648 "votes above u32::MAX must be rejected (fail-closed narrowing)"
5649 );
5650 // Unsupported key kind: AddKey embedding a key with kind=2. a3 01 01 02 f6 03 a3 01 02 02 01 03 41 09
5651 assert!(
5652 Aum::from_cbor(&[
5653 0xa3, 0x01, 0x01, 0x02, 0xf6, 0x03, 0xa3, 0x01, 0x02, 0x02, 0x01, 0x03, 0x41, 0x09,
5654 ])
5655 .is_err(),
5656 "an unsupported key kind must be rejected (not silently treated as Ed25519)"
5657 );
5658 // Malformed state: key 5 is a uint, not a map. a2 01 05 05 00 — but prev (key 2) missing too;
5659 // use a3 with prev null: a3 01 05 02 f6 05 00.
5660 assert!(
5661 Aum::from_cbor(&[0xa3, 0x01, 0x05, 0x02, 0xf6, 0x05, 0x00]).is_err(),
5662 "a non-map `state` value must be rejected"
5663 );
5664 // A deeply-nested array inside an AUM field must error (shared depth cap), not overflow.
5665 let mut nested = alloc::vec![0xa2u8, 0x01, 0x03, 0x02]; // map(2){1:3, 2: <nested>}
5666 nested.extend(core::iter::repeat_n(0x81u8, MAX_SIG_NESTING_DEPTH + 8)); // array(1) per level
5667 nested.push(0x00); // innermost uint
5668 assert!(
5669 Aum::from_cbor(&nested).is_err(),
5670 "an AUM field nested past the depth cap must be rejected, not overflow the stack"
5671 );
5672 }
5673
5674 /// Gap 5 + Finding L2: a NON-canonical encoding decodes to the SAME `Aum` (and thus the same
5675 /// `hash()`) as its canonical form — pinning the property that makes the lenient decode benign
5676 /// (the verify path re-serializes canonically, so wire-form variation can never forge a hash).
5677 #[test]
5678 fn aum_from_cbor_noncanonical_decodes_to_same_hash() {
5679 // Canonical NoOp with null prev: a2 01 03 02 f6.
5680 let canonical = [0xa2u8, 0x01, 0x03, 0x02, 0xf6];
5681 // Non-canonical variants that must decode to the SAME struct:
5682 // (a) message_kind via a non-minimal 2-byte int head (0x18 0x03 instead of 0x03):
5683 let noncanon_int = [0xa2u8, 0x01, 0x18, 0x03, 0x02, 0xf6];
5684 // (b) prev=null via the 2-byte simple-value form (0xf8 0x16 instead of 0xf6):
5685 let noncanon_null = [0xa2u8, 0x01, 0x03, 0x02, 0xf8, 0x16];
5686 // (c) map keys in DESCENDING order (2 before 1):
5687 let noncanon_order = [0xa2u8, 0x02, 0xf6, 0x01, 0x03];
5688
5689 let base = Aum::from_cbor(&canonical).expect("canonical decodes");
5690 for (label, bytes) in [
5691 ("non-minimal int head", &noncanon_int[..]),
5692 ("2-byte null simple value", &noncanon_null[..]),
5693 ("descending key order", &noncanon_order[..]),
5694 ] {
5695 let got = Aum::from_cbor(bytes)
5696 .unwrap_or_else(|e| panic!("non-canonical ({label}) should still decode: {e}"));
5697 assert_eq!(
5698 got, base,
5699 "non-canonical ({label}) must decode to the same Aum"
5700 );
5701 assert_eq!(
5702 got.hash(),
5703 base.hash(),
5704 "non-canonical ({label}) must hash identically (re-serialized canonically)"
5705 );
5706 // And it normalises: re-serialize equals the canonical bytes.
5707 assert_eq!(
5708 got.serialize(),
5709 canonical,
5710 "non-canonical ({label}) must re-serialize to the canonical form"
5711 );
5712 }
5713 }
5714 // ---- StaticValidate cluster (tsr-uvg): Go `AUM/Key/State.StaticValidate` parity ----
5715
5716 /// `Key::static_validate` — votes must be 1..=4096 (Go `Key.StaticValidate`).
5717 #[test]
5718 fn key_static_validate_votes_range() {
5719 let mut k = test_aum_key(1, 1);
5720 assert!(k.static_validate().is_ok(), "votes=1 ok");
5721 k.votes = 4096;
5722 assert!(k.static_validate().is_ok(), "votes=4096 ok (boundary)");
5723 k.votes = 0;
5724 assert_eq!(
5725 k.static_validate().unwrap_err(),
5726 TkaError::BadKeyState,
5727 "votes=0 rejected"
5728 );
5729 k.votes = 4097;
5730 assert_eq!(
5731 k.static_validate().unwrap_err(),
5732 TkaError::BadKeyState,
5733 "votes>4096 rejected"
5734 );
5735 }
5736
5737 /// `Key::static_validate` — metadata byte total must be ≤ MAX_META_BYTES.
5738 #[test]
5739 fn key_static_validate_meta_size() {
5740 let mut k = test_aum_key(2, 1);
5741 // 256-byte key + 256-byte value = 512 total = exactly MAX_META_BYTES → ok.
5742 k.meta = alloc::vec![(
5743 String::from_utf8(alloc::vec![b'k'; 256]).unwrap(),
5744 String::from_utf8(alloc::vec![b'v'; 256]).unwrap(),
5745 )];
5746 assert!(k.static_validate().is_ok(), "512 meta bytes ok (boundary)");
5747 // One more byte → rejected.
5748 k.meta[0].1.push('x');
5749 assert_eq!(
5750 k.static_validate().unwrap_err(),
5751 TkaError::BadKeyState,
5752 "meta>512 rejected"
5753 );
5754 }
5755
5756 /// `Aum::static_validate` — per-kind field allow-lists (Go `AUM.StaticValidate`).
5757 #[test]
5758 fn aum_static_validate_per_kind_field_allow_lists() {
5759 // AddKey must have a key and nothing else.
5760 let mut a = genesis_add(test_aum_key(1, 1));
5761 assert!(a.static_validate().is_ok());
5762 a.key_id = alloc::vec![1, 2, 3]; // foreign field
5763 assert!(
5764 a.static_validate().is_err(),
5765 "AddKey with a stray KeyID rejected"
5766 );
5767
5768 // RemoveKey must have a key_id and nothing else.
5769 let g = signed_genesis_add(1, 1);
5770 let mut rm = child(
5771 &g,
5772 AumKind::RemoveKey,
5773 None,
5774 test_aum_key(2, 1).public.clone(),
5775 );
5776 assert!(rm.static_validate().is_ok());
5777 rm.votes = Some(3); // foreign field
5778 assert!(
5779 rm.static_validate().is_err(),
5780 "RemoveKey with stray Votes rejected"
5781 );
5782
5783 // UpdateKey must have key_id AND (votes or meta).
5784 let mut up = child(
5785 &g,
5786 AumKind::UpdateKey,
5787 None,
5788 test_aum_key(2, 1).public.clone(),
5789 );
5790 assert!(
5791 up.static_validate().is_err(),
5792 "UpdateKey with neither votes nor meta rejected"
5793 );
5794 up.votes = Some(2);
5795 assert!(up.static_validate().is_ok(), "UpdateKey with votes ok");
5796 up.key = Some(test_aum_key(3, 1)); // foreign field
5797 assert!(
5798 up.static_validate().is_err(),
5799 "UpdateKey with a stray Key rejected"
5800 );
5801
5802 // Checkpoint must have state and nothing else.
5803 let mut cp = child(&g, AumKind::Checkpoint, None, Vec::new());
5804 cp.state = Some(AumState {
5805 last_aum_hash: None,
5806 disablement_values: Some(alloc::vec![alloc::vec![0xD5u8; DISABLEMENT_LENGTH]]),
5807 keys: Some(alloc::vec![test_aum_key(1, 1)]),
5808 state_id1: 0,
5809 state_id2: 0,
5810 });
5811 assert!(cp.static_validate().is_ok());
5812 cp.votes = Some(1); // foreign field
5813 assert!(
5814 cp.static_validate().is_err(),
5815 "Checkpoint with stray Votes rejected"
5816 );
5817 }
5818
5819 /// `Aum::static_validate` — every signature must have a 32-byte key_id and 64-byte signature.
5820 #[test]
5821 fn aum_static_validate_signature_lengths() {
5822 let mut a = genesis_add(test_aum_key(1, 1));
5823 a.signatures = alloc::vec![AumSignature {
5824 key_id: alloc::vec![0u8; 31], // wrong length (should be 32)
5825 signature: alloc::vec![0u8; 64],
5826 }];
5827 assert!(a.static_validate().is_err(), "31-byte keyID rejected");
5828 a.signatures[0].key_id = alloc::vec![0u8; 32];
5829 a.signatures[0].signature = alloc::vec![0u8; 63]; // wrong length (should be 64)
5830 assert!(a.static_validate().is_err(), "63-byte signature rejected");
5831 }
5832
5833 /// The last-key guard (Go `aumVerify`): a `RemoveKey` removing the only remaining trusted key is
5834 /// rejected — otherwise the authority would be left with an empty key set (lock disabled).
5835 #[test]
5836 fn verified_chain_rejects_removing_last_key() {
5837 let g = signed_genesis_add(1, 1); // exactly one trusted key (seed 1)
5838 let mut rm = child(
5839 &g,
5840 AumKind::RemoveKey,
5841 None,
5842 test_aum_key(1, 1).public.clone(),
5843 );
5844 sign_aum(&mut rm, &[1]); // validly signed by the trusted key
5845 assert_eq!(
5846 VerifiedAumChain::verify(&[g, rm]).unwrap_err(),
5847 TkaError::BadKeyState,
5848 "removing the last trusted key must be refused"
5849 );
5850 }
5851
5852 /// Removing a non-last key is fine: with two trusted keys, one can be removed.
5853 #[test]
5854 fn verified_chain_allows_removing_non_last_key() {
5855 let g = signed_genesis_add(1, 1);
5856 let mut add = child(&g, AumKind::AddKey, Some(test_aum_key(2, 1)), Vec::new());
5857 sign_aum(&mut add, &[1]);
5858 let mut rm = child(
5859 &add,
5860 AumKind::RemoveKey,
5861 None,
5862 test_aum_key(2, 1).public.clone(),
5863 );
5864 sign_aum(&mut rm, &[1]);
5865 let verified = VerifiedAumChain::verify(&[g, add, rm]).expect("removing a non-last key ok");
5866 let auth = Authority::from_verified_chain(verified);
5867 assert_eq!(
5868 auth.state().keys.len(),
5869 1,
5870 "back to one key after the remove"
5871 );
5872 }
5873
5874 /// `UpdateKey` is re-validated after mutation (Go re-runs `Key.StaticValidate`): an update that
5875 /// sets votes out of range is rejected.
5876 #[test]
5877 fn verified_chain_rejects_updatekey_to_invalid_votes() {
5878 let g = signed_genesis_add(1, 1);
5879 let mut up = child(
5880 &g,
5881 AumKind::UpdateKey,
5882 None,
5883 test_aum_key(1, 1).public.clone(),
5884 );
5885 up.votes = Some(5000); // > 4096 → invalid after mutation
5886 sign_aum(&mut up, &[1]);
5887 assert_eq!(
5888 VerifiedAumChain::verify(&[g, up]).unwrap_err(),
5889 TkaError::BadKeyState,
5890 "an UpdateKey that sets votes > 4096 is rejected (post-mutation re-validate)"
5891 );
5892 }
5893
5894 // ===== AUM-chain sync: store + SyncOffer + MissingAUMs (issue #7 chunk 2, tsr-5po) =====
5895
5896 /// Build a simple linear chain `genesis(AddKey) -> NoOp -> NoOp -> ...` of `len` AUMs, returning
5897 /// the AUMs in parent→child order. The genesis adds `test_aum_key(1, 1)`.
5898 fn linear_chain(len: usize) -> Vec<Aum> {
5899 assert!(len >= 1);
5900 let mut chain = alloc::vec![genesis_add(test_aum_key(1, 1))];
5901 for _ in 1..len {
5902 let parent = chain.last().unwrap();
5903 chain.push(child(parent, AumKind::NoOp, None, Vec::new()));
5904 }
5905 chain
5906 }
5907
5908 /// An [`Authority`] whose head is the last AUM of `chain` (via the structural `from_chain`; the
5909 /// sync layer is signature-agnostic, so unsigned test chains are fine here).
5910 fn authority_at_head(chain: &[Aum]) -> Authority {
5911 Authority::from_chain(chain).expect("linear test chain replays")
5912 }
5913
5914 #[test]
5915 fn mem_store_indexes_by_hash_and_children() {
5916 let chain = linear_chain(3);
5917 let store = MemAumStore::from_aums(chain.clone());
5918 assert_eq!(store.len(), 3);
5919 // by-hash lookup
5920 assert_eq!(store.aum(&chain[1].hash()).as_ref(), Some(&chain[1]));
5921 assert!(store.aum(&AumHash([0xFF; AUM_HASH_LEN])).is_none());
5922 // child index: genesis has one child (chain[1]); the tail has none.
5923 let kids = store.child_aums(&chain[0].hash());
5924 assert_eq!(kids.len(), 1);
5925 assert_eq!(kids[0], chain[1]);
5926 assert!(store.child_aums(&chain[2].hash()).is_empty());
5927 // insert is idempotent on hash + child edge.
5928 let mut s2 = store.clone();
5929 s2.insert(chain[1].clone());
5930 assert_eq!(s2.len(), 3, "re-insert must not grow the store");
5931 assert_eq!(
5932 s2.child_aums(&chain[0].hash()).len(),
5933 1,
5934 "child edge not duplicated"
5935 );
5936 }
5937
5938 #[test]
5939 fn sync_offer_head_and_oldest_bookend() {
5940 let chain = linear_chain(5);
5941 let store = MemAumStore::from_aums(chain.clone());
5942 let auth = authority_at_head(&chain);
5943 let oldest = chain[0].hash();
5944
5945 let offer = auth.sync_offer(&store, oldest).expect("offer");
5946 assert_eq!(offer.head, chain[4].hash(), "offer head is the chain head");
5947 assert_eq!(
5948 *offer.ancestors.last().unwrap(),
5949 oldest,
5950 "the last ancestor is always the oldest AUM"
5951 );
5952 // Every ancestor is a real hash in the chain.
5953 for a in &offer.ancestors {
5954 assert!(
5955 store.aum(a).is_some(),
5956 "ancestor {a:?} must be in the store"
5957 );
5958 }
5959 }
5960
5961 #[test]
5962 fn sync_offer_truncates_on_a_gap() {
5963 // A store missing an interior AUM: the backward walk breaks early, but `oldest` is still
5964 // appended (matching Go's break-then-append). Drop chain[1] so walking back from head hits
5965 // a gap.
5966 let chain = linear_chain(4);
5967 let store = MemAumStore::from_aums(
5968 chain
5969 .iter()
5970 .enumerate()
5971 .filter(|(i, _)| *i != 1)
5972 .map(|(_, a)| a.clone()),
5973 );
5974 let auth = authority_at_head(&chain);
5975 let offer = auth
5976 .sync_offer(&store, chain[0].hash())
5977 .expect("offer despite gap");
5978 assert_eq!(*offer.ancestors.last().unwrap(), chain[0].hash());
5979 }
5980
5981 #[test]
5982 fn missing_aums_empty_when_up_to_date() {
5983 let chain = linear_chain(4);
5984 let store = MemAumStore::from_aums(chain.clone());
5985 let auth = authority_at_head(&chain);
5986 let oldest = chain[0].hash();
5987 // Peer offers the SAME head → nothing missing.
5988 let peer_offer = auth.sync_offer(&store, oldest).expect("offer");
5989 let missing = auth
5990 .missing_aums(&store, &peer_offer, oldest)
5991 .expect("missing");
5992 assert!(missing.is_empty(), "an up-to-date peer is missing nothing");
5993 }
5994
5995 #[test]
5996 fn missing_aums_head_intersection_sends_the_tail() {
5997 // We are at head chain[4]; the peer is behind at chain[2] (their head is an ancestor of
5998 // ours). We must send them chain[3] and chain[4] (everything after the intersection).
5999 let chain = linear_chain(5);
6000 let store = MemAumStore::from_aums(chain.clone());
6001 let oldest = chain[0].hash();
6002 let us = authority_at_head(&chain); // head = chain[4]
6003
6004 // The peer's offer: head = chain[2], ancestors back to oldest. Build it from a peer authority
6005 // whose head is chain[2] over a store holding the prefix [0..=2].
6006 let peer_prefix: Vec<Aum> = chain[0..=2].to_vec();
6007 let peer_store = MemAumStore::from_aums(peer_prefix.clone());
6008 let peer = authority_at_head(&peer_prefix); // head = chain[2]
6009 let peer_offer = peer.sync_offer(&peer_store, oldest).expect("peer offer");
6010
6011 let missing = us
6012 .missing_aums(&store, &peer_offer, oldest)
6013 .expect("missing");
6014 let missing_hashes: Vec<AumHash> = missing.iter().map(Aum::hash).collect();
6015 assert_eq!(
6016 missing_hashes,
6017 alloc::vec![chain[3].hash(), chain[4].hash()],
6018 "must send exactly the AUMs after the peer's head, in order"
6019 );
6020 }
6021
6022 #[test]
6023 fn missing_aums_excludes_the_intersection_itself() {
6024 // The intersection AUM (the peer's head) must NOT be in the sent set — they already have it.
6025 let chain = linear_chain(4);
6026 let store = MemAumStore::from_aums(chain.clone());
6027 let oldest = chain[0].hash();
6028 let us = authority_at_head(&chain);
6029
6030 let peer_prefix: Vec<Aum> = chain[0..=1].to_vec();
6031 let peer = authority_at_head(&peer_prefix);
6032 let peer_offer = peer
6033 .sync_offer(&MemAumStore::from_aums(peer_prefix.clone()), oldest)
6034 .expect("peer offer");
6035
6036 let missing = us
6037 .missing_aums(&store, &peer_offer, oldest)
6038 .expect("missing");
6039 assert!(
6040 !missing.iter().any(|a| a.hash() == chain[1].hash()),
6041 "the intersection AUM (peer's head) must be excluded"
6042 );
6043 assert_eq!(missing.len(), 2, "only chain[2] and chain[3] are missing");
6044 }
6045
6046 #[test]
6047 fn missing_aums_no_intersection_errors() {
6048 // Two totally unrelated chains (different genesis keys → different hashes everywhere): no
6049 // intersection, so `missing_aums` fails closed rather than mis-rooting.
6050 let ours = linear_chain(3);
6051 let store = MemAumStore::from_aums(ours.clone());
6052 let us = authority_at_head(&ours);
6053
6054 // A foreign chain the peer offers; we hold none of it.
6055 let theirs = {
6056 let mut c = alloc::vec![genesis_add(test_aum_key(9, 1))];
6057 c.push(child(&c[0], AumKind::NoOp, None, Vec::new()));
6058 c
6059 };
6060 let foreign_offer = SyncOffer {
6061 head: theirs[1].hash(),
6062 ancestors: alloc::vec![theirs[1].hash(), theirs[0].hash()],
6063 };
6064 assert!(
6065 us.missing_aums(&store, &foreign_offer, ours[0].hash())
6066 .is_err(),
6067 "no intersection must fail closed, not mis-root"
6068 );
6069 }
6070
6071 #[test]
6072 fn compute_state_at_matches_replay_at_each_point() {
6073 // The state computed at an interior AUM via the store walk must equal a direct linear replay
6074 // of the prefix up to that AUM (the verify-only Authority's state).
6075 let chain = linear_chain(4);
6076 let store = MemAumStore::from_aums(chain.clone());
6077 for i in 0..chain.len() {
6078 let want = chain[i].hash();
6079 let via_store = compute_state_at(&store, MAX_SYNC_ITER, want)
6080 .expect("compute_state_at ok")
6081 .expect("hash present");
6082 let via_replay = Authority::from_chain(&chain[0..=i]).expect("prefix replays");
6083 assert_eq!(
6084 via_store.to_state(),
6085 *via_replay.state(),
6086 "computed state at chain[{i}] must match a direct prefix replay"
6087 );
6088 }
6089 }
6090
6091 #[test]
6092 fn sync_offer_ancestors_are_exponentially_spaced() {
6093 // With a long chain the ancestor sampling thins out (skip 4, then 16, ...), so the count is
6094 // far below the chain length — the whole point of the offer.
6095 let chain = linear_chain(60);
6096 let store = MemAumStore::from_aums(chain.clone());
6097 let auth = authority_at_head(&chain);
6098 let offer = auth.sync_offer(&store, chain[0].hash()).expect("offer");
6099 assert!(
6100 offer.ancestors.len() < 12,
6101 "exponential spacing keeps the ancestor list small (got {})",
6102 offer.ancestors.len()
6103 );
6104 // First sampled ancestor is 4 back from head (i=4 is the first i%4==0 with i>0): chain[56].
6105 assert_eq!(offer.ancestors[0], chain[60 - 1 - 4].hash());
6106 assert_eq!(*offer.ancestors.last().unwrap(), chain[0].hash());
6107 }
6108
6109 #[test]
6110 fn linear_chain_from_returns_ordered_chain() {
6111 // A store built from a linear chain returns it genesis→head in order, regardless of insert
6112 // order, so it round-trips through `VerifiedAumChain`/`from_chain`.
6113 let chain = linear_chain(5);
6114 // Insert in reverse to prove ordering is by chain links, not insert order.
6115 let mut store = MemAumStore::new();
6116 for aum in chain.iter().rev() {
6117 store.insert(aum.clone());
6118 }
6119 let ordered = store.linear_chain_from(chain[0].hash()).expect("walk");
6120 let got: Vec<AumHash> = ordered.iter().map(Aum::hash).collect();
6121 let want: Vec<AumHash> = chain.iter().map(Aum::hash).collect();
6122 assert_eq!(got, want, "linear_chain_from must yield genesis→head order");
6123 // And it replays into the same head a direct from_chain produces.
6124 assert_eq!(
6125 Authority::from_chain(&ordered).unwrap().head(),
6126 chain[4].hash()
6127 );
6128 }
6129
6130 #[test]
6131 fn linear_chain_from_missing_genesis_errors() {
6132 let chain = linear_chain(3);
6133 let store = MemAumStore::from_aums(chain.clone());
6134 // A genesis hash not in the store is BadChain, not a panic.
6135 assert_eq!(
6136 store
6137 .linear_chain_from(AumHash([0xEE; AUM_HASH_LEN]))
6138 .unwrap_err(),
6139 TkaError::BadChain
6140 );
6141 }
6142
6143 #[test]
6144 fn linear_chain_from_single_genesis() {
6145 // A store with only the genesis returns just it (the bootstrap case before any sync).
6146 let g = genesis_add(test_aum_key(1, 1));
6147 let store = MemAumStore::from_aums([g.clone()]);
6148 let ordered = store.linear_chain_from(g.hash()).expect("walk");
6149 assert_eq!(ordered.len(), 1);
6150 assert_eq!(ordered[0].hash(), g.hash());
6151 }
6152
6153 /// `AumKind::as_str` must return the exact Go `AUMKind.String()` strings (`tka/aum.go`,
6154 /// v1.100.0) — these are the `Change` values rendered by `tailscale lock log`, so a drift would
6155 /// silently produce a non-Go log row. Freeze every variant→string pair.
6156 #[test]
6157 fn aum_kind_as_str_matches_go() {
6158 let cases = [
6159 (AumKind::AddKey, "add-key"),
6160 (AumKind::RemoveKey, "remove-key"),
6161 (AumKind::UpdateKey, "update-key"),
6162 (AumKind::Checkpoint, "checkpoint"),
6163 (AumKind::NoOp, "no-op"),
6164 (AumKind::Invalid, "invalid"),
6165 ];
6166 for (kind, want) in cases {
6167 assert_eq!(kind.as_str(), want, "as_str for {kind:?}");
6168 }
6169 }
6170
6171 /// Consensus regression (tsr-3x4): at a genuine **weight-decided** fork, `linear_chain_from` must
6172 /// pick the branch with the higher signing weight — the branch a Go node picks (Go folds the real
6173 /// trusted-key state before each `pickNextAUM`). The previous code resolved the fork against an
6174 /// empty (zero-key) `ReplayState`, so every candidate scored weight 0 and the tiebreak collapsed
6175 /// to lowest-hash; on a fork where the lowest-hash branch is NOT the highest-weight branch, that
6176 /// diverged from Go = an accept-direction consensus split. This test constructs exactly that
6177 /// adversarial shape (the low-weight branch has the lower hash) and asserts weight wins.
6178 #[test]
6179 fn linear_chain_from_resolves_fork_by_real_weight_not_empty_state() {
6180 use ed25519_dalek::{Signer, SigningKey};
6181
6182 // Two trusted keys with very different vote weights, established by a checkpoint genesis.
6183 let key_light = test_aum_key(0x10, 1); // votes = 1
6184 let key_heavy = test_aum_key(0x20, 100); // votes = 100
6185 let signer_light = SigningKey::from_bytes(&[0x10; 32]);
6186 let signer_heavy = SigningKey::from_bytes(&[0x20; 32]);
6187
6188 let genesis = Aum {
6189 message_kind: AumKind::Checkpoint,
6190 prev_aum_hash: None,
6191 key: None,
6192 key_id: Vec::new(),
6193 state: Some(AumState {
6194 last_aum_hash: None,
6195 disablement_values: Some(alloc::vec![alloc::vec![0x33u8; DISABLEMENT_LENGTH]]),
6196 keys: Some(alloc::vec![key_light.clone(), key_heavy.clone()]),
6197 state_id1: 0,
6198 state_id2: 0,
6199 }),
6200 votes: None,
6201 meta: Vec::new(),
6202 signatures: Vec::new(),
6203 };
6204 let gh = genesis.hash();
6205
6206 // Build a child NoOp signed by the given key. `salt` perturbs the AUM bytes (via meta) so we
6207 // can search for the hash ordering we need without changing which key signs it.
6208 let child_signed_by = |signer: &SigningKey, key: &AumKey, salt: u8| -> Aum {
6209 let mut aum = Aum {
6210 message_kind: AumKind::NoOp,
6211 prev_aum_hash: Some(gh),
6212 key: None,
6213 key_id: Vec::new(),
6214 state: None,
6215 votes: None,
6216 meta: alloc::vec![("s".to_string(), alloc::format!("{salt}"))],
6217 signatures: Vec::new(),
6218 };
6219 let sh = aum.sig_hash();
6220 aum.signatures = alloc::vec![AumSignature {
6221 key_id: key.id().to_vec(),
6222 signature: signer.sign(&sh).to_bytes().to_vec(),
6223 }];
6224 aum
6225 };
6226
6227 // Find a salt pair where the LIGHT-weight branch has the LOWER hash (the adversarial case:
6228 // lowest-hash != highest-weight). With content-derived hashes this is found by probing salts.
6229 let (light_child, heavy_child) = (0u8..64)
6230 .flat_map(|ls| (0u8..64).map(move |hs| (ls, hs)))
6231 .find_map(|(ls, hs)| {
6232 let light = child_signed_by(&signer_light, &key_light, ls);
6233 let heavy = child_signed_by(&signer_heavy, &key_heavy, hs);
6234 (light.hash().0 < heavy.hash().0).then_some((light, heavy))
6235 })
6236 .expect("a salt pair where the light branch sorts lower must exist");
6237
6238 // Sanity: the adversarial precondition actually holds.
6239 assert!(
6240 light_child.hash().0 < heavy_child.hash().0,
6241 "test setup: light (low-weight) branch must have the lower hash"
6242 );
6243
6244 let store =
6245 MemAumStore::from_aums([genesis.clone(), light_child.clone(), heavy_child.clone()]);
6246 let ordered = store.linear_chain_from(gh).expect("walk");
6247
6248 // The walk must pick the HEAVY (weight-100) branch as the genesis's successor — matching Go —
6249 // NOT the light/low-hash branch the old empty-state code would have chosen.
6250 assert_eq!(ordered.len(), 2, "genesis + the chosen branch head");
6251 assert_eq!(ordered[0].hash(), gh);
6252 assert_eq!(
6253 ordered[1].hash(),
6254 heavy_child.hash(),
6255 "fork must resolve to the higher-WEIGHT branch (Go parity), not the lower-HASH one"
6256 );
6257 assert_ne!(
6258 ordered[1].hash(),
6259 light_child.hash(),
6260 "the lower-hash low-weight branch must NOT be chosen (the pre-fix empty-state bug)"
6261 );
6262 }
6263
6264 /// Consensus regression (tsr-3x4), the state-ACCUMULATION case: the fork is resolved by a key
6265 /// that was added by a **mid-chain `AddKey`**, not by the genesis. This is the scenario that
6266 /// distinguishes a walk that folds *every AUM up to the fork* (correct, Go `advanceByPrimary`)
6267 /// from one that only ever reflects the genesis state — the genesis-only test above would pass
6268 /// even if `state` failed to advance past the genesis, so this fork lives two AUMs deep and its
6269 /// weight winner depends on the intervening `AddKey` having been folded in.
6270 #[test]
6271 fn linear_chain_from_resolves_deep_fork_using_mid_chain_added_key_weight() {
6272 use ed25519_dalek::{Signer, SigningKey};
6273
6274 // Genesis trusts only a bootstrap key (votes 1). A mid-chain AddKey then introduces the
6275 // heavy key (votes 100). The fork below the AddKey is decided by that heavy key's weight —
6276 // so it can only resolve correctly if the AddKey was folded into the walk's state.
6277 let key_boot = test_aum_key(0x40, 1);
6278 let key_heavy = test_aum_key(0x50, 100);
6279 let signer_boot = SigningKey::from_bytes(&[0x40; 32]);
6280 let signer_heavy = SigningKey::from_bytes(&[0x50; 32]);
6281
6282 let genesis = Aum {
6283 message_kind: AumKind::Checkpoint,
6284 prev_aum_hash: None,
6285 key: None,
6286 key_id: Vec::new(),
6287 state: Some(AumState {
6288 last_aum_hash: None,
6289 disablement_values: Some(alloc::vec![alloc::vec![0x44u8; DISABLEMENT_LENGTH]]),
6290 keys: Some(alloc::vec![key_boot.clone()]),
6291 state_id1: 0,
6292 state_id2: 0,
6293 }),
6294 votes: None,
6295 meta: Vec::new(),
6296 signatures: Vec::new(),
6297 };
6298 let gh = genesis.hash();
6299
6300 // Mid-chain AddKey introducing the heavy key, signed by the bootstrap key (the only trusted
6301 // key at this point). prev = genesis.
6302 let mut add_heavy = Aum {
6303 message_kind: AumKind::AddKey,
6304 prev_aum_hash: Some(gh),
6305 key: Some(key_heavy.clone()),
6306 key_id: Vec::new(),
6307 state: None,
6308 votes: None,
6309 meta: Vec::new(),
6310 signatures: Vec::new(),
6311 };
6312 let ah_sh = add_heavy.sig_hash();
6313 add_heavy.signatures = alloc::vec![AumSignature {
6314 key_id: key_boot.id().to_vec(),
6315 signature: signer_boot.sign(&ah_sh).to_bytes().to_vec(),
6316 }];
6317 let ah = add_heavy.hash();
6318
6319 // Two competing children of the AddKey: one signed by the heavy key (weight 100), one by the
6320 // bootstrap key (weight 1). Arrange (via salt) so the LIGHT (boot-signed) branch sorts lower.
6321 let child_signed_by = |signer: &SigningKey, key: &AumKey, salt: u8| -> Aum {
6322 let mut aum = Aum {
6323 message_kind: AumKind::NoOp,
6324 prev_aum_hash: Some(ah),
6325 key: None,
6326 key_id: Vec::new(),
6327 state: None,
6328 votes: None,
6329 meta: alloc::vec![("s".to_string(), alloc::format!("{salt}"))],
6330 signatures: Vec::new(),
6331 };
6332 let sh = aum.sig_hash();
6333 aum.signatures = alloc::vec![AumSignature {
6334 key_id: key.id().to_vec(),
6335 signature: signer.sign(&sh).to_bytes().to_vec(),
6336 }];
6337 aum
6338 };
6339 let (light_child, heavy_child) = (0u8..64)
6340 .flat_map(|ls| (0u8..64).map(move |hs| (ls, hs)))
6341 .find_map(|(ls, hs)| {
6342 let light = child_signed_by(&signer_boot, &key_boot, ls);
6343 let heavy = child_signed_by(&signer_heavy, &key_heavy, hs);
6344 (light.hash().0 < heavy.hash().0).then_some((light, heavy))
6345 })
6346 .expect("a salt pair where the light branch sorts lower must exist");
6347
6348 let store = MemAumStore::from_aums([
6349 genesis.clone(),
6350 add_heavy.clone(),
6351 light_child.clone(),
6352 heavy_child.clone(),
6353 ]);
6354 let ordered = store.linear_chain_from(gh).expect("walk");
6355
6356 // genesis → AddKey → the HEAVY branch (resolved by the mid-chain-added key's weight).
6357 assert_eq!(
6358 ordered.len(),
6359 3,
6360 "genesis + AddKey + the chosen branch head"
6361 );
6362 assert_eq!(ordered[0].hash(), gh);
6363 assert_eq!(ordered[1].hash(), ah, "the AddKey is on the walked chain");
6364 assert_eq!(
6365 ordered[2].hash(),
6366 heavy_child.hash(),
6367 "the deep fork must resolve by the mid-chain-added key's weight (state was accumulated past genesis)"
6368 );
6369 }
6370
6371 // ----- tsr-jo1: RotationDetails extraction (Go `NodeKeySignature.rotationDetails`) -----
6372
6373 /// Build a verified rotation chain: a trusted key signs an inner `Direct` over `wrapping_pub`,
6374 /// then `rotations` successive `Rotation` layers each re-wrap, the outermost authorizing
6375 /// `node_key`. Returns `(authority, node_key, signed_cbor, wrapping_pub)`. Every layer's pivot is
6376 /// the same `wrapping` keypair (the simplest chain that still has `prev_node_keys` entries).
6377 #[cfg(test)]
6378 fn rotation_chain(
6379 rotations: usize,
6380 inner_kind: SigKind,
6381 ) -> (Authority, Vec<u8>, Vec<u8>, Vec<u8>) {
6382 use ed25519_dalek::{Signer, SigningKey};
6383
6384 let trusted = SigningKey::from_bytes(&[7u8; 32]);
6385 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
6386 let wrapping = SigningKey::from_bytes(&[9u8; 32]);
6387 let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
6388 let node_key = alloc::vec![5u8; 32];
6389
6390 let auth = Authority::from_state(
6391 AumHash([0; 32]),
6392 State {
6393 keys: alloc::vec![Key {
6394 kind: KeyKind::Ed25519,
6395 votes: 1,
6396 public: trusted_pub.clone(),
6397 }],
6398 },
6399 );
6400
6401 // Inner signature: the trusted key signs over `wrapping_pub`. A `Credential` inner is
6402 // exercised by `initial_sig_kind` tests; it still verifies (security is the two signatures,
6403 // not the kind).
6404 let mut inner = NodeKeySignature {
6405 sig_kind: inner_kind,
6406 pubkey: wrapping_pub.clone(),
6407 key_id: trusted_pub.clone(),
6408 signature: Vec::new(),
6409 nested: None,
6410 wrapping_pubkey: wrapping_pub.clone(),
6411 };
6412 let inner_hash = inner.sig_hash();
6413 inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
6414
6415 // Successive Rotation layers, each wrapping the previous and signed by the wrapping key.
6416 let mut current = inner;
6417 for _ in 0..rotations {
6418 let mut outer = NodeKeySignature {
6419 sig_kind: SigKind::Rotation,
6420 pubkey: node_key.clone(),
6421 key_id: Vec::new(),
6422 signature: Vec::new(),
6423 nested: Some(alloc::boxed::Box::new(current)),
6424 wrapping_pubkey: Vec::new(),
6425 };
6426 let h = outer.sig_hash();
6427 outer.signature = wrapping.sign(&h).to_bytes().to_vec();
6428 current = outer;
6429 }
6430 let cbor = current.to_cbor(true).to_vec();
6431 (auth, node_key, cbor, wrapping_pub)
6432 }
6433
6434 /// A single-level rotation (Direct inner) yields `Some` details: one prior node key (the inner's
6435 /// `pubkey` = the wrapping pubkey), `initial_sig_kind == Direct`, and the initial wrapping pubkey.
6436 #[test]
6437 fn rotation_details_extracts_prev_node_keys() {
6438 let (auth, node_key, cbor, wrapping_pub) = rotation_chain(1, SigKind::Direct);
6439 let details = auth
6440 .node_key_authorized_with_details(&node_key, &cbor)
6441 .expect("must verify")
6442 .expect("rotation sig yields details");
6443 assert_eq!(details.prev_node_keys, alloc::vec![wrapping_pub.clone()]);
6444 assert_eq!(details.initial_sig_kind, SigKind::Direct);
6445 assert_eq!(details.initial_wrapping_pubkey, wrapping_pub);
6446 }
6447
6448 /// A plain `Direct` signature (no rotation) yields `None` details (and still authorizes).
6449 #[test]
6450 fn rotation_details_none_for_direct() {
6451 use ed25519_dalek::{Signer, SigningKey};
6452 let trusted = SigningKey::from_bytes(&[7u8; 32]);
6453 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
6454 let node_key = alloc::vec![5u8; 32];
6455 let auth = Authority::from_state(
6456 AumHash([0; 32]),
6457 State {
6458 keys: alloc::vec![Key {
6459 kind: KeyKind::Ed25519,
6460 votes: 1,
6461 public: trusted_pub.clone(),
6462 }],
6463 },
6464 );
6465 let mut direct = NodeKeySignature {
6466 sig_kind: SigKind::Direct,
6467 pubkey: node_key.clone(),
6468 key_id: trusted_pub.clone(),
6469 signature: Vec::new(),
6470 nested: None,
6471 wrapping_pubkey: node_key.clone(),
6472 };
6473 let h = direct.sig_hash();
6474 direct.signature = trusted.sign(&h).to_bytes().to_vec();
6475 let cbor = direct.to_cbor(true).to_vec();
6476 let details = auth
6477 .node_key_authorized_with_details(&node_key, &cbor)
6478 .expect("must verify");
6479 assert_eq!(details, None, "a Direct sig has no rotation details");
6480 }
6481
6482 /// A multi-level rotation collects every nested pubkey in outer→inner order, and the innermost
6483 /// (Direct) signature determines `initial_sig_kind`. Here both rotation layers share the same
6484 /// wrapping pivot, so both prior-key entries equal the wrapping pubkey.
6485 #[test]
6486 fn rotation_details_multi_level_order() {
6487 let (auth, node_key, cbor, wrapping_pub) = rotation_chain(2, SigKind::Direct);
6488 let details = auth
6489 .node_key_authorized_with_details(&node_key, &cbor)
6490 .expect("must verify")
6491 .expect("rotation yields details");
6492 // Two rotation layers ⇒ two nested signatures walked (the inner Rotation's pubkey = node_key
6493 // pivot is its own; the innermost Direct's pubkey = wrapping_pub). Both nested `pubkey`s are
6494 // collected; the innermost is the Direct over `wrapping_pub`.
6495 assert_eq!(
6496 details.prev_node_keys.len(),
6497 2,
6498 "both nested layers contribute a prior key"
6499 );
6500 assert_eq!(details.initial_sig_kind, SigKind::Direct);
6501 assert_eq!(details.initial_wrapping_pubkey, wrapping_pub);
6502 }
6503
6504 /// A `Credential`-rooted rotation chain reports `initial_sig_kind == Credential`, so the netmap
6505 /// rotation filter will exempt it from the clone-uniqueness rule (reusable-authkey carve-out).
6506 #[test]
6507 fn rotation_details_credential_root_kind() {
6508 let (auth, node_key, cbor, _wrapping_pub) = rotation_chain(1, SigKind::Credential);
6509 let details = auth
6510 .node_key_authorized_with_details(&node_key, &cbor)
6511 .expect("must verify")
6512 .expect("rotation yields details");
6513 assert_eq!(
6514 details.initial_sig_kind,
6515 SigKind::Credential,
6516 "a credential-rooted chain is reported so the uniqueness rule can exempt it"
6517 );
6518 }
6519
6520 /// Fail-closed parity with Go `rotationDetails` (which errors when a nested pubkey fails
6521 /// `NodePublic.UnmarshalBinary`): a rotation chain whose nested signature carries a pubkey of the
6522 /// wrong length must be REJECTED, not admitted with a junk prior-key entry. The crafted case is a
6523 /// nested **Credential** layer — the verify path does not bind a credential's `pubkey` to a node
6524 /// key, so without this check a bad-length credential pubkey would slip through (fail-open).
6525 #[test]
6526 fn rotation_details_rejects_malformed_nested_pubkey() {
6527 use ed25519_dalek::{Signer, SigningKey};
6528 let trusted = SigningKey::from_bytes(&[7u8; 32]);
6529 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
6530 let wrapping = SigningKey::from_bytes(&[9u8; 32]);
6531 let wrapping_pub = wrapping.verifying_key().to_bytes().to_vec();
6532 let node_key = alloc::vec![5u8; 32];
6533 let auth = Authority::from_state(
6534 AumHash([0; 32]),
6535 State {
6536 keys: alloc::vec![Key {
6537 kind: KeyKind::Ed25519,
6538 votes: 1,
6539 public: trusted_pub.clone(),
6540 }],
6541 },
6542 );
6543
6544 // Inner Credential with a deliberately MALFORMED (3-byte) pubkey. The credential is signed by
6545 // the trusted key over the wrapping pubkey; the verify path does not constrain its `pubkey`.
6546 let mut inner = NodeKeySignature {
6547 sig_kind: SigKind::Credential,
6548 pubkey: alloc::vec![1u8, 2u8, 3u8], // wrong length (not 32)
6549 key_id: trusted_pub.clone(),
6550 signature: Vec::new(),
6551 nested: None,
6552 wrapping_pubkey: wrapping_pub.clone(),
6553 };
6554 let inner_hash = inner.sig_hash();
6555 inner.signature = trusted.sign(&inner_hash).to_bytes().to_vec();
6556
6557 let mut outer = NodeKeySignature {
6558 sig_kind: SigKind::Rotation,
6559 pubkey: node_key.clone(),
6560 key_id: Vec::new(),
6561 signature: Vec::new(),
6562 nested: Some(alloc::boxed::Box::new(inner)),
6563 wrapping_pubkey: Vec::new(),
6564 };
6565 let outer_hash = outer.sig_hash();
6566 outer.signature = wrapping.sign(&outer_hash).to_bytes().to_vec();
6567 let cbor = outer.to_cbor(true).to_vec();
6568
6569 // The signatures themselves verify, but details extraction rejects the bad-length nested
6570 // pubkey fail-closed (Go drops the peer here).
6571 let err = auth
6572 .node_key_authorized_with_details(&node_key, &cbor)
6573 .unwrap_err();
6574 assert_eq!(err, TkaError::Decode("nested rotation pubkey wrong length"));
6575 // And the bool authorize path rejects identically (no fail-open).
6576 assert!(auth.node_key_authorized(&node_key, &cbor).is_err());
6577 }
6578
6579 /// The verify verdict of `node_key_authorized` is byte-identical to the details path across the
6580 /// good rotation case and the bad (tampered) case — the refactor that made the former a wrapper
6581 /// of the latter must not change a single verdict.
6582 #[test]
6583 fn node_key_authorized_matches_details_path() {
6584 let (auth, node_key, cbor, _wp) = rotation_chain(1, SigKind::Direct);
6585 assert_eq!(
6586 auth.node_key_authorized(&node_key, &cbor).is_ok(),
6587 auth.node_key_authorized_with_details(&node_key, &cbor)
6588 .is_ok(),
6589 );
6590 // Tamper a byte of the CBOR so verification fails; both paths must reject identically.
6591 let mut bad = cbor.clone();
6592 let last = bad.len() - 1;
6593 bad[last] ^= 0xff;
6594 assert_eq!(
6595 auth.node_key_authorized(&node_key, &bad).is_err(),
6596 auth.node_key_authorized_with_details(&node_key, &bad)
6597 .is_err(),
6598 );
6599 }
6600}