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
59use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
60
61/// Header carrying the base64 (standard, padded) Ed25519 signature over the
62/// message body.
63pub const SIG: &str = "Kanade-Sig";
64/// Header naming which key signed it. Present from the first release; see the
65/// module doc on why it is not deferred.
66pub const SIG_KID: &str = "Kanade-Sig-Kid";
67/// Header naming the algorithm. Ed25519 today; present so a future migration
68/// is a value change rather than a format change.
69pub const SIG_ALG: &str = "Kanade-Sig-Alg";
70/// Header carrying when the message was signed, as decimal milliseconds since
71/// the Unix epoch. **Covered by the signature** — see [`signed_material`].
72pub const SIG_AT: &str = "Kanade-Sig-At";
73
74/// The only algorithm currently emitted or accepted.
75pub const ALG_ED25519: &str = "ed25519";
76
77/// The bytes a signature actually covers: the signing time, then the message.
78///
79/// The timestamp is **inside** the signed material, unlike [`SIG_KID`], and
80/// the asymmetry is easy to get backwards. A rewritten `kid` can only select a
81/// key under which verification fails, so it is safe outside. A rewritten
82/// timestamp would let anyone replay a captured message by making it look
83/// fresh again — which is the entire freshness bound, defeated by editing one
84/// header. So it is signed.
85///
86/// The prefix is fixed-width rather than delimited so there is no
87/// concatenation ambiguity to reason about: no timestamp encoding can be
88/// chosen such that one `(at, body)` pair produces the same bytes as another.
89/// Keeping it a prefix rather than a field also leaves the `Command` wire type
90/// untouched — agent-authored in-process commands would otherwise carry a
91/// field that means nothing for them.
92pub fn signed_material(at_ms: i64, body: &[u8]) -> Vec<u8> {
93    let mut out = Vec::with_capacity(8 + body.len());
94    out.extend_from_slice(&at_ms.to_be_bytes());
95    out.extend_from_slice(body);
96    out
97}
98
99/// What a key is allowed to authorise.
100///
101/// Exists from the first release with a single variant in practical use,
102/// because a keyring without policy is just more keys that can do everything —
103/// strictly worse than one key. Adding the concept later would mean auditing
104/// every existing key's authority retroactively.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct KeyPolicy {
107    /// Human-facing note for logs and audit ("backend", "break-glass").
108    pub label: String,
109    /// Reject a signature older than this. `None` = no freshness bound.
110    ///
111    /// **`None` is required for the ordinary signer, not merely convenient.**
112    /// The agent's JetStream replay path deliberately redelivers the most
113    /// recent retained command per subject, kept for 7 days, so an agent
114    /// reconnecting after a week legitimately receives week-old commands. A
115    /// bound on the backend key would reject exactly that.
116    ///
117    /// A break-glass key sets a short bound, and there the bound is doing real
118    /// work rather than being belt-and-braces. Replay dedup is keyed on
119    /// `request_id` in an in-memory cache, which is **empty on first boot** —
120    /// so without a freshness check, a machine booting for the first time
121    /// would execute a week-old emergency command that no dedup can catch.
122    pub max_age: Option<Duration>,
123    /// Emit an audit record on **every** use of this key, including failed
124    /// verifications.
125    ///
126    /// A break-glass credential whose use nobody investigates is a second
127    /// production key with extra steps, so the flag lives next to the key
128    /// rather than in a call site that can forget it.
129    pub audit_every_use: bool,
130}
131
132impl KeyPolicy {
133    /// The ordinary signer: no extra freshness bound, no per-use audit (the
134    /// command itself is already audited).
135    pub fn backend(label: impl Into<String>) -> Self {
136        Self {
137            label: label.into(),
138            max_age: None,
139            audit_every_use: false,
140        }
141    }
142
143    /// A deliberately-guarded key: short-lived signatures, every use recorded.
144    pub fn break_glass(label: impl Into<String>, max_age: Duration) -> Self {
145        Self {
146            label: label.into(),
147            max_age: Some(max_age),
148            audit_every_use: true,
149        }
150    }
151}
152
153/// The public keys an agent trusts, keyed by `kid`.
154#[derive(Debug, Clone, Default)]
155pub struct KeyRing {
156    keys: BTreeMap<String, (VerifyingKey, KeyPolicy)>,
157}
158
159impl KeyRing {
160    pub fn new() -> Self {
161        Self::default()
162    }
163
164    pub fn insert(&mut self, kid: impl Into<String>, key: VerifyingKey, policy: KeyPolicy) {
165        self.keys.insert(kid.into(), (key, policy));
166    }
167
168    pub fn is_empty(&self) -> bool {
169        self.keys.is_empty()
170    }
171
172    /// Look a key up, yielding the ring's own `kid` string so a verified
173    /// result borrows from the trusted ring rather than from the attacker-
174    /// supplied header it was matched against.
175    pub fn get(&self, kid: &str) -> Option<(&str, &VerifyingKey, &KeyPolicy)> {
176        self.keys
177            .get_key_value(kid)
178            .map(|(k, (key, policy))| (k.as_str(), key, policy))
179    }
180
181    /// Every `kid` on the ring, for reporting which keys an agent holds.
182    pub fn kids(&self) -> impl Iterator<Item = &str> {
183        self.keys.keys().map(String::as_str)
184    }
185}
186
187/// Why a message was not accepted as backend-authored.
188///
189/// Deliberately distinguishes "carries no signature" from every other case.
190/// During stages 1–2 of the rollout `Unsigned` is expected and benign, while
191/// the rest mean something is wrong — conflating them would hide a real
192/// failure inside a normal one for the whole migration window.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum VerifyError {
195    /// No signature headers at all. Normal until enforcement is switched on.
196    Unsigned,
197    /// Signed, but the `kid` is not on this agent's ring.
198    ///
199    /// The operationally dangerous case: an agent that never received a new
200    /// key looks, from the operator's side, exactly like a backend that is not
201    /// sending commands — and a mid-rotation fleet is full of agents in that
202    /// state. Callers must surface this, not just log it locally.
203    UnknownKid { kid: String },
204    /// Signed with an algorithm this build does not implement.
205    UnsupportedAlg { alg: String },
206    /// Signature header present but not decodable as a signature.
207    Malformed(String),
208    /// Cryptographically invalid for the bytes received.
209    BadSignature { kid: String },
210    /// Valid, by a key whose policy bounds how old a signature may be, and
211    /// this one is older. Distinct from [`VerifyError::BadSignature`] because
212    /// the signature is genuine — what failed is the policy, and the two want
213    /// different responses.
214    Stale {
215        kid: String,
216        age_ms: i64,
217        max_age_ms: u128,
218    },
219}
220
221impl std::fmt::Display for VerifyError {
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        match self {
224            VerifyError::Unsigned => write!(f, "no signature"),
225            VerifyError::UnknownKid { kid } => {
226                write!(
227                    f,
228                    "signed by unknown key id {kid} — is this agent's keyring current?"
229                )
230            }
231            VerifyError::UnsupportedAlg { alg } => {
232                write!(f, "unsupported signature algorithm {alg}")
233            }
234            VerifyError::Malformed(e) => write!(f, "malformed signature: {e}"),
235            VerifyError::BadSignature { kid } => {
236                write!(f, "signature by {kid} does not match these bytes")
237            }
238            VerifyError::Stale {
239                kid,
240                age_ms,
241                max_age_ms,
242            } => write!(
243                f,
244                "signature by {kid} is {age_ms}ms old, past its {max_age_ms}ms bound"
245            ),
246        }
247    }
248}
249
250impl std::error::Error for VerifyError {}
251
252/// A verified message: which key vouched for it, and under what policy.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct Verified<'a> {
255    pub kid: &'a str,
256    pub policy: &'a KeyPolicy,
257}
258
259/// The signature headers carried alongside a message body.
260///
261/// Extracted from the transport by the caller so this module stays free of a
262/// NATS dependency and is testable without a broker.
263#[derive(Debug, Clone, Default, PartialEq, Eq)]
264pub struct SigHeaders {
265    pub sig_b64: Option<String>,
266    pub kid: Option<String>,
267    pub alg: Option<String>,
268    /// Decimal milliseconds since the Unix epoch, as sent. Parsed rather than
269    /// trusted — it is covered by the signature, so a value that does not
270    /// reconstruct the signed material simply fails verification.
271    pub at_ms: Option<String>,
272}
273
274impl SigHeaders {
275    /// True only when the message makes **no signing claim at all**.
276    ///
277    /// Any one of these headers means the sender is asserting the message is
278    /// signed, so a partial set is a broken claim rather than an absent one.
279    /// `alg` counts for the same reason `kid` does: the rule has to be "no
280    /// signing headers at all", not "none of the two I happened to think of",
281    /// or stripping whichever header is unchecked reclassifies a malformed
282    /// message as an unsigned one — and unsigned is the accepted case for the
283    /// whole of stages 1-2.
284    pub fn is_absent(&self) -> bool {
285        self.sig_b64.is_none() && self.kid.is_none() && self.alg.is_none() && self.at_ms.is_none()
286    }
287}
288
289/// Verify `body` against the signature headers using `ring`.
290///
291/// Verification is over the **exact received bytes**; deserialization happens
292/// afterwards, at the caller. That ordering is what removes canonicalisation
293/// from the problem — there is no need for serde to produce byte-identical
294/// output on both sides, only for the bytes to arrive unchanged.
295pub fn verify<'a>(
296    ring: &'a KeyRing,
297    body: &[u8],
298    headers: &SigHeaders,
299    now_ms: i64,
300) -> Result<Verified<'a>, VerifyError> {
301    if headers.is_absent() {
302        return Err(VerifyError::Unsigned);
303    }
304    // A signature with no kid cannot be attributed, so it is not "unsigned" —
305    // it is a signed message we cannot route to a key.
306    let kid = headers
307        .kid
308        .as_deref()
309        .ok_or_else(|| VerifyError::Malformed("signature without a key id".into()))?;
310    let sig_b64 = headers
311        .sig_b64
312        .as_deref()
313        .ok_or_else(|| VerifyError::Malformed("key id without a signature".into()))?;
314    let at_raw = headers
315        .at_ms
316        .as_deref()
317        .ok_or_else(|| VerifyError::Malformed("signature without a signing time".into()))?;
318    let at_ms: i64 = at_raw
319        .parse()
320        .map_err(|_| VerifyError::Malformed(format!("signing time {at_raw} is not a number")))?;
321
322    // Absent alg means the original single-algorithm shape; anything else must
323    // match exactly rather than being guessed at.
324    if let Some(alg) = headers.alg.as_deref()
325        && alg != ALG_ED25519
326    {
327        return Err(VerifyError::UnsupportedAlg {
328            alg: alg.to_owned(),
329        });
330    }
331
332    // The kid is a key-selection hint and is NOT covered by the signature.
333    // That is safe: rewriting it can only select a key under which
334    // verification fails, never make an invalid signature pass. The signing
335    // time is the opposite case and IS covered — see `signed_material`.
336    let (ring_kid, key, policy) = ring.get(kid).ok_or_else(|| VerifyError::UnknownKid {
337        kid: kid.to_owned(),
338    })?;
339
340    let raw = base64_decode(sig_b64).map_err(VerifyError::Malformed)?;
341    let sig = Signature::from_slice(&raw).map_err(|e| VerifyError::Malformed(e.to_string()))?;
342    key.verify(&signed_material(at_ms, body), &sig)
343        .map_err(|_| VerifyError::BadSignature {
344            kid: kid.to_owned(),
345        })?;
346
347    // Freshness is checked only AFTER the signature holds, so a stale verdict
348    // is always about a genuine message. Checking it first would let anyone
349    // provoke a `Stale` report by sending garbage with an old timestamp.
350    if let Some(max_age) = policy.max_age {
351        let age_ms = now_ms - at_ms;
352        let max_age_ms = max_age.as_millis();
353        // Compared in i128 rather than casting the bound down to i64. A
354        // `max_age_secs` large enough to overflow the cast is nonsense config
355        // rather than an attack (the registry holding it is ACL'd), but the
356        // truncation would silently turn a bound into its opposite — a
357        // negative limit that nothing can satisfy, or one so large the key is
358        // effectively unbounded — and `-(x as i64)` can panic outright on
359        // i64::MIN in a debug build. i128 holds every value `Duration::as_millis`
360        // can produce, so there is nothing left to get wrong.
361        let age = age_ms as i128;
362        let bound = max_age_ms as i128;
363        // A signature from the future is not "fresh" — it is a clock that
364        // disagrees, and treating it as valid would let a skewed or hostile
365        // signer mint credentials that outlive the bound. Bound both ends.
366        if age > bound || age < -bound {
367            return Err(VerifyError::Stale {
368                kid: kid.to_owned(),
369                age_ms,
370                max_age_ms,
371            });
372        }
373    }
374
375    Ok(Verified {
376        kid: ring_kid,
377        policy,
378    })
379}
380
381/// Sign `body`, producing the headers to publish alongside it.
382///
383/// Lives here rather than in the backend so the signing and verifying halves
384/// cannot drift — a round-trip test in this module covers both at once.
385pub fn sign(key: &SigningKey, kid: &str, body: &[u8], at_ms: i64) -> SigHeaders {
386    let sig = key.sign(&signed_material(at_ms, body));
387    SigHeaders {
388        sig_b64: Some(base64_encode(&sig.to_bytes())),
389        kid: Some(kid.to_owned()),
390        alg: Some(ALG_ED25519.to_owned()),
391        at_ms: Some(at_ms.to_string()),
392    }
393}
394
395/// Registry subkey + value holding the backend's **private** signing key.
396///
397/// Lives beside `StaticToken` / `JwtSecret` in the key `deploy-backend.ps1`
398/// already hardens to SYSTEM + Administrators. Registry ACLs are per-key, not
399/// per-value, so a value written into that key inherits the protection — which
400/// is why the writer must refuse to *create* the key: creating it would
401/// produce an unhardened one and leave the signing key world-readable.
402pub const REG_BACKEND_SUBKEY: &str = r"SOFTWARE\kanade\backend";
403pub const REG_SIGNING_KEY: &str = "CommandSigningKey";
404/// Registry value holding the `kid` that names the key beside it.
405///
406/// Persisted rather than re-derived. The signing path needs it to fill
407/// `Kanade-Sig-Kid`, and it is the operator's choice — re-deriving a date
408/// stamp at signing time would produce a different id than the one already
409/// distributed to agents whenever a key is generated on one day and first used
410/// on another. An id that disagrees with the fleet's keyring is
411/// indistinguishable, from the agent's side, from an unknown signer.
412pub const REG_SIGNING_KID: &str = "CommandSigningKid";
413
414/// Mint a fresh signing keypair.
415pub fn generate_keypair() -> Result<SigningKey, String> {
416    // Seeded from the OS CSPRNG directly rather than through
417    // `SigningKey::generate`, which wants a `rand_core` RNG — and this
418    // workspace carries three incompatible `rand_core` majors transitively.
419    // Filling 32 bytes sidesteps the version pairing entirely, and the seed
420    // *is* the key, so nothing is lost.
421    let mut seed = [0u8; 32];
422    getrandom::fill(&mut seed).map_err(|e| format!("OS randomness unavailable: {e}"))?;
423    Ok(SigningKey::from_bytes(&seed))
424}
425
426/// Base64 of the 32-byte seed. This is the secret; it is written to the
427/// registry and never printed by any code path that logs.
428pub fn encode_secret(key: &SigningKey) -> String {
429    base64_encode(&key.to_bytes())
430}
431
432pub fn decode_secret(raw: &str) -> Result<SigningKey, String> {
433    let bytes = base64_decode(raw)?;
434    let arr: [u8; 32] = bytes
435        .as_slice()
436        .try_into()
437        .map_err(|_| format!("signing key must be 32 bytes, got {}", bytes.len()))?;
438    Ok(SigningKey::from_bytes(&arr))
439}
440
441pub fn encode_public(key: &VerifyingKey) -> String {
442    base64_encode(key.as_bytes())
443}
444
445/// The JSON object an agent's `CommandKeys` array holds for this key.
446///
447/// Emitted by the generator so the operator distributes a value that is
448/// correct by construction rather than assembling it by hand — the shape is
449/// parsed by `kanade-agent`'s `parse_keyring`, and a hand-built entry that
450/// fails to parse takes the whole ring with it.
451pub fn keyring_entry(kid: &str, key: &VerifyingKey, label: &str) -> serde_json::Value {
452    serde_json::json!({
453        "kid": kid,
454        "public_key": encode_public(key),
455        "label": label,
456    })
457}
458
459fn base64_encode(bytes: &[u8]) -> String {
460    use base64::Engine;
461    base64::engine::general_purpose::STANDARD.encode(bytes)
462}
463
464fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
465    use base64::Engine;
466    base64::engine::general_purpose::STANDARD
467        .decode(s)
468        .map_err(|e| e.to_string())
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    /// A fixed "now" so the tests never depend on wall-clock time.
476    const NOW: i64 = 1_700_000_000_000;
477
478    fn keypair(seed: u8) -> (SigningKey, VerifyingKey) {
479        let sk = SigningKey::from_bytes(&[seed; 32]);
480        let vk = sk.verifying_key();
481        (sk, vk)
482    }
483
484    fn ring_with(kid: &str, vk: VerifyingKey) -> KeyRing {
485        let mut r = KeyRing::new();
486        r.insert(kid, vk, KeyPolicy::backend("backend"));
487        r
488    }
489
490    #[test]
491    fn round_trip_accepts_the_bytes_that_were_signed() {
492        let (sk, vk) = keypair(1);
493        let ring = ring_with("backend-1", vk);
494        let body = br#"{"id":"job","request_id":"r1"}"#;
495
496        let headers = sign(&sk, "backend-1", body, NOW);
497        let ok = verify(&ring, body, &headers, NOW).expect("verifies");
498        assert_eq!(ok.kid, "backend-1");
499        assert_eq!(ok.policy.label, "backend");
500    }
501
502    #[test]
503    fn a_single_flipped_byte_fails() {
504        let (sk, vk) = keypair(1);
505        let ring = ring_with("backend-1", vk);
506        let headers = sign(&sk, "backend-1", b"run this", NOW);
507        // The whole point: bytes the backend did not author must not execute.
508        assert_eq!(
509            verify(&ring, b"run thit", &headers, NOW),
510            Err(VerifyError::BadSignature {
511                kid: "backend-1".into()
512            })
513        );
514    }
515
516    #[test]
517    fn a_forged_command_from_another_key_fails() {
518        // The #1155 attacker: holds a NATS credential, can place bytes on a
519        // command subject, and signs with a key of their own.
520        let (_, backend_vk) = keypair(1);
521        let (attacker_sk, _) = keypair(9);
522        let ring = ring_with("backend-1", backend_vk);
523
524        let body = b"malicious";
525        // Signed with the attacker's key but claiming the backend's kid — the
526        // kid being outside the signature buys them nothing.
527        let headers = sign(&attacker_sk, "backend-1", body, NOW);
528        assert_eq!(
529            verify(&ring, body, &headers, NOW),
530            Err(VerifyError::BadSignature {
531                kid: "backend-1".into()
532            })
533        );
534    }
535
536    #[test]
537    fn unsigned_is_distinct_from_every_failure() {
538        let (_, vk) = keypair(1);
539        let ring = ring_with("backend-1", vk);
540        // Stages 1–2 deliver unsigned commands as a matter of course. If this
541        // collapsed into the same error as a bad signature, a real forgery
542        // would be indistinguishable from normal traffic for the whole
543        // migration window.
544        assert_eq!(
545            verify(&ring, b"anything", &SigHeaders::default(), NOW),
546            Err(VerifyError::Unsigned)
547        );
548    }
549
550    #[test]
551    fn an_unknown_kid_is_its_own_error() {
552        let (sk, vk) = keypair(1);
553        let ring = ring_with("backend-1", vk);
554        let headers = sign(&sk, "backend-2", b"body", NOW);
555        // The mid-rotation state: correctly signed, but this agent has not
556        // been given the new key. Reported distinctly because "your keyring is
557        // stale" and "someone forged this" need opposite responses.
558        assert_eq!(
559            verify(&ring, b"body", &headers, NOW),
560            Err(VerifyError::UnknownKid {
561                kid: "backend-2".into()
562            })
563        );
564    }
565
566    #[test]
567    fn rotation_is_two_kids_on_one_ring() {
568        // Rotation shares its implementation with multi-signer rather than
569        // being a separate mechanism — this is that claim, as a test.
570        let (old_sk, old_vk) = keypair(1);
571        let (new_sk, new_vk) = keypair(2);
572        let mut ring = ring_with("backend-1", old_vk);
573        ring.insert("backend-2", new_vk, KeyPolicy::backend("backend (new)"));
574
575        for (sk, kid) in [(&old_sk, "backend-1"), (&new_sk, "backend-2")] {
576            let headers = sign(sk, kid, b"during the window", NOW);
577            assert_eq!(
578                verify(&ring, b"during the window", &headers, NOW)
579                    .unwrap()
580                    .kid,
581                kid
582            );
583        }
584    }
585
586    #[test]
587    fn policies_are_per_key_not_per_ring() {
588        // Without this, "multiple signers" means "more keys that can do
589        // everything", which is worse than one key.
590        let (_, backend_vk) = keypair(1);
591        let (bg_sk, bg_vk) = keypair(3);
592        let mut ring = ring_with("backend-1", backend_vk);
593        ring.insert(
594            "break-glass",
595            bg_vk,
596            KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
597        );
598
599        let headers = sign(&bg_sk, "break-glass", b"emergency", NOW);
600        let ok = verify(&ring, b"emergency", &headers, NOW).unwrap();
601        assert!(
602            ok.policy.audit_every_use,
603            "break-glass use must be recorded"
604        );
605        assert_eq!(ok.policy.max_age, Some(Duration::from_secs(300)));
606
607        // The ordinary signer keeps the ordinary policy.
608        let (sk, vk) = keypair(1);
609        let mut r2 = KeyRing::new();
610        r2.insert("backend-1", vk, KeyPolicy::backend("backend"));
611        let h = sign(&sk, "backend-1", b"routine", NOW);
612        assert!(
613            !verify(&r2, b"routine", &h, NOW)
614                .unwrap()
615                .policy
616                .audit_every_use
617        );
618    }
619
620    #[test]
621    fn partial_or_unreadable_headers_are_rejected_rather_than_ignored() {
622        let (_, vk) = keypair(1);
623        let ring = ring_with("backend-1", vk);
624
625        // A signature with no kid cannot be attributed. Treating it as
626        // "unsigned" would let an attacker strip the kid to slip a message
627        // through the stage-1/2 accept-unsigned path.
628        let no_kid = SigHeaders {
629            sig_b64: Some("AAAA".into()),
630            kid: None,
631            alg: None,
632            at_ms: None,
633        };
634        assert!(matches!(
635            verify(&ring, b"x", &no_kid, NOW),
636            Err(VerifyError::Malformed(_))
637        ));
638
639        // A kid with no signature is equally not "unsigned".
640        let no_sig = SigHeaders {
641            sig_b64: None,
642            kid: Some("backend-1".into()),
643            alg: None,
644            at_ms: None,
645        };
646        assert!(matches!(
647            verify(&ring, b"x", &no_sig, NOW),
648            Err(VerifyError::Malformed(_))
649        ));
650
651        // Only the algorithm header, with nothing to verify. It still claims
652        // to be signed, so it must not fall through to the accept-unsigned
653        // path: the rule is "no signing headers at all", not "none of the two
654        // that carry the signature".
655        let alg_only = SigHeaders {
656            sig_b64: None,
657            kid: None,
658            alg: Some(ALG_ED25519.into()),
659            at_ms: None,
660        };
661        assert!(matches!(
662            verify(&ring, b"x", &alg_only, NOW),
663            Err(VerifyError::Malformed(_))
664        ));
665
666        // Not base64 at all.
667        let junk = SigHeaders {
668            sig_b64: Some("!!!not base64!!!".into()),
669            kid: Some("backend-1".into()),
670            alg: None,
671            at_ms: Some(NOW.to_string()),
672        };
673        assert!(matches!(
674            verify(&ring, b"x", &junk, NOW),
675            Err(VerifyError::Malformed(_))
676        ));
677    }
678
679    #[test]
680    fn an_unexpected_algorithm_is_refused_not_assumed() {
681        let (sk, vk) = keypair(1);
682        let ring = ring_with("backend-1", vk);
683        let mut headers = sign(&sk, "backend-1", b"body", NOW);
684        headers.alg = Some("hmac-sha256".into());
685        assert_eq!(
686            verify(&ring, b"body", &headers, NOW),
687            Err(VerifyError::UnsupportedAlg {
688                alg: "hmac-sha256".into()
689            })
690        );
691    }
692
693    #[test]
694    fn an_absent_alg_header_means_the_original_shape() {
695        // Forward compatibility in the other direction: a signer that predates
696        // the alg header must still verify.
697        let (sk, vk) = keypair(1);
698        let ring = ring_with("backend-1", vk);
699        let mut headers = sign(&sk, "backend-1", b"body", NOW);
700        headers.alg = None;
701        assert!(verify(&ring, b"body", &headers, NOW).is_ok());
702    }
703
704    #[test]
705    fn the_signing_time_is_covered_by_the_signature() {
706        // The asymmetry with `kid`: rewriting the timestamp must not verify,
707        // or a captured message could be replayed by simply making it look
708        // fresh. This is the whole reason `at` sits inside the signed
709        // material.
710        let (sk, vk) = keypair(1);
711        let ring = ring_with("backend-1", vk);
712        let mut headers = sign(&sk, "backend-1", b"body", NOW);
713        headers.at_ms = Some((NOW + 1).to_string());
714        assert_eq!(
715            verify(&ring, b"body", &headers, NOW),
716            Err(VerifyError::BadSignature {
717                kid: "backend-1".into()
718            })
719        );
720    }
721
722    #[test]
723    fn the_ordinary_signer_accepts_a_week_old_command() {
724        // Not a nicety: the agent's JetStream replay redelivers the retained
725        // command per subject for 7 days, so an agent reconnecting after a
726        // week legitimately receives week-old commands. A freshness bound on
727        // the backend key would reject exactly that traffic.
728        let (sk, vk) = keypair(1);
729        let ring = ring_with("backend-1", vk);
730        let week = 7 * 24 * 60 * 60 * 1000;
731        let headers = sign(&sk, "backend-1", b"scheduled job", NOW - week);
732        assert!(verify(&ring, b"scheduled job", &headers, NOW).is_ok());
733    }
734
735    #[test]
736    fn a_bounded_key_rejects_an_old_signature() {
737        let (sk, vk) = keypair(3);
738        let mut ring = KeyRing::new();
739        ring.insert(
740            "break-glass",
741            vk,
742            KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
743        );
744
745        // Inside the window.
746        let fresh = sign(&sk, "break-glass", b"emergency", NOW - 60_000);
747        assert!(verify(&ring, b"emergency", &fresh, NOW).is_ok());
748
749        // Past it. This is the case that stops a first-boot agent executing a
750        // week-old emergency command: its dedup cache is empty, so freshness
751        // is the only thing left.
752        let old = sign(&sk, "break-glass", b"emergency", NOW - 600_000);
753        assert_eq!(
754            verify(&ring, b"emergency", &old, NOW),
755            Err(VerifyError::Stale {
756                kid: "break-glass".into(),
757                age_ms: 600_000,
758                max_age_ms: 300_000,
759            })
760        );
761    }
762
763    #[test]
764    fn a_signature_from_the_future_is_not_fresh() {
765        // A clock that disagrees, or a signer trying to mint something that
766        // outlives its bound. Bounding only the old side would let a
767        // far-future timestamp stay valid indefinitely.
768        let (sk, vk) = keypair(3);
769        let mut ring = KeyRing::new();
770        ring.insert(
771            "break-glass",
772            vk,
773            KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
774        );
775        let ahead = sign(&sk, "break-glass", b"emergency", NOW + 3_600_000);
776        assert!(matches!(
777            verify(&ring, b"emergency", &ahead, NOW),
778            Err(VerifyError::Stale { .. })
779        ));
780        // Modest skew inside the window still passes, so a machine a few
781        // seconds ahead is not locked out.
782        let skewed = sign(&sk, "break-glass", b"emergency", NOW + 5_000);
783        assert!(verify(&ring, b"emergency", &skewed, NOW).is_ok());
784    }
785
786    #[test]
787    fn freshness_is_only_judged_after_the_signature_holds() {
788        // Otherwise anyone could provoke a `Stale` report — which at stage 3
789        // is a rejection an operator has to investigate — by sending garbage
790        // with an old timestamp.
791        let (_, vk) = keypair(3);
792        let mut ring = KeyRing::new();
793        ring.insert(
794            "break-glass",
795            vk,
796            KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
797        );
798        let forged = SigHeaders {
799            sig_b64: Some(base64_encode(&[7u8; 64])),
800            kid: Some("break-glass".into()),
801            alg: Some(ALG_ED25519.into()),
802            at_ms: Some((NOW - 600_000).to_string()),
803        };
804        assert_eq!(
805            verify(&ring, b"x", &forged, NOW),
806            Err(VerifyError::BadSignature {
807                kid: "break-glass".into()
808            })
809        );
810    }
811
812    #[test]
813    fn a_missing_or_unparseable_signing_time_is_malformed() {
814        let (sk, vk) = keypair(1);
815        let ring = ring_with("backend-1", vk);
816
817        let mut no_at = sign(&sk, "backend-1", b"body", NOW);
818        no_at.at_ms = None;
819        assert!(matches!(
820            verify(&ring, b"body", &no_at, NOW),
821            Err(VerifyError::Malformed(_))
822        ));
823
824        let mut junk_at = sign(&sk, "backend-1", b"body", NOW);
825        junk_at.at_ms = Some("yesterday".into());
826        assert!(matches!(
827            verify(&ring, b"body", &junk_at, NOW),
828            Err(VerifyError::Malformed(_))
829        ));
830
831        // And a lone `at` header still counts as a signing claim rather than
832        // an absent one.
833        let at_only = SigHeaders {
834            sig_b64: None,
835            kid: None,
836            alg: None,
837            at_ms: Some(NOW.to_string()),
838        };
839        assert!(matches!(
840            verify(&ring, b"body", &at_only, NOW),
841            Err(VerifyError::Malformed(_))
842        ));
843    }
844
845    #[test]
846    fn an_absurd_bound_neither_panics_nor_inverts() {
847        // `max_age_secs` reaches this from JSON with no upper bound. A value
848        // this large is a typo rather than an attack, but the old `as i64`
849        // cast would have truncated it into a negative limit that nothing can
850        // satisfy — turning "almost never expires" into "always expired" —
851        // and could panic on the negation in a debug build.
852        let (sk, vk) = keypair(5);
853        let mut ring = KeyRing::new();
854        ring.insert(
855            "silly",
856            vk,
857            KeyPolicy::break_glass("silly", Duration::from_secs(u64::MAX / 1000)),
858        );
859        let headers = sign(&sk, "silly", b"body", NOW - 10_000);
860        assert!(
861            verify(&ring, b"body", &headers, NOW).is_ok(),
862            "an enormous bound must read as permissive, not as inverted"
863        );
864    }
865
866    #[test]
867    fn a_generated_key_round_trips_through_its_encoded_secret() {
868        // The registry stores the encoded secret; if this did not round trip,
869        // a backend would come back from a restart unable to sign and every
870        // agent would start reporting unsigned traffic.
871        let key = generate_keypair().expect("OS randomness");
872        let restored = decode_secret(&encode_secret(&key)).expect("decodes");
873        assert_eq!(restored.to_bytes(), key.to_bytes());
874
875        // And the restored key produces signatures the original's public half
876        // accepts — the property that actually matters across a restart.
877        let ring = ring_with("backend-1", key.verifying_key());
878        let headers = sign(&restored, "backend-1", b"after a restart", NOW);
879        assert!(verify(&ring, b"after a restart", &headers, NOW).is_ok());
880    }
881
882    #[test]
883    fn two_generated_keys_differ() {
884        // Cheap guard against a seeding mistake that returns a constant —
885        // which would make every backend in existence share one key.
886        let a = generate_keypair().unwrap();
887        let b = generate_keypair().unwrap();
888        assert_ne!(a.to_bytes(), b.to_bytes());
889    }
890
891    #[test]
892    fn a_malformed_secret_is_rejected_with_its_length() {
893        assert!(decode_secret("not base64!!").is_err());
894        let short = base64_encode(&[0u8; 31]);
895        let err = decode_secret(&short).unwrap_err();
896        assert!(
897            err.contains("31"),
898            "the error should name the length: {err}"
899        );
900    }
901
902    #[test]
903    fn the_emitted_keyring_entry_is_what_the_agent_parses() {
904        // The generator prints this for an operator to distribute. It has to
905        // match `kanade-agent`'s `parse_keyring` shape exactly — a hand-fixed
906        // entry that fails to parse takes the entire ring with it, and at
907        // stage 3 an empty ring rejects every command on that machine.
908        let key = generate_keypair().unwrap();
909        let entry = keyring_entry("backend-20260728", &key.verifying_key(), "backend");
910        assert_eq!(entry["kid"], "backend-20260728");
911        assert_eq!(entry["label"], "backend");
912        let pk = entry["public_key"]
913            .as_str()
914            .expect("public_key is a string");
915        assert_eq!(pk, encode_public(&key.verifying_key()));
916        // 32 bytes base64-encodes to 44 chars with padding.
917        assert_eq!(pk.len(), 44);
918    }
919}