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