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