Skip to main content

kanade_shared/
signing.rs

1//! Command provenance: the backend signs, the agent verifies (#1165).
2//!
3//! The agent authorises a command **by the subject it arrived on** and runs it
4//! without checking who wrote it. #1155 measured why that is not fixable with
5//! credentials: `deliver_subject` is not authorization-checked, so a connected
6//! party can place attacker-authored bytes on `commands.all` /
7//! `commands.pc.<id>` through a push consumer, and subject permissions cannot
8//! express a rule against it. Signing attacks a different axis — the agent
9//! checks provenance regardless of how the bytes arrived.
10//!
11//! This is the code-signing model (signed packages, Authenticode), not a login
12//! handshake. The asymmetry is the whole point: the **private** key lives only
13//! on the backend, and agents hold only **public** verify keys, which are not
14//! secrets. Compromising one of hundreds of endpoints therefore yields nothing
15//! that can command another — the endpoint holds no key capable of authoring a
16//! valid command.
17//!
18//! # Where the signature travels
19//!
20//! In **NATS headers**, over the message body's exact bytes — not in an
21//! envelope wrapping the body.
22//!
23//! Both are detached signatures and both sidestep canonicalisation (verify the
24//! received bytes, *then* deserialize). Headers win on migration: the body
25//! stays a bare serialized `Command`, so an agent that predates this feature
26//! parses a signed message exactly as it parses an unsigned one and is
27//! unaffected by the rollout. An envelope would change the body's shape, which
28//! means every agent must understand both shapes for the whole of stages 1–2 —
29//! a dual-parse path across a fleet-wide upgrade window, to buy nothing.
30//!
31//! The frame plane already does this (#1140: metadata in headers, raw payload)
32//! for the same reason: don't wrap the body when the transport has a place for
33//! metadata.
34//!
35//! # Multiple signers from the start
36//!
37//! [`SIG_KID`] is populated and checked from the first release, even while
38//! only one key exists. Adding a key id later is a wire break across the whole
39//! fleet; reserving it now costs nothing, and it buys two things:
40//!
41//! * **Rotation is not a separate mechanism.** Rotating = two valid kids for a
42//!   window, which is the same code path as having two signers. A rotation
43//!   procedure that shares its implementation with everyday operation is one
44//!   that still works the day it is needed.
45//! * **The recovery-path decision can wait.** `kanade run` publishes its own
46//!   commands (`crates/kanade/src/cmd/run.rs`) and is the documented
47//!   backend-down recovery route; whether it gets a break-glass key, routes
48//!   through the backend, or is retired does not have to be settled before the
49//!   wire format ships.
50//!
51//! A [`KeyRing`] therefore maps `kid → (key, policy)` rather than holding a
52//! bare set of keys. Policy is what makes more keys *safer* rather than merely
53//! wider: a break-glass key should not silently carry the same authority as
54//! the backend's.
55
56use std::collections::BTreeMap;
57use std::time::Duration;
58
59// The two traits are imported anonymously so `key.sign(..)` / `key.verify(..)`
60// still resolve without `ed25519_dalek::Signer` colliding with this module's
61// own [`Signer`] — the type an operator-facing caller actually holds.
62use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey};
63
64/// Header carrying the base64 (standard, padded) Ed25519 signature over the
65/// message body.
66pub const SIG: &str = "Kanade-Sig";
67/// Header naming which key signed it. Present from the first release; see the
68/// module doc on why it is not deferred.
69pub const SIG_KID: &str = "Kanade-Sig-Kid";
70/// Header naming the algorithm. Ed25519 today; present so a future migration
71/// is a value change rather than a format change.
72pub const SIG_ALG: &str = "Kanade-Sig-Alg";
73/// Header carrying when the message was signed, as decimal milliseconds since
74/// the Unix epoch. **Covered by the signature** — see [`signed_material`].
75pub const SIG_AT: &str = "Kanade-Sig-At";
76
77/// The only algorithm currently emitted or accepted.
78pub const ALG_ED25519: &str = "ed25519";
79
80/// The bytes a signature actually covers: the signing time, then the message.
81///
82/// The timestamp is **inside** the signed material, unlike [`SIG_KID`], and
83/// the asymmetry is easy to get backwards. A rewritten `kid` can only select a
84/// key under which verification fails, so it is safe outside. A rewritten
85/// timestamp would let anyone replay a captured message by making it look
86/// fresh again — which is the entire freshness bound, defeated by editing one
87/// header. So it is signed.
88///
89/// The prefix is fixed-width rather than delimited so there is no
90/// concatenation ambiguity to reason about: no timestamp encoding can be
91/// chosen such that one `(at, body)` pair produces the same bytes as another.
92/// Keeping it a prefix rather than a field also leaves the `Command` wire type
93/// untouched — agent-authored in-process commands would otherwise carry a
94/// field that means nothing for them.
95pub fn signed_material(at_ms: i64, body: &[u8]) -> Vec<u8> {
96    let mut out = Vec::with_capacity(8 + body.len());
97    out.extend_from_slice(&at_ms.to_be_bytes());
98    out.extend_from_slice(body);
99    out
100}
101
102/// What a key is allowed to authorise.
103///
104/// Exists from the first release with a single variant in practical use,
105/// because a keyring without policy is just more keys that can do everything —
106/// strictly worse than one key. Adding the concept later would mean auditing
107/// every existing key's authority retroactively.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct KeyPolicy {
110    /// Human-facing note for logs and audit ("backend", "break-glass").
111    pub label: String,
112    /// Reject a signature older than this. `None` = no freshness bound.
113    ///
114    /// **`None` is required for the ordinary signer, not merely convenient.**
115    /// The agent's JetStream replay path deliberately redelivers the most
116    /// recent retained command per subject, kept for 7 days, so an agent
117    /// reconnecting after a week legitimately receives week-old commands. A
118    /// bound on the backend key would reject exactly that.
119    ///
120    /// A break-glass key sets a short bound, and there the bound is doing real
121    /// work rather than being belt-and-braces. Replay dedup is keyed on
122    /// `request_id` in an in-memory cache, which is **empty on first boot** —
123    /// so without a freshness check, a machine booting for the first time
124    /// would execute a week-old emergency command that no dedup can catch.
125    pub max_age: Option<Duration>,
126    /// Emit an audit record on **every** use of this key, including failed
127    /// verifications.
128    ///
129    /// A break-glass credential whose use nobody investigates is a second
130    /// production key with extra steps, so the flag lives next to the key
131    /// rather than in a call site that can forget it.
132    pub audit_every_use: bool,
133}
134
135impl KeyPolicy {
136    /// The ordinary signer: no extra freshness bound, no per-use audit (the
137    /// command itself is already audited).
138    pub fn backend(label: impl Into<String>) -> Self {
139        Self {
140            label: label.into(),
141            max_age: None,
142            audit_every_use: false,
143        }
144    }
145
146    /// A deliberately-guarded key: short-lived signatures, every use recorded.
147    pub fn break_glass(label: impl Into<String>, max_age: Duration) -> Self {
148        Self {
149            label: label.into(),
150            max_age: Some(max_age),
151            audit_every_use: true,
152        }
153    }
154}
155
156/// The public keys an agent trusts, keyed by `kid`.
157#[derive(Debug, Clone, Default)]
158pub struct KeyRing {
159    keys: BTreeMap<String, (VerifyingKey, KeyPolicy)>,
160}
161
162impl KeyRing {
163    pub fn new() -> Self {
164        Self::default()
165    }
166
167    pub fn insert(&mut self, kid: impl Into<String>, key: VerifyingKey, policy: KeyPolicy) {
168        self.keys.insert(kid.into(), (key, policy));
169    }
170
171    pub fn is_empty(&self) -> bool {
172        self.keys.is_empty()
173    }
174
175    /// Look a key up, yielding the ring's own `kid` string so a verified
176    /// result borrows from the trusted ring rather than from the attacker-
177    /// supplied header it was matched against.
178    pub fn get(&self, kid: &str) -> Option<(&str, &VerifyingKey, &KeyPolicy)> {
179        self.keys
180            .get_key_value(kid)
181            .map(|(k, (key, policy))| (k.as_str(), key, policy))
182    }
183
184    /// Every `kid` on the ring, for reporting which keys an agent holds.
185    pub fn kids(&self) -> impl Iterator<Item = &str> {
186        self.keys.keys().map(String::as_str)
187    }
188}
189
190/// Why a message was not accepted as backend-authored.
191///
192/// Deliberately distinguishes "carries no signature" from every other case.
193/// During stages 1–2 of the rollout `Unsigned` is expected and benign, while
194/// the rest mean something is wrong — conflating them would hide a real
195/// failure inside a normal one for the whole migration window.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum VerifyError {
198    /// No signature headers at all. Normal until enforcement is switched on.
199    Unsigned,
200    /// Signed, but the `kid` is not on this agent's ring.
201    ///
202    /// The operationally dangerous case: an agent that never received a new
203    /// key looks, from the operator's side, exactly like a backend that is not
204    /// sending commands — and a mid-rotation fleet is full of agents in that
205    /// state. Callers must surface this, not just log it locally.
206    UnknownKid { kid: String },
207    /// Signed with an algorithm this build does not implement.
208    UnsupportedAlg { alg: String },
209    /// Signature header present but not decodable as a signature.
210    Malformed(String),
211    /// Cryptographically invalid for the bytes received.
212    BadSignature { kid: String },
213    /// Valid, by a key whose policy bounds how old a signature may be, and
214    /// this one is older. Distinct from [`VerifyError::BadSignature`] because
215    /// the signature is genuine — what failed is the policy, and the two want
216    /// different responses.
217    Stale {
218        kid: String,
219        age_ms: i64,
220        max_age_ms: u128,
221    },
222}
223
224impl std::fmt::Display for VerifyError {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        match self {
227            VerifyError::Unsigned => write!(f, "no signature"),
228            VerifyError::UnknownKid { kid } => {
229                write!(
230                    f,
231                    "signed by unknown key id {kid} — is this agent's keyring current?"
232                )
233            }
234            VerifyError::UnsupportedAlg { alg } => {
235                write!(f, "unsupported signature algorithm {alg}")
236            }
237            VerifyError::Malformed(e) => write!(f, "malformed signature: {e}"),
238            VerifyError::BadSignature { kid } => {
239                write!(f, "signature by {kid} does not match these bytes")
240            }
241            VerifyError::Stale {
242                kid,
243                age_ms,
244                max_age_ms,
245            } => write!(
246                f,
247                "signature by {kid} is {age_ms}ms old, past its {max_age_ms}ms bound"
248            ),
249        }
250    }
251}
252
253impl std::error::Error for VerifyError {}
254
255/// A verified message: which key vouched for it, and under what policy.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct Verified<'a> {
258    pub kid: &'a str,
259    pub policy: &'a KeyPolicy,
260}
261
262/// The signature headers carried alongside a message body.
263///
264/// Extracted from the transport by the caller so this module stays free of a
265/// NATS dependency and is testable without a broker.
266#[derive(Debug, Clone, Default, PartialEq, Eq)]
267pub struct SigHeaders {
268    pub sig_b64: Option<String>,
269    pub kid: Option<String>,
270    pub alg: Option<String>,
271    /// Decimal milliseconds since the Unix epoch, as sent. Parsed rather than
272    /// trusted — it is covered by the signature, so a value that does not
273    /// reconstruct the signed material simply fails verification.
274    pub at_ms: Option<String>,
275}
276
277impl SigHeaders {
278    /// True only when the message makes **no signing claim at all**.
279    ///
280    /// Any one of these headers means the sender is asserting the message is
281    /// signed, so a partial set is a broken claim rather than an absent one.
282    /// `alg` counts for the same reason `kid` does: the rule has to be "no
283    /// signing headers at all", not "none of the two I happened to think of",
284    /// or stripping whichever header is unchecked reclassifies a malformed
285    /// message as an unsigned one — and unsigned is the accepted case for the
286    /// whole of stages 1-2.
287    pub fn is_absent(&self) -> bool {
288        self.sig_b64.is_none() && self.kid.is_none() && self.alg.is_none() && self.at_ms.is_none()
289    }
290}
291
292/// Verify `body` against the signature headers using `ring`.
293///
294/// Verification is over the **exact received bytes**; deserialization happens
295/// afterwards, at the caller. That ordering is what removes canonicalisation
296/// from the problem — there is no need for serde to produce byte-identical
297/// output on both sides, only for the bytes to arrive unchanged.
298pub fn verify<'a>(
299    ring: &'a KeyRing,
300    body: &[u8],
301    headers: &SigHeaders,
302    now_ms: i64,
303) -> Result<Verified<'a>, VerifyError> {
304    if headers.is_absent() {
305        return Err(VerifyError::Unsigned);
306    }
307    // A signature with no kid cannot be attributed, so it is not "unsigned" —
308    // it is a signed message we cannot route to a key.
309    let kid = headers
310        .kid
311        .as_deref()
312        .ok_or_else(|| VerifyError::Malformed("signature without a key id".into()))?;
313    let sig_b64 = headers
314        .sig_b64
315        .as_deref()
316        .ok_or_else(|| VerifyError::Malformed("key id without a signature".into()))?;
317    let at_raw = headers
318        .at_ms
319        .as_deref()
320        .ok_or_else(|| VerifyError::Malformed("signature without a signing time".into()))?;
321    let at_ms: i64 = at_raw
322        .parse()
323        .map_err(|_| VerifyError::Malformed(format!("signing time {at_raw} is not a number")))?;
324
325    // Absent alg means the original single-algorithm shape; anything else must
326    // match exactly rather than being guessed at.
327    if let Some(alg) = headers.alg.as_deref()
328        && alg != ALG_ED25519
329    {
330        return Err(VerifyError::UnsupportedAlg {
331            alg: alg.to_owned(),
332        });
333    }
334
335    // The kid is a key-selection hint and is NOT covered by the signature.
336    // That is safe: rewriting it can only select a key under which
337    // verification fails, never make an invalid signature pass. The signing
338    // time is the opposite case and IS covered — see `signed_material`.
339    let (ring_kid, key, policy) = ring.get(kid).ok_or_else(|| VerifyError::UnknownKid {
340        kid: kid.to_owned(),
341    })?;
342
343    let raw = base64_decode(sig_b64).map_err(VerifyError::Malformed)?;
344    let sig = Signature::from_slice(&raw).map_err(|e| VerifyError::Malformed(e.to_string()))?;
345    key.verify(&signed_material(at_ms, body), &sig)
346        .map_err(|_| VerifyError::BadSignature {
347            kid: kid.to_owned(),
348        })?;
349
350    // Freshness is checked only AFTER the signature holds, so a stale verdict
351    // is always about a genuine message. Checking it first would let anyone
352    // provoke a `Stale` report by sending garbage with an old timestamp.
353    if let Some(max_age) = policy.max_age {
354        let age_ms = now_ms - at_ms;
355        let max_age_ms = max_age.as_millis();
356        // Compared in i128 rather than casting the bound down to i64. A
357        // `max_age_secs` large enough to overflow the cast is nonsense config
358        // rather than an attack (the registry holding it is ACL'd), but the
359        // truncation would silently turn a bound into its opposite — a
360        // negative limit that nothing can satisfy, or one so large the key is
361        // effectively unbounded — and `-(x as i64)` can panic outright on
362        // i64::MIN in a debug build. i128 holds every value `Duration::as_millis`
363        // can produce, so there is nothing left to get wrong.
364        let age = age_ms as i128;
365        let bound = max_age_ms as i128;
366        // A signature from the future is not "fresh" — it is a clock that
367        // disagrees, and treating it as valid would let a skewed or hostile
368        // signer mint credentials that outlive the bound. Bound both ends.
369        if age > bound || age < -bound {
370            return Err(VerifyError::Stale {
371                kid: kid.to_owned(),
372                age_ms,
373                max_age_ms,
374            });
375        }
376    }
377
378    Ok(Verified {
379        kid: ring_kid,
380        policy,
381    })
382}
383
384/// Sign `body`, producing the headers to publish alongside it.
385///
386/// Lives here rather than in the backend so the signing and verifying halves
387/// cannot drift — a round-trip test in this module covers both at once.
388pub fn sign(key: &SigningKey, kid: &str, body: &[u8], at_ms: i64) -> SigHeaders {
389    let sig = key.sign(&signed_material(at_ms, body));
390    SigHeaders {
391        sig_b64: Some(base64_encode(&sig.to_bytes())),
392        kid: Some(kid.to_owned()),
393        alg: Some(ALG_ED25519.to_owned()),
394        at_ms: Some(at_ms.to_string()),
395    }
396}
397
398/// The signing half, bound to the id agents know it by.
399///
400/// The key and its `kid` are only meaningful together, so nothing here hands
401/// out one without the other. Signing under an id whose public half agents
402/// hold for a *different* key produces `command_signature_invalid` on every
403/// machine at once — which reads as a fleet-wide forgery, not as the
404/// misconfiguration it is. Any API that lets the two be supplied separately is
405/// a way to reach that state, and `resolve_kid` on the generating side already
406/// refuses the other way in (two keys sharing one id).
407pub struct Signer {
408    key: SigningKey,
409    kid: String,
410}
411
412impl Signer {
413    pub fn new(key: SigningKey, kid: impl Into<String>) -> Self {
414        Self {
415            key,
416            kid: kid.into(),
417        }
418    }
419
420    /// Build from the encoded secret as it rests in the registry or the
421    /// environment, rejecting an empty `kid` rather than signing under one.
422    ///
423    /// An empty id is not a cosmetic problem: `Kanade-Sig-Kid: ""` matches no
424    /// keyring entry, so every agent that holds keys reports
425    /// `command_signature_unknown_key` — the signal that is supposed to mean
426    /// "this agent missed a rotation". Producing it from a backend-side typo
427    /// would train operators to ignore the one alarm the rotation procedure
428    /// depends on.
429    ///
430    /// (An agent holding *no* keys reports `command_signature_unprovisioned`
431    /// instead, since it cannot tell an empty id from any other it lacks. The
432    /// distinction is the agent's, in `command_verify`; the harm named above is
433    /// the one that lands on an already-provisioned fleet.)
434    pub fn from_secret(secret: &str, kid: &str) -> Result<Self, String> {
435        if kid.trim().is_empty() {
436            return Err("the signing key id is empty".to_string());
437        }
438        Ok(Self::new(decode_secret(secret)?, kid))
439    }
440
441    pub fn kid(&self) -> &str {
442        &self.kid
443    }
444
445    pub fn verifying_key(&self) -> VerifyingKey {
446        self.key.verifying_key()
447    }
448
449    /// Sign `body` as of `at_ms`, yielding the headers to publish with it.
450    pub fn headers(&self, body: &[u8], at_ms: i64) -> SigHeaders {
451        sign(&self.key, &self.kid, body, at_ms)
452    }
453}
454
455/// Which half of a `(secret, kid)` pair is missing.
456///
457/// Returned rather than a formatted message so each caller can name the store
458/// it looked in — the registry, an environment variable pair, a config file —
459/// while the rule itself lives in one place. Two callers writing their own
460/// version of "half is an error" is how they end up disagreeing about it.
461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462pub enum MissingHalf {
463    /// A key id with no key to go with it.
464    Key,
465    /// A key with no id. Cannot be signed with: `Kanade-Sig-Kid` has to name
466    /// something the fleet's keyring recognises, and guessing produces a
467    /// signature every agent reports as unattributable.
468    Kid,
469}
470
471/// Classify a possibly-half-configured `(secret, kid)` pair.
472///
473/// `Ok(None)` = nothing configured, which is a legitimate state everywhere
474/// this is used (a host that does not sign, an operator without a break-glass
475/// key to hand). `Err` = exactly one half present, which is never intentional
476/// and must not silently fall back to unsigned: signing under a mismatched or
477/// invented id produces `command_signature_invalid` fleet-wide, which is the
478/// forgery alarm raised by a configuration mistake.
479pub fn pair<'a>(
480    secret: Option<&'a str>,
481    kid: Option<&'a str>,
482) -> Result<Option<(&'a str, &'a str)>, MissingHalf> {
483    match (secret, kid) {
484        (Some(s), Some(k)) => Ok(Some((s, k))),
485        (Some(_), None) => Err(MissingHalf::Kid),
486        (None, Some(_)) => Err(MissingHalf::Key),
487        (None, None) => Ok(None),
488    }
489}
490
491/// The JSON object an agent's `CommandKeys` array holds for a **break-glass**
492/// key.
493///
494/// Differs from [`keyring_entry`] by carrying `max_age_secs`, which is what
495/// makes the agent treat it as break-glass at all: `parse_keyring` maps a
496/// present `max_age_secs` to [`KeyPolicy::break_glass`], and that constructor
497/// is what sets `audit_every_use`.
498///
499/// Deliberately does **not** emit `audit_every_use`. The agent ignores that
500/// field whenever `max_age_secs` is present — auditing is not optional for a
501/// break-glass key — so printing it would put a value in the operator's file
502/// that looks adjustable and is not. An entry that lies about what it controls
503/// is worse than one that omits it.
504pub fn break_glass_keyring_entry(
505    kid: &str,
506    key: &VerifyingKey,
507    label: &str,
508    max_age: Duration,
509) -> serde_json::Value {
510    serde_json::json!({
511        "kid": kid,
512        "public_key": encode_public(key),
513        "label": label,
514        "max_age_secs": max_age.as_secs(),
515    })
516}
517
518/// Hand-written so the private key cannot reach a log line.
519///
520/// `SigningKey` derives `Debug` and prints its bytes, so a derived impl here
521/// would put the fleet's crown jewel into any `tracing` call that formats the
522/// struct — including the ones nobody writes deliberately, like a `#[derive(
523/// Debug)]` on an enclosing type.
524impl std::fmt::Debug for Signer {
525    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
526        f.debug_struct("Signer").field("kid", &self.kid).finish()
527    }
528}
529
530/// Registry subkey + value holding the backend's **private** signing key.
531///
532/// Lives beside `StaticToken` / `JwtSecret` in the key `deploy-backend.ps1`
533/// already hardens to SYSTEM + Administrators. Registry ACLs are per-key, not
534/// per-value, so a value written into that key inherits the protection — which
535/// is why the writer must refuse to *create* the key: creating it would
536/// produce an unhardened one and leave the signing key world-readable.
537pub const REG_BACKEND_SUBKEY: &str = r"SOFTWARE\kanade\backend";
538pub const REG_SIGNING_KEY: &str = "CommandSigningKey";
539/// Registry value holding the `kid` that names the key beside it.
540///
541/// Persisted rather than re-derived. The signing path needs it to fill
542/// `Kanade-Sig-Kid`, and it is the operator's choice — re-deriving a date
543/// stamp at signing time would produce a different id than the one already
544/// distributed to agents whenever a key is generated on one day and first used
545/// on another. An id that disagrees with the fleet's keyring is
546/// indistinguishable, from the agent's side, from an unknown signer.
547pub const REG_SIGNING_KID: &str = "CommandSigningKid";
548
549/// Mint a fresh signing keypair.
550pub fn generate_keypair() -> Result<SigningKey, String> {
551    // Seeded from the OS CSPRNG directly rather than through
552    // `SigningKey::generate`, which wants a `rand_core` RNG — and this
553    // workspace carries three incompatible `rand_core` majors transitively.
554    // Filling 32 bytes sidesteps the version pairing entirely, and the seed
555    // *is* the key, so nothing is lost.
556    let mut seed = [0u8; 32];
557    getrandom::fill(&mut seed).map_err(|e| format!("OS randomness unavailable: {e}"))?;
558    Ok(SigningKey::from_bytes(&seed))
559}
560
561/// Base64 of the 32-byte seed. This is the secret; it is written to the
562/// registry and never printed by any code path that logs.
563pub fn encode_secret(key: &SigningKey) -> String {
564    base64_encode(&key.to_bytes())
565}
566
567pub fn decode_secret(raw: &str) -> Result<SigningKey, String> {
568    let bytes = base64_decode(raw)?;
569    let arr: [u8; 32] = bytes
570        .as_slice()
571        .try_into()
572        .map_err(|_| format!("signing key must be 32 bytes, got {}", bytes.len()))?;
573    Ok(SigningKey::from_bytes(&arr))
574}
575
576pub fn encode_public(key: &VerifyingKey) -> String {
577    base64_encode(key.as_bytes())
578}
579
580/// The JSON object an agent's `CommandKeys` array holds for this key.
581///
582/// Emitted by the generator so the operator distributes a value that is
583/// correct by construction rather than assembling it by hand — the shape is
584/// parsed by `kanade-agent`'s `parse_keyring`, and a hand-built entry that
585/// fails to parse takes the whole ring with it.
586pub fn keyring_entry(kid: &str, key: &VerifyingKey, label: &str) -> serde_json::Value {
587    serde_json::json!({
588        "kid": kid,
589        "public_key": encode_public(key),
590        "label": label,
591    })
592}
593
594fn base64_encode(bytes: &[u8]) -> String {
595    use base64::Engine;
596    base64::engine::general_purpose::STANDARD.encode(bytes)
597}
598
599fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
600    use base64::Engine;
601    base64::engine::general_purpose::STANDARD
602        .decode(s)
603        .map_err(|e| e.to_string())
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    /// A fixed "now" so the tests never depend on wall-clock time.
611    const NOW: i64 = 1_700_000_000_000;
612
613    fn keypair(seed: u8) -> (SigningKey, VerifyingKey) {
614        let sk = SigningKey::from_bytes(&[seed; 32]);
615        let vk = sk.verifying_key();
616        (sk, vk)
617    }
618
619    fn ring_with(kid: &str, vk: VerifyingKey) -> KeyRing {
620        let mut r = KeyRing::new();
621        r.insert(kid, vk, KeyPolicy::backend("backend"));
622        r
623    }
624
625    #[test]
626    fn round_trip_accepts_the_bytes_that_were_signed() {
627        let (sk, vk) = keypair(1);
628        let ring = ring_with("backend-1", vk);
629        let body = br#"{"id":"job","request_id":"r1"}"#;
630
631        let headers = sign(&sk, "backend-1", body, NOW);
632        let ok = verify(&ring, body, &headers, NOW).expect("verifies");
633        assert_eq!(ok.kid, "backend-1");
634        assert_eq!(ok.policy.label, "backend");
635    }
636
637    #[test]
638    fn a_single_flipped_byte_fails() {
639        let (sk, vk) = keypair(1);
640        let ring = ring_with("backend-1", vk);
641        let headers = sign(&sk, "backend-1", b"run this", NOW);
642        // The whole point: bytes the backend did not author must not execute.
643        assert_eq!(
644            verify(&ring, b"run thit", &headers, NOW),
645            Err(VerifyError::BadSignature {
646                kid: "backend-1".into()
647            })
648        );
649    }
650
651    #[test]
652    fn a_forged_command_from_another_key_fails() {
653        // The #1155 attacker: holds a NATS credential, can place bytes on a
654        // command subject, and signs with a key of their own.
655        let (_, backend_vk) = keypair(1);
656        let (attacker_sk, _) = keypair(9);
657        let ring = ring_with("backend-1", backend_vk);
658
659        let body = b"malicious";
660        // Signed with the attacker's key but claiming the backend's kid — the
661        // kid being outside the signature buys them nothing.
662        let headers = sign(&attacker_sk, "backend-1", body, NOW);
663        assert_eq!(
664            verify(&ring, body, &headers, NOW),
665            Err(VerifyError::BadSignature {
666                kid: "backend-1".into()
667            })
668        );
669    }
670
671    #[test]
672    fn unsigned_is_distinct_from_every_failure() {
673        let (_, vk) = keypair(1);
674        let ring = ring_with("backend-1", vk);
675        // Stages 1–2 deliver unsigned commands as a matter of course. If this
676        // collapsed into the same error as a bad signature, a real forgery
677        // would be indistinguishable from normal traffic for the whole
678        // migration window.
679        assert_eq!(
680            verify(&ring, b"anything", &SigHeaders::default(), NOW),
681            Err(VerifyError::Unsigned)
682        );
683    }
684
685    #[test]
686    fn an_unknown_kid_is_its_own_error() {
687        let (sk, vk) = keypair(1);
688        let ring = ring_with("backend-1", vk);
689        let headers = sign(&sk, "backend-2", b"body", NOW);
690        // The mid-rotation state: correctly signed, but this agent has not
691        // been given the new key. Reported distinctly because "your keyring is
692        // stale" and "someone forged this" need opposite responses.
693        assert_eq!(
694            verify(&ring, b"body", &headers, NOW),
695            Err(VerifyError::UnknownKid {
696                kid: "backend-2".into()
697            })
698        );
699    }
700
701    #[test]
702    fn rotation_is_two_kids_on_one_ring() {
703        // Rotation shares its implementation with multi-signer rather than
704        // being a separate mechanism — this is that claim, as a test.
705        let (old_sk, old_vk) = keypair(1);
706        let (new_sk, new_vk) = keypair(2);
707        let mut ring = ring_with("backend-1", old_vk);
708        ring.insert("backend-2", new_vk, KeyPolicy::backend("backend (new)"));
709
710        for (sk, kid) in [(&old_sk, "backend-1"), (&new_sk, "backend-2")] {
711            let headers = sign(sk, kid, b"during the window", NOW);
712            assert_eq!(
713                verify(&ring, b"during the window", &headers, NOW)
714                    .unwrap()
715                    .kid,
716                kid
717            );
718        }
719    }
720
721    #[test]
722    fn policies_are_per_key_not_per_ring() {
723        // Without this, "multiple signers" means "more keys that can do
724        // everything", which is worse than one key.
725        let (_, backend_vk) = keypair(1);
726        let (bg_sk, bg_vk) = keypair(3);
727        let mut ring = ring_with("backend-1", backend_vk);
728        ring.insert(
729            "break-glass",
730            bg_vk,
731            KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
732        );
733
734        let headers = sign(&bg_sk, "break-glass", b"emergency", NOW);
735        let ok = verify(&ring, b"emergency", &headers, NOW).unwrap();
736        assert!(
737            ok.policy.audit_every_use,
738            "break-glass use must be recorded"
739        );
740        assert_eq!(ok.policy.max_age, Some(Duration::from_secs(300)));
741
742        // The ordinary signer keeps the ordinary policy.
743        let (sk, vk) = keypair(1);
744        let mut r2 = KeyRing::new();
745        r2.insert("backend-1", vk, KeyPolicy::backend("backend"));
746        let h = sign(&sk, "backend-1", b"routine", NOW);
747        assert!(
748            !verify(&r2, b"routine", &h, NOW)
749                .unwrap()
750                .policy
751                .audit_every_use
752        );
753    }
754
755    #[test]
756    fn partial_or_unreadable_headers_are_rejected_rather_than_ignored() {
757        let (_, vk) = keypair(1);
758        let ring = ring_with("backend-1", vk);
759
760        // A signature with no kid cannot be attributed. Treating it as
761        // "unsigned" would let an attacker strip the kid to slip a message
762        // through the stage-1/2 accept-unsigned path.
763        let no_kid = SigHeaders {
764            sig_b64: Some("AAAA".into()),
765            kid: None,
766            alg: None,
767            at_ms: None,
768        };
769        assert!(matches!(
770            verify(&ring, b"x", &no_kid, NOW),
771            Err(VerifyError::Malformed(_))
772        ));
773
774        // A kid with no signature is equally not "unsigned".
775        let no_sig = SigHeaders {
776            sig_b64: None,
777            kid: Some("backend-1".into()),
778            alg: None,
779            at_ms: None,
780        };
781        assert!(matches!(
782            verify(&ring, b"x", &no_sig, NOW),
783            Err(VerifyError::Malformed(_))
784        ));
785
786        // Only the algorithm header, with nothing to verify. It still claims
787        // to be signed, so it must not fall through to the accept-unsigned
788        // path: the rule is "no signing headers at all", not "none of the two
789        // that carry the signature".
790        let alg_only = SigHeaders {
791            sig_b64: None,
792            kid: None,
793            alg: Some(ALG_ED25519.into()),
794            at_ms: None,
795        };
796        assert!(matches!(
797            verify(&ring, b"x", &alg_only, NOW),
798            Err(VerifyError::Malformed(_))
799        ));
800
801        // Not base64 at all.
802        let junk = SigHeaders {
803            sig_b64: Some("!!!not base64!!!".into()),
804            kid: Some("backend-1".into()),
805            alg: None,
806            at_ms: Some(NOW.to_string()),
807        };
808        assert!(matches!(
809            verify(&ring, b"x", &junk, NOW),
810            Err(VerifyError::Malformed(_))
811        ));
812    }
813
814    #[test]
815    fn an_unexpected_algorithm_is_refused_not_assumed() {
816        let (sk, vk) = keypair(1);
817        let ring = ring_with("backend-1", vk);
818        let mut headers = sign(&sk, "backend-1", b"body", NOW);
819        headers.alg = Some("hmac-sha256".into());
820        assert_eq!(
821            verify(&ring, b"body", &headers, NOW),
822            Err(VerifyError::UnsupportedAlg {
823                alg: "hmac-sha256".into()
824            })
825        );
826    }
827
828    #[test]
829    fn an_absent_alg_header_means_the_original_shape() {
830        // Forward compatibility in the other direction: a signer that predates
831        // the alg header must still verify.
832        let (sk, vk) = keypair(1);
833        let ring = ring_with("backend-1", vk);
834        let mut headers = sign(&sk, "backend-1", b"body", NOW);
835        headers.alg = None;
836        assert!(verify(&ring, b"body", &headers, NOW).is_ok());
837    }
838
839    #[test]
840    fn the_signing_time_is_covered_by_the_signature() {
841        // The asymmetry with `kid`: rewriting the timestamp must not verify,
842        // or a captured message could be replayed by simply making it look
843        // fresh. This is the whole reason `at` sits inside the signed
844        // material.
845        let (sk, vk) = keypair(1);
846        let ring = ring_with("backend-1", vk);
847        let mut headers = sign(&sk, "backend-1", b"body", NOW);
848        headers.at_ms = Some((NOW + 1).to_string());
849        assert_eq!(
850            verify(&ring, b"body", &headers, NOW),
851            Err(VerifyError::BadSignature {
852                kid: "backend-1".into()
853            })
854        );
855    }
856
857    #[test]
858    fn the_ordinary_signer_accepts_a_week_old_command() {
859        // Not a nicety: the agent's JetStream replay redelivers the retained
860        // command per subject for 7 days, so an agent reconnecting after a
861        // week legitimately receives week-old commands. A freshness bound on
862        // the backend key would reject exactly that traffic.
863        let (sk, vk) = keypair(1);
864        let ring = ring_with("backend-1", vk);
865        let week = 7 * 24 * 60 * 60 * 1000;
866        let headers = sign(&sk, "backend-1", b"scheduled job", NOW - week);
867        assert!(verify(&ring, b"scheduled job", &headers, NOW).is_ok());
868    }
869
870    #[test]
871    fn a_bounded_key_rejects_an_old_signature() {
872        let (sk, vk) = keypair(3);
873        let mut ring = KeyRing::new();
874        ring.insert(
875            "break-glass",
876            vk,
877            KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
878        );
879
880        // Inside the window.
881        let fresh = sign(&sk, "break-glass", b"emergency", NOW - 60_000);
882        assert!(verify(&ring, b"emergency", &fresh, NOW).is_ok());
883
884        // Past it. This is the case that stops a first-boot agent executing a
885        // week-old emergency command: its dedup cache is empty, so freshness
886        // is the only thing left.
887        let old = sign(&sk, "break-glass", b"emergency", NOW - 600_000);
888        assert_eq!(
889            verify(&ring, b"emergency", &old, NOW),
890            Err(VerifyError::Stale {
891                kid: "break-glass".into(),
892                age_ms: 600_000,
893                max_age_ms: 300_000,
894            })
895        );
896    }
897
898    #[test]
899    fn a_signature_from_the_future_is_not_fresh() {
900        // A clock that disagrees, or a signer trying to mint something that
901        // outlives its bound. Bounding only the old side would let a
902        // far-future timestamp stay valid indefinitely.
903        let (sk, vk) = keypair(3);
904        let mut ring = KeyRing::new();
905        ring.insert(
906            "break-glass",
907            vk,
908            KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
909        );
910        let ahead = sign(&sk, "break-glass", b"emergency", NOW + 3_600_000);
911        assert!(matches!(
912            verify(&ring, b"emergency", &ahead, NOW),
913            Err(VerifyError::Stale { .. })
914        ));
915        // Modest skew inside the window still passes, so a machine a few
916        // seconds ahead is not locked out.
917        let skewed = sign(&sk, "break-glass", b"emergency", NOW + 5_000);
918        assert!(verify(&ring, b"emergency", &skewed, NOW).is_ok());
919    }
920
921    #[test]
922    fn freshness_is_only_judged_after_the_signature_holds() {
923        // Otherwise anyone could provoke a `Stale` report — which at stage 3
924        // is a rejection an operator has to investigate — by sending garbage
925        // with an old timestamp.
926        let (_, vk) = keypair(3);
927        let mut ring = KeyRing::new();
928        ring.insert(
929            "break-glass",
930            vk,
931            KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
932        );
933        let forged = SigHeaders {
934            sig_b64: Some(base64_encode(&[7u8; 64])),
935            kid: Some("break-glass".into()),
936            alg: Some(ALG_ED25519.into()),
937            at_ms: Some((NOW - 600_000).to_string()),
938        };
939        assert_eq!(
940            verify(&ring, b"x", &forged, NOW),
941            Err(VerifyError::BadSignature {
942                kid: "break-glass".into()
943            })
944        );
945    }
946
947    #[test]
948    fn a_missing_or_unparseable_signing_time_is_malformed() {
949        let (sk, vk) = keypair(1);
950        let ring = ring_with("backend-1", vk);
951
952        let mut no_at = sign(&sk, "backend-1", b"body", NOW);
953        no_at.at_ms = None;
954        assert!(matches!(
955            verify(&ring, b"body", &no_at, NOW),
956            Err(VerifyError::Malformed(_))
957        ));
958
959        let mut junk_at = sign(&sk, "backend-1", b"body", NOW);
960        junk_at.at_ms = Some("yesterday".into());
961        assert!(matches!(
962            verify(&ring, b"body", &junk_at, NOW),
963            Err(VerifyError::Malformed(_))
964        ));
965
966        // And a lone `at` header still counts as a signing claim rather than
967        // an absent one.
968        let at_only = SigHeaders {
969            sig_b64: None,
970            kid: None,
971            alg: None,
972            at_ms: Some(NOW.to_string()),
973        };
974        assert!(matches!(
975            verify(&ring, b"body", &at_only, NOW),
976            Err(VerifyError::Malformed(_))
977        ));
978    }
979
980    #[test]
981    fn an_absurd_bound_neither_panics_nor_inverts() {
982        // `max_age_secs` reaches this from JSON with no upper bound. A value
983        // this large is a typo rather than an attack, but the old `as i64`
984        // cast would have truncated it into a negative limit that nothing can
985        // satisfy — turning "almost never expires" into "always expired" —
986        // and could panic on the negation in a debug build.
987        let (sk, vk) = keypair(5);
988        let mut ring = KeyRing::new();
989        ring.insert(
990            "silly",
991            vk,
992            KeyPolicy::break_glass("silly", Duration::from_secs(u64::MAX / 1000)),
993        );
994        let headers = sign(&sk, "silly", b"body", NOW - 10_000);
995        assert!(
996            verify(&ring, b"body", &headers, NOW).is_ok(),
997            "an enormous bound must read as permissive, not as inverted"
998        );
999    }
1000
1001    #[test]
1002    fn a_generated_key_round_trips_through_its_encoded_secret() {
1003        // The registry stores the encoded secret; if this did not round trip,
1004        // a backend would come back from a restart unable to sign and every
1005        // agent would start reporting unsigned traffic.
1006        let key = generate_keypair().expect("OS randomness");
1007        let restored = decode_secret(&encode_secret(&key)).expect("decodes");
1008        assert_eq!(restored.to_bytes(), key.to_bytes());
1009
1010        // And the restored key produces signatures the original's public half
1011        // accepts — the property that actually matters across a restart.
1012        let ring = ring_with("backend-1", key.verifying_key());
1013        let headers = sign(&restored, "backend-1", b"after a restart", NOW);
1014        assert!(verify(&ring, b"after a restart", &headers, NOW).is_ok());
1015    }
1016
1017    #[test]
1018    fn a_signer_built_from_the_stored_secret_verifies_against_its_own_ring() {
1019        // The whole backend-side path in one assertion: the encoded secret as
1020        // it rests in the registry, through `Signer`, out as headers, verified
1021        // by the public half an agent was given.
1022        let key = generate_keypair().unwrap();
1023        let signer = Signer::from_secret(&encode_secret(&key), "backend-1").expect("builds");
1024        let ring = ring_with("backend-1", key.verifying_key());
1025
1026        let body = br#"{"id":"job","request_id":"r1"}"#;
1027        let headers = signer.headers(body, NOW);
1028        assert_eq!(verify(&ring, body, &headers, NOW).unwrap().kid, "backend-1");
1029    }
1030
1031    #[test]
1032    fn a_signer_refuses_an_empty_kid_rather_than_signing_under_one() {
1033        // `Kanade-Sig-Kid: ""` matches no keyring entry, so every agent that
1034        // holds keys would report `unknown_key` — the alarm that is supposed to
1035        // mean a rotation went wrong. A backend typo must not be able to raise
1036        // it fleet-wide. (A machine holding none reports `unprovisioned`; the
1037        // fleet this harms is the already-provisioned one.)
1038        let secret = encode_secret(&generate_keypair().unwrap());
1039        assert!(Signer::from_secret(&secret, "").is_err());
1040        assert!(Signer::from_secret(&secret, "   ").is_err());
1041        // And a bad secret is still rejected on its own terms.
1042        assert!(Signer::from_secret("not base64!!", "backend-1").is_err());
1043    }
1044
1045    #[test]
1046    fn debugging_a_signer_never_prints_the_key() {
1047        // `SigningKey` derives Debug and prints its bytes, so a derived impl
1048        // would leak the fleet's crown jewel into any log line that formats an
1049        // enclosing struct.
1050        let key = generate_keypair().unwrap();
1051        let signer = Signer::new(key.clone(), "backend-1");
1052        // Pinned exactly rather than by absence: a field added later would
1053        // otherwise have to be *remembered* to be excluded, and the failure
1054        // mode is a secret in a log file.
1055        assert_eq!(format!("{signer:?}"), r#"Signer { kid: "backend-1" }"#);
1056        assert!(!format!("{signer:?}").contains(&encode_secret(&key)));
1057    }
1058
1059    #[test]
1060    fn a_half_configured_pair_names_the_missing_half() {
1061        assert_eq!(
1062            pair(Some("secret"), Some("kid")),
1063            Ok(Some(("secret", "kid")))
1064        );
1065        assert_eq!(pair(None, None), Ok(None));
1066        // The two failures are distinguished because the fixes differ: one
1067        // needs a key generated, the other needs the id it was distributed
1068        // under.
1069        assert_eq!(pair(Some("secret"), None), Err(MissingHalf::Kid));
1070        assert_eq!(pair(None, Some("kid")), Err(MissingHalf::Key));
1071    }
1072
1073    #[test]
1074    fn a_break_glass_entry_carries_the_bound_and_not_the_audit_flag() {
1075        // `max_age_secs` is what makes the agent treat this as break-glass at
1076        // all (`parse_keyring` maps it to `KeyPolicy::break_glass`, which is
1077        // what turns auditing on). Emitting `audit_every_use` too would put a
1078        // field in the operator's file that the agent ignores — adjustable in
1079        // appearance, fixed in fact.
1080        let key = generate_keypair().unwrap();
1081        let entry = break_glass_keyring_entry(
1082            "break-glass-20260730",
1083            &key.verifying_key(),
1084            "break-glass",
1085            Duration::from_secs(900),
1086        );
1087        assert_eq!(entry["kid"], "break-glass-20260730");
1088        assert_eq!(entry["max_age_secs"], 900);
1089        assert_eq!(entry["public_key"], encode_public(&key.verifying_key()));
1090        assert!(
1091            entry.get("audit_every_use").is_none(),
1092            "must not print a field the agent overrides: {entry}"
1093        );
1094    }
1095
1096    #[test]
1097    fn two_generated_keys_differ() {
1098        // Cheap guard against a seeding mistake that returns a constant —
1099        // which would make every backend in existence share one key.
1100        let a = generate_keypair().unwrap();
1101        let b = generate_keypair().unwrap();
1102        assert_ne!(a.to_bytes(), b.to_bytes());
1103    }
1104
1105    #[test]
1106    fn a_malformed_secret_is_rejected_with_its_length() {
1107        assert!(decode_secret("not base64!!").is_err());
1108        let short = base64_encode(&[0u8; 31]);
1109        let err = decode_secret(&short).unwrap_err();
1110        assert!(
1111            err.contains("31"),
1112            "the error should name the length: {err}"
1113        );
1114    }
1115
1116    #[test]
1117    fn the_emitted_keyring_entry_is_what_the_agent_parses() {
1118        // The generator prints this for an operator to distribute. It has to
1119        // match `kanade-agent`'s `parse_keyring` shape exactly — a hand-fixed
1120        // entry that fails to parse takes the entire ring with it, and at
1121        // stage 3 an empty ring rejects every command on that machine.
1122        let key = generate_keypair().unwrap();
1123        let entry = keyring_entry("backend-20260728", &key.verifying_key(), "backend");
1124        assert_eq!(entry["kid"], "backend-20260728");
1125        assert_eq!(entry["label"], "backend");
1126        let pk = entry["public_key"]
1127            .as_str()
1128            .expect("public_key is a string");
1129        assert_eq!(pk, encode_public(&key.verifying_key()));
1130        // 32 bytes base64-encodes to 44 chars with padding.
1131        assert_eq!(pk.len(), 44);
1132    }
1133}