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