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