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 /// Every entry as `kid:fingerprint`, which is what the heartbeat reports.
190 ///
191 /// The `kid` alone cannot answer "do two machines holding the same id hold
192 /// the same key" (#1229). A ring entry with the right id and wrong bytes
193 /// refuses every command once enforcement is on, and does not self-heal —
194 /// the reload-on-unknown-key path never fires, because the key *is*
195 /// present, just wrong. Carrying the fingerprint makes that state visible
196 /// from the fleet view instead of only on the host.
197 pub fn kid_fingerprints(&self) -> impl Iterator<Item = String> {
198 self.keys
199 .iter()
200 .map(|(kid, (key, _))| format!("{kid}:{}", fingerprint(key)))
201 }
202}
203
204/// A short, stable identifier for a **public** key: the first 8 bytes of
205/// SHA-256 over its raw 32 bytes, lower-case hex.
206///
207/// Truncated deliberately. This is not a security boundary — the ring itself
208/// is the trust root, and a fingerprint that matched by luck would still have
209/// to be a valid Ed25519 key someone had provisioned. It exists to be read,
210/// compared, and pasted by an operator, and to survive a `LIKE` against the
211/// projected JSON array, so 16 characters beats 64.
212pub fn fingerprint(key: &VerifyingKey) -> String {
213 use sha2::{Digest, Sha256};
214
215 let digest = Sha256::digest(key.as_bytes());
216 digest[..8].iter().fold(String::new(), |mut s, b| {
217 use std::fmt::Write;
218 let _ = write!(s, "{b:02x}");
219 s
220 })
221}
222
223/// Why a message was not accepted as backend-authored.
224///
225/// Deliberately distinguishes "carries no signature" from every other case.
226/// During stages 1–2 of the rollout `Unsigned` is expected and benign, while
227/// the rest mean something is wrong — conflating them would hide a real
228/// failure inside a normal one for the whole migration window.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum VerifyError {
231 /// No signature headers at all. Normal until enforcement is switched on.
232 Unsigned,
233 /// Signed, but the `kid` is not on this agent's ring.
234 ///
235 /// The operationally dangerous case: an agent that never received a new
236 /// key looks, from the operator's side, exactly like a backend that is not
237 /// sending commands — and a mid-rotation fleet is full of agents in that
238 /// state. Callers must surface this, not just log it locally.
239 UnknownKid { kid: String },
240 /// Signed with an algorithm this build does not implement.
241 UnsupportedAlg { alg: String },
242 /// Signature header present but not decodable as a signature.
243 Malformed(String),
244 /// Cryptographically invalid for the bytes received.
245 BadSignature { kid: String },
246 /// Valid, by a key whose policy bounds how old a signature may be, and
247 /// this one is older. Distinct from [`VerifyError::BadSignature`] because
248 /// the signature is genuine — what failed is the policy, and the two want
249 /// different responses.
250 Stale {
251 kid: String,
252 age_ms: i64,
253 max_age_ms: u128,
254 },
255}
256
257impl std::fmt::Display for VerifyError {
258 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 match self {
260 VerifyError::Unsigned => write!(f, "no signature"),
261 VerifyError::UnknownKid { kid } => {
262 write!(
263 f,
264 "signed by unknown key id {kid} — is this agent's keyring current?"
265 )
266 }
267 VerifyError::UnsupportedAlg { alg } => {
268 write!(f, "unsupported signature algorithm {alg}")
269 }
270 VerifyError::Malformed(e) => write!(f, "malformed signature: {e}"),
271 VerifyError::BadSignature { kid } => {
272 write!(f, "signature by {kid} does not match these bytes")
273 }
274 VerifyError::Stale {
275 kid,
276 age_ms,
277 max_age_ms,
278 } => write!(
279 f,
280 "signature by {kid} is {age_ms}ms old, past its {max_age_ms}ms bound"
281 ),
282 }
283 }
284}
285
286impl std::error::Error for VerifyError {}
287
288/// A verified message: which key vouched for it, and under what policy.
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct Verified<'a> {
291 pub kid: &'a str,
292 pub policy: &'a KeyPolicy,
293}
294
295/// The signature headers carried alongside a message body.
296///
297/// Extracted from the transport by the caller so this module stays free of a
298/// NATS dependency and is testable without a broker.
299#[derive(Debug, Clone, Default, PartialEq, Eq)]
300pub struct SigHeaders {
301 pub sig_b64: Option<String>,
302 pub kid: Option<String>,
303 pub alg: Option<String>,
304 /// Decimal milliseconds since the Unix epoch, as sent. Parsed rather than
305 /// trusted — it is covered by the signature, so a value that does not
306 /// reconstruct the signed material simply fails verification.
307 pub at_ms: Option<String>,
308}
309
310impl SigHeaders {
311 /// True only when the message makes **no signing claim at all**.
312 ///
313 /// Any one of these headers means the sender is asserting the message is
314 /// signed, so a partial set is a broken claim rather than an absent one.
315 /// `alg` counts for the same reason `kid` does: the rule has to be "no
316 /// signing headers at all", not "none of the two I happened to think of",
317 /// or stripping whichever header is unchecked reclassifies a malformed
318 /// message as an unsigned one — and unsigned is the accepted case for the
319 /// whole of stages 1-2.
320 pub fn is_absent(&self) -> bool {
321 self.sig_b64.is_none() && self.kid.is_none() && self.alg.is_none() && self.at_ms.is_none()
322 }
323}
324
325/// A message whose signature holds, before any freshness policy is applied.
326///
327/// The cryptographic half of [`verify`], exposed so a caller with a different
328/// time policy (the command envelope, which bounds the future side by a clock
329/// allowance rather than by the key's `max_age`) can apply its own without
330/// re-implementing key lookup and signature checking.
331#[derive(Debug, Clone, PartialEq, Eq)]
332pub struct Authentic<'a> {
333 pub kid: &'a str,
334 pub policy: &'a KeyPolicy,
335 /// The signing time the signature covers, milliseconds since the epoch.
336 pub at_ms: i64,
337}
338
339/// Check only that `body` was signed by a key on `ring`, over the exact
340/// received bytes. No freshness policy is applied here.
341pub fn verify_signature<'a>(
342 ring: &'a KeyRing,
343 body: &[u8],
344 headers: &SigHeaders,
345) -> Result<Authentic<'a>, VerifyError> {
346 if headers.is_absent() {
347 return Err(VerifyError::Unsigned);
348 }
349 // A signature with no kid cannot be attributed, so it is not "unsigned" —
350 // it is a signed message we cannot route to a key.
351 let kid = headers
352 .kid
353 .as_deref()
354 .ok_or_else(|| VerifyError::Malformed("signature without a key id".into()))?;
355 let sig_b64 = headers
356 .sig_b64
357 .as_deref()
358 .ok_or_else(|| VerifyError::Malformed("key id without a signature".into()))?;
359 let at_raw = headers
360 .at_ms
361 .as_deref()
362 .ok_or_else(|| VerifyError::Malformed("signature without a signing time".into()))?;
363 let at_ms: i64 = at_raw
364 .parse()
365 .map_err(|_| VerifyError::Malformed(format!("signing time {at_raw} is not a number")))?;
366
367 // Absent alg means the original single-algorithm shape; anything else must
368 // match exactly rather than being guessed at.
369 if let Some(alg) = headers.alg.as_deref()
370 && alg != ALG_ED25519
371 {
372 return Err(VerifyError::UnsupportedAlg {
373 alg: alg.to_owned(),
374 });
375 }
376
377 // The kid is a key-selection hint and is NOT covered by the signature.
378 // That is safe: rewriting it can only select a key under which
379 // verification fails, never make an invalid signature pass. The signing
380 // time is the opposite case and IS covered — see `signed_material`.
381 let (ring_kid, key, policy) = ring.get(kid).ok_or_else(|| VerifyError::UnknownKid {
382 kid: kid.to_owned(),
383 })?;
384
385 let raw = base64_decode(sig_b64).map_err(VerifyError::Malformed)?;
386 let sig = Signature::from_slice(&raw).map_err(|e| VerifyError::Malformed(e.to_string()))?;
387 key.verify(&signed_material(at_ms, body), &sig)
388 .map_err(|_| VerifyError::BadSignature {
389 kid: kid.to_owned(),
390 })?;
391
392 Ok(Authentic {
393 kid: ring_kid,
394 policy,
395 at_ms,
396 })
397}
398
399/// Verify `body` against the signature headers using `ring`.
400///
401/// Verification is over the **exact received bytes**; deserialization happens
402/// afterwards, at the caller. That ordering is what removes canonicalisation
403/// from the problem — there is no need for serde to produce byte-identical
404/// output on both sides, only for the bytes to arrive unchanged.
405pub fn verify<'a>(
406 ring: &'a KeyRing,
407 body: &[u8],
408 headers: &SigHeaders,
409 now_ms: i64,
410) -> Result<Verified<'a>, VerifyError> {
411 let Authentic { kid, policy, at_ms } = verify_signature(ring, body, headers)?;
412
413 // Freshness is checked only AFTER the signature holds, so a stale verdict
414 // is always about a genuine message. Checking it first would let anyone
415 // provoke a `Stale` report by sending garbage with an old timestamp.
416 if let Some(max_age) = policy.max_age {
417 let age_ms = now_ms - at_ms;
418 let max_age_ms = max_age.as_millis();
419 // Compared in i128 rather than casting the bound down to i64. A
420 // `max_age_secs` large enough to overflow the cast is nonsense config
421 // rather than an attack (the registry holding it is ACL'd), but the
422 // truncation would silently turn a bound into its opposite — a
423 // negative limit that nothing can satisfy, or one so large the key is
424 // effectively unbounded — and `-(x as i64)` can panic outright on
425 // i64::MIN in a debug build. i128 holds every value `Duration::as_millis`
426 // can produce, so there is nothing left to get wrong.
427 let age = age_ms as i128;
428 let bound = max_age_ms as i128;
429 // A signature from the future is not "fresh" — it is a clock that
430 // disagrees, and treating it as valid would let a skewed or hostile
431 // signer mint credentials that outlive the bound. Bound both ends.
432 if age > bound || age < -bound {
433 return Err(VerifyError::Stale {
434 kid: kid.to_owned(),
435 age_ms,
436 max_age_ms,
437 });
438 }
439 }
440
441 Ok(Verified { kid, policy })
442}
443
444/// Sign `body`, producing the headers to publish alongside it.
445///
446/// Lives here rather than in the backend so the signing and verifying halves
447/// cannot drift — a round-trip test in this module covers both at once.
448pub fn sign(key: &SigningKey, kid: &str, body: &[u8], at_ms: i64) -> SigHeaders {
449 let sig = key.sign(&signed_material(at_ms, body));
450 SigHeaders {
451 sig_b64: Some(base64_encode(&sig.to_bytes())),
452 kid: Some(kid.to_owned()),
453 alg: Some(ALG_ED25519.to_owned()),
454 at_ms: Some(at_ms.to_string()),
455 }
456}
457
458/// The signing half, bound to the id agents know it by.
459///
460/// The key and its `kid` are only meaningful together, so nothing here hands
461/// out one without the other. Signing under an id whose public half agents
462/// hold for a *different* key produces `command_signature_invalid` on every
463/// machine at once — which reads as a fleet-wide forgery, not as the
464/// misconfiguration it is. Any API that lets the two be supplied separately is
465/// a way to reach that state, and `resolve_kid` on the generating side already
466/// refuses the other way in (two keys sharing one id).
467pub struct Signer {
468 key: SigningKey,
469 kid: String,
470}
471
472impl Signer {
473 pub fn new(key: SigningKey, kid: impl Into<String>) -> Self {
474 Self {
475 key,
476 kid: kid.into(),
477 }
478 }
479
480 /// Build from the encoded secret as it rests in the registry or the
481 /// environment, rejecting an empty `kid` rather than signing under one.
482 ///
483 /// An empty id is not a cosmetic problem: `Kanade-Sig-Kid: ""` matches no
484 /// keyring entry, so every agent that holds keys reports
485 /// `command_signature_unknown_key` — the signal that is supposed to mean
486 /// "this agent missed a rotation". Producing it from a backend-side typo
487 /// would train operators to ignore the one alarm the rotation procedure
488 /// depends on.
489 ///
490 /// (An agent holding *no* keys reports `command_signature_unprovisioned`
491 /// instead, since it cannot tell an empty id from any other it lacks. The
492 /// distinction is the agent's, in `command_verify`; the harm named above is
493 /// the one that lands on an already-provisioned fleet.)
494 pub fn from_secret(secret: &str, kid: &str) -> Result<Self, String> {
495 if kid.trim().is_empty() {
496 return Err("the signing key id is empty".to_string());
497 }
498 Ok(Self::new(decode_secret(secret)?, kid))
499 }
500
501 pub fn kid(&self) -> &str {
502 &self.kid
503 }
504
505 pub fn verifying_key(&self) -> VerifyingKey {
506 self.key.verifying_key()
507 }
508
509 /// Sign `body` as of `at_ms`, yielding the headers to publish with it.
510 pub fn headers(&self, body: &[u8], at_ms: i64) -> SigHeaders {
511 sign(&self.key, &self.kid, body, at_ms)
512 }
513}
514
515/// Which half of a `(secret, kid)` pair is missing.
516///
517/// Returned rather than a formatted message so each caller can name the store
518/// it looked in — the registry, an environment variable pair, a config file —
519/// while the rule itself lives in one place. Two callers writing their own
520/// version of "half is an error" is how they end up disagreeing about it.
521#[derive(Debug, Clone, Copy, PartialEq, Eq)]
522pub enum MissingHalf {
523 /// A key id with no key to go with it.
524 Key,
525 /// A key with no id. Cannot be signed with: `Kanade-Sig-Kid` has to name
526 /// something the fleet's keyring recognises, and guessing produces a
527 /// signature every agent reports as unattributable.
528 Kid,
529}
530
531/// Classify a possibly-half-configured `(secret, kid)` pair.
532///
533/// `Ok(None)` = nothing configured, which is a legitimate state everywhere
534/// this is used (a host that does not sign, an operator without a break-glass
535/// key to hand). `Err` = exactly one half present, which is never intentional
536/// and must not silently fall back to unsigned: signing under a mismatched or
537/// invented id produces `command_signature_invalid` fleet-wide, which is the
538/// forgery alarm raised by a configuration mistake.
539pub fn pair<'a>(
540 secret: Option<&'a str>,
541 kid: Option<&'a str>,
542) -> Result<Option<(&'a str, &'a str)>, MissingHalf> {
543 match (secret, kid) {
544 (Some(s), Some(k)) => Ok(Some((s, k))),
545 (Some(_), None) => Err(MissingHalf::Kid),
546 (None, Some(_)) => Err(MissingHalf::Key),
547 (None, None) => Ok(None),
548 }
549}
550
551/// The JSON object an agent's `CommandKeys` array holds for a **break-glass**
552/// key.
553///
554/// Differs from [`keyring_entry`] by carrying `max_age_secs`, which is what
555/// makes the agent treat it as break-glass at all: `parse_keyring` maps a
556/// present `max_age_secs` to [`KeyPolicy::break_glass`], and that constructor
557/// is what sets `audit_every_use`.
558///
559/// Deliberately does **not** emit `audit_every_use`. The agent ignores that
560/// field whenever `max_age_secs` is present — auditing is not optional for a
561/// break-glass key — so printing it would put a value in the operator's file
562/// that looks adjustable and is not. An entry that lies about what it controls
563/// is worse than one that omits it.
564pub fn break_glass_keyring_entry(
565 kid: &str,
566 key: &VerifyingKey,
567 label: &str,
568 max_age: Duration,
569) -> serde_json::Value {
570 serde_json::json!({
571 "kid": kid,
572 "public_key": encode_public(key),
573 "label": label,
574 "max_age_secs": max_age.as_secs(),
575 })
576}
577
578/// Hand-written so the private key cannot reach a log line.
579///
580/// `SigningKey` derives `Debug` and prints its bytes, so a derived impl here
581/// would put the fleet's crown jewel into any `tracing` call that formats the
582/// struct — including the ones nobody writes deliberately, like a `#[derive(
583/// Debug)]` on an enclosing type.
584impl std::fmt::Debug for Signer {
585 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
586 f.debug_struct("Signer").field("kid", &self.kid).finish()
587 }
588}
589
590/// Registry subkey + value holding the backend's **private** signing key.
591///
592/// Lives beside `StaticToken` / `JwtSecret` in the key `deploy-backend.ps1`
593/// already hardens to SYSTEM + Administrators. Registry ACLs are per-key, not
594/// per-value, so a value written into that key inherits the protection — which
595/// is why the writer must refuse to *create* the key: creating it would
596/// produce an unhardened one and leave the signing key world-readable.
597pub const REG_BACKEND_SUBKEY: &str = r"SOFTWARE\kanade\backend";
598pub const REG_SIGNING_KEY: &str = "CommandSigningKey";
599/// Registry value holding the `kid` that names the key beside it.
600///
601/// Persisted rather than re-derived. The signing path needs it to fill
602/// `Kanade-Sig-Kid`, and it is the operator's choice — re-deriving a date
603/// stamp at signing time would produce a different id than the one already
604/// distributed to agents whenever a key is generated on one day and first used
605/// on another. An id that disagrees with the fleet's keyring is
606/// indistinguishable, from the agent's side, from an unknown signer.
607pub const REG_SIGNING_KID: &str = "CommandSigningKid";
608
609/// Mint a fresh signing keypair.
610pub fn generate_keypair() -> Result<SigningKey, String> {
611 // Seeded from the OS CSPRNG directly rather than through
612 // `SigningKey::generate`, which wants a `rand_core` RNG — and this
613 // workspace carries three incompatible `rand_core` majors transitively.
614 // Filling 32 bytes sidesteps the version pairing entirely, and the seed
615 // *is* the key, so nothing is lost.
616 let mut seed = [0u8; 32];
617 getrandom::fill(&mut seed).map_err(|e| format!("OS randomness unavailable: {e}"))?;
618 Ok(SigningKey::from_bytes(&seed))
619}
620
621/// Base64 of the 32-byte seed. This is the secret; it is written to the
622/// registry and never printed by any code path that logs.
623pub fn encode_secret(key: &SigningKey) -> String {
624 base64_encode(&key.to_bytes())
625}
626
627pub fn decode_secret(raw: &str) -> Result<SigningKey, String> {
628 let bytes = base64_decode(raw)?;
629 let arr: [u8; 32] = bytes
630 .as_slice()
631 .try_into()
632 .map_err(|_| format!("signing key must be 32 bytes, got {}", bytes.len()))?;
633 Ok(SigningKey::from_bytes(&arr))
634}
635
636pub fn encode_public(key: &VerifyingKey) -> String {
637 base64_encode(key.as_bytes())
638}
639
640/// The JSON object an agent's `CommandKeys` array holds for this key.
641///
642/// Emitted by the generator so the operator distributes a value that is
643/// correct by construction rather than assembling it by hand — the shape is
644/// parsed by `kanade-agent`'s `parse_keyring`, and a hand-built entry that
645/// fails to parse takes the whole ring with it.
646pub fn keyring_entry(kid: &str, key: &VerifyingKey, label: &str) -> serde_json::Value {
647 serde_json::json!({
648 "kid": kid,
649 "public_key": encode_public(key),
650 "label": label,
651 })
652}
653
654fn base64_encode(bytes: &[u8]) -> String {
655 use base64::Engine;
656 base64::engine::general_purpose::STANDARD.encode(bytes)
657}
658
659fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
660 use base64::Engine;
661 base64::engine::general_purpose::STANDARD
662 .decode(s)
663 .map_err(|e| e.to_string())
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669
670 /// A fixed "now" so the tests never depend on wall-clock time.
671 const NOW: i64 = 1_700_000_000_000;
672
673 fn keypair(seed: u8) -> (SigningKey, VerifyingKey) {
674 let sk = SigningKey::from_bytes(&[seed; 32]);
675 let vk = sk.verifying_key();
676 (sk, vk)
677 }
678
679 fn ring_with(kid: &str, vk: VerifyingKey) -> KeyRing {
680 let mut r = KeyRing::new();
681 r.insert(kid, vk, KeyPolicy::backend("backend"));
682 r
683 }
684
685 #[test]
686 fn a_fingerprint_is_a_fixed_width_lower_hex_string() {
687 let (_, vk) = keypair(1);
688 let fp = fingerprint(&vk);
689 assert_eq!(fp.len(), 16, "8 bytes, hex: {fp}");
690 assert!(
691 fp.chars()
692 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
693 "must be pasteable and LIKE-able without escaping: {fp}"
694 );
695 }
696
697 #[test]
698 fn a_fingerprint_is_stable_across_calls_and_distinct_across_keys() {
699 // Stability is the whole point — a value that varied per call would
700 // make every host look like the odd one out. Distinctness is what makes
701 // a same-kid-different-key ring visible (#1229).
702 let (_, a) = keypair(2);
703 let (_, b) = keypair(3);
704 assert_eq!(fingerprint(&a), fingerprint(&a));
705 assert_ne!(fingerprint(&a), fingerprint(&b));
706 }
707
708 #[test]
709 fn a_fingerprint_is_pinned_to_the_public_key_bytes() {
710 // Pinned against a hand-computed value so a change of hash, of
711 // truncation length, or of *what* is hashed (the raw 32 bytes, not the
712 // base64 text) fails here rather than fleet-wide, where every host
713 // would silently disagree with every stored literal.
714 use sha2::{Digest, Sha256};
715
716 let (_, vk) = keypair(7);
717 let expect: String = Sha256::digest(vk.as_bytes())[..8]
718 .iter()
719 .map(|b| format!("{b:02x}"))
720 .collect();
721 assert_eq!(fingerprint(&vk), expect);
722 }
723
724 #[test]
725 fn a_ring_reports_each_entry_as_kid_then_fingerprint() {
726 let (_, a) = keypair(4);
727 let (_, b) = keypair(5);
728 let mut ring = ring_with("backend-1", a);
729 ring.insert("break-glass-1", b, KeyPolicy::backend("break-glass"));
730
731 let reported: Vec<String> = ring.kid_fingerprints().collect();
732 assert_eq!(
733 reported,
734 vec![
735 format!("backend-1:{}", fingerprint(&a)),
736 format!("break-glass-1:{}", fingerprint(&b)),
737 ],
738 "sorted by kid, so a heartbeat does not churn the projected value"
739 );
740 }
741
742 #[test]
743 fn round_trip_accepts_the_bytes_that_were_signed() {
744 let (sk, vk) = keypair(1);
745 let ring = ring_with("backend-1", vk);
746 let body = br#"{"id":"job","request_id":"r1"}"#;
747
748 let headers = sign(&sk, "backend-1", body, NOW);
749 let ok = verify(&ring, body, &headers, NOW).expect("verifies");
750 assert_eq!(ok.kid, "backend-1");
751 assert_eq!(ok.policy.label, "backend");
752 }
753
754 #[test]
755 fn a_single_flipped_byte_fails() {
756 let (sk, vk) = keypair(1);
757 let ring = ring_with("backend-1", vk);
758 let headers = sign(&sk, "backend-1", b"run this", NOW);
759 // The whole point: bytes the backend did not author must not execute.
760 assert_eq!(
761 verify(&ring, b"run thit", &headers, NOW),
762 Err(VerifyError::BadSignature {
763 kid: "backend-1".into()
764 })
765 );
766 }
767
768 #[test]
769 fn a_forged_command_from_another_key_fails() {
770 // The #1155 attacker: holds a NATS credential, can place bytes on a
771 // command subject, and signs with a key of their own.
772 let (_, backend_vk) = keypair(1);
773 let (attacker_sk, _) = keypair(9);
774 let ring = ring_with("backend-1", backend_vk);
775
776 let body = b"malicious";
777 // Signed with the attacker's key but claiming the backend's kid — the
778 // kid being outside the signature buys them nothing.
779 let headers = sign(&attacker_sk, "backend-1", body, NOW);
780 assert_eq!(
781 verify(&ring, body, &headers, NOW),
782 Err(VerifyError::BadSignature {
783 kid: "backend-1".into()
784 })
785 );
786 }
787
788 #[test]
789 fn unsigned_is_distinct_from_every_failure() {
790 let (_, vk) = keypair(1);
791 let ring = ring_with("backend-1", vk);
792 // Stages 1–2 deliver unsigned commands as a matter of course. If this
793 // collapsed into the same error as a bad signature, a real forgery
794 // would be indistinguishable from normal traffic for the whole
795 // migration window.
796 assert_eq!(
797 verify(&ring, b"anything", &SigHeaders::default(), NOW),
798 Err(VerifyError::Unsigned)
799 );
800 }
801
802 #[test]
803 fn an_unknown_kid_is_its_own_error() {
804 let (sk, vk) = keypair(1);
805 let ring = ring_with("backend-1", vk);
806 let headers = sign(&sk, "backend-2", b"body", NOW);
807 // The mid-rotation state: correctly signed, but this agent has not
808 // been given the new key. Reported distinctly because "your keyring is
809 // stale" and "someone forged this" need opposite responses.
810 assert_eq!(
811 verify(&ring, b"body", &headers, NOW),
812 Err(VerifyError::UnknownKid {
813 kid: "backend-2".into()
814 })
815 );
816 }
817
818 #[test]
819 fn rotation_is_two_kids_on_one_ring() {
820 // Rotation shares its implementation with multi-signer rather than
821 // being a separate mechanism — this is that claim, as a test.
822 let (old_sk, old_vk) = keypair(1);
823 let (new_sk, new_vk) = keypair(2);
824 let mut ring = ring_with("backend-1", old_vk);
825 ring.insert("backend-2", new_vk, KeyPolicy::backend("backend (new)"));
826
827 for (sk, kid) in [(&old_sk, "backend-1"), (&new_sk, "backend-2")] {
828 let headers = sign(sk, kid, b"during the window", NOW);
829 assert_eq!(
830 verify(&ring, b"during the window", &headers, NOW)
831 .unwrap()
832 .kid,
833 kid
834 );
835 }
836 }
837
838 #[test]
839 fn policies_are_per_key_not_per_ring() {
840 // Without this, "multiple signers" means "more keys that can do
841 // everything", which is worse than one key.
842 let (_, backend_vk) = keypair(1);
843 let (bg_sk, bg_vk) = keypair(3);
844 let mut ring = ring_with("backend-1", backend_vk);
845 ring.insert(
846 "break-glass",
847 bg_vk,
848 KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
849 );
850
851 let headers = sign(&bg_sk, "break-glass", b"emergency", NOW);
852 let ok = verify(&ring, b"emergency", &headers, NOW).unwrap();
853 assert!(
854 ok.policy.audit_every_use,
855 "break-glass use must be recorded"
856 );
857 assert_eq!(ok.policy.max_age, Some(Duration::from_secs(300)));
858
859 // The ordinary signer keeps the ordinary policy.
860 let (sk, vk) = keypair(1);
861 let mut r2 = KeyRing::new();
862 r2.insert("backend-1", vk, KeyPolicy::backend("backend"));
863 let h = sign(&sk, "backend-1", b"routine", NOW);
864 assert!(
865 !verify(&r2, b"routine", &h, NOW)
866 .unwrap()
867 .policy
868 .audit_every_use
869 );
870 }
871
872 #[test]
873 fn partial_or_unreadable_headers_are_rejected_rather_than_ignored() {
874 let (_, vk) = keypair(1);
875 let ring = ring_with("backend-1", vk);
876
877 // A signature with no kid cannot be attributed. Treating it as
878 // "unsigned" would let an attacker strip the kid to slip a message
879 // through the stage-1/2 accept-unsigned path.
880 let no_kid = SigHeaders {
881 sig_b64: Some("AAAA".into()),
882 kid: None,
883 alg: None,
884 at_ms: None,
885 };
886 assert!(matches!(
887 verify(&ring, b"x", &no_kid, NOW),
888 Err(VerifyError::Malformed(_))
889 ));
890
891 // A kid with no signature is equally not "unsigned".
892 let no_sig = SigHeaders {
893 sig_b64: None,
894 kid: Some("backend-1".into()),
895 alg: None,
896 at_ms: None,
897 };
898 assert!(matches!(
899 verify(&ring, b"x", &no_sig, NOW),
900 Err(VerifyError::Malformed(_))
901 ));
902
903 // Only the algorithm header, with nothing to verify. It still claims
904 // to be signed, so it must not fall through to the accept-unsigned
905 // path: the rule is "no signing headers at all", not "none of the two
906 // that carry the signature".
907 let alg_only = SigHeaders {
908 sig_b64: None,
909 kid: None,
910 alg: Some(ALG_ED25519.into()),
911 at_ms: None,
912 };
913 assert!(matches!(
914 verify(&ring, b"x", &alg_only, NOW),
915 Err(VerifyError::Malformed(_))
916 ));
917
918 // Not base64 at all.
919 let junk = SigHeaders {
920 sig_b64: Some("!!!not base64!!!".into()),
921 kid: Some("backend-1".into()),
922 alg: None,
923 at_ms: Some(NOW.to_string()),
924 };
925 assert!(matches!(
926 verify(&ring, b"x", &junk, NOW),
927 Err(VerifyError::Malformed(_))
928 ));
929 }
930
931 #[test]
932 fn an_unexpected_algorithm_is_refused_not_assumed() {
933 let (sk, vk) = keypair(1);
934 let ring = ring_with("backend-1", vk);
935 let mut headers = sign(&sk, "backend-1", b"body", NOW);
936 headers.alg = Some("hmac-sha256".into());
937 assert_eq!(
938 verify(&ring, b"body", &headers, NOW),
939 Err(VerifyError::UnsupportedAlg {
940 alg: "hmac-sha256".into()
941 })
942 );
943 }
944
945 #[test]
946 fn an_absent_alg_header_means_the_original_shape() {
947 // Forward compatibility in the other direction: a signer that predates
948 // the alg header must still verify.
949 let (sk, vk) = keypair(1);
950 let ring = ring_with("backend-1", vk);
951 let mut headers = sign(&sk, "backend-1", b"body", NOW);
952 headers.alg = None;
953 assert!(verify(&ring, b"body", &headers, NOW).is_ok());
954 }
955
956 #[test]
957 fn the_signing_time_is_covered_by_the_signature() {
958 // The asymmetry with `kid`: rewriting the timestamp must not verify,
959 // or a captured message could be replayed by simply making it look
960 // fresh. This is the whole reason `at` sits inside the signed
961 // material.
962 let (sk, vk) = keypair(1);
963 let ring = ring_with("backend-1", vk);
964 let mut headers = sign(&sk, "backend-1", b"body", NOW);
965 headers.at_ms = Some((NOW + 1).to_string());
966 assert_eq!(
967 verify(&ring, b"body", &headers, NOW),
968 Err(VerifyError::BadSignature {
969 kid: "backend-1".into()
970 })
971 );
972 }
973
974 #[test]
975 fn the_ordinary_signer_accepts_a_week_old_command() {
976 // Not a nicety: the agent's JetStream replay redelivers the retained
977 // command per subject for 7 days, so an agent reconnecting after a
978 // week legitimately receives week-old commands. A freshness bound on
979 // the backend key would reject exactly that traffic.
980 let (sk, vk) = keypair(1);
981 let ring = ring_with("backend-1", vk);
982 let week = 7 * 24 * 60 * 60 * 1000;
983 let headers = sign(&sk, "backend-1", b"scheduled job", NOW - week);
984 assert!(verify(&ring, b"scheduled job", &headers, NOW).is_ok());
985 }
986
987 #[test]
988 fn a_bounded_key_rejects_an_old_signature() {
989 let (sk, vk) = keypair(3);
990 let mut ring = KeyRing::new();
991 ring.insert(
992 "break-glass",
993 vk,
994 KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
995 );
996
997 // Inside the window.
998 let fresh = sign(&sk, "break-glass", b"emergency", NOW - 60_000);
999 assert!(verify(&ring, b"emergency", &fresh, NOW).is_ok());
1000
1001 // Past it. This is the case that stops a first-boot agent executing a
1002 // week-old emergency command: its dedup cache is empty, so freshness
1003 // is the only thing left.
1004 let old = sign(&sk, "break-glass", b"emergency", NOW - 600_000);
1005 assert_eq!(
1006 verify(&ring, b"emergency", &old, NOW),
1007 Err(VerifyError::Stale {
1008 kid: "break-glass".into(),
1009 age_ms: 600_000,
1010 max_age_ms: 300_000,
1011 })
1012 );
1013 }
1014
1015 #[test]
1016 fn a_signature_from_the_future_is_not_fresh() {
1017 // A clock that disagrees, or a signer trying to mint something that
1018 // outlives its bound. Bounding only the old side would let a
1019 // far-future timestamp stay valid indefinitely.
1020 let (sk, vk) = keypair(3);
1021 let mut ring = KeyRing::new();
1022 ring.insert(
1023 "break-glass",
1024 vk,
1025 KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
1026 );
1027 let ahead = sign(&sk, "break-glass", b"emergency", NOW + 3_600_000);
1028 assert!(matches!(
1029 verify(&ring, b"emergency", &ahead, NOW),
1030 Err(VerifyError::Stale { .. })
1031 ));
1032 // Modest skew inside the window still passes, so a machine a few
1033 // seconds ahead is not locked out.
1034 let skewed = sign(&sk, "break-glass", b"emergency", NOW + 5_000);
1035 assert!(verify(&ring, b"emergency", &skewed, NOW).is_ok());
1036 }
1037
1038 #[test]
1039 fn freshness_is_only_judged_after_the_signature_holds() {
1040 // Otherwise anyone could provoke a `Stale` report — which at stage 3
1041 // is a rejection an operator has to investigate — by sending garbage
1042 // with an old timestamp.
1043 let (_, vk) = keypair(3);
1044 let mut ring = KeyRing::new();
1045 ring.insert(
1046 "break-glass",
1047 vk,
1048 KeyPolicy::break_glass("break-glass", Duration::from_secs(300)),
1049 );
1050 let forged = SigHeaders {
1051 sig_b64: Some(base64_encode(&[7u8; 64])),
1052 kid: Some("break-glass".into()),
1053 alg: Some(ALG_ED25519.into()),
1054 at_ms: Some((NOW - 600_000).to_string()),
1055 };
1056 assert_eq!(
1057 verify(&ring, b"x", &forged, NOW),
1058 Err(VerifyError::BadSignature {
1059 kid: "break-glass".into()
1060 })
1061 );
1062 }
1063
1064 #[test]
1065 fn a_missing_or_unparseable_signing_time_is_malformed() {
1066 let (sk, vk) = keypair(1);
1067 let ring = ring_with("backend-1", vk);
1068
1069 let mut no_at = sign(&sk, "backend-1", b"body", NOW);
1070 no_at.at_ms = None;
1071 assert!(matches!(
1072 verify(&ring, b"body", &no_at, NOW),
1073 Err(VerifyError::Malformed(_))
1074 ));
1075
1076 let mut junk_at = sign(&sk, "backend-1", b"body", NOW);
1077 junk_at.at_ms = Some("yesterday".into());
1078 assert!(matches!(
1079 verify(&ring, b"body", &junk_at, NOW),
1080 Err(VerifyError::Malformed(_))
1081 ));
1082
1083 // And a lone `at` header still counts as a signing claim rather than
1084 // an absent one.
1085 let at_only = SigHeaders {
1086 sig_b64: None,
1087 kid: None,
1088 alg: None,
1089 at_ms: Some(NOW.to_string()),
1090 };
1091 assert!(matches!(
1092 verify(&ring, b"body", &at_only, NOW),
1093 Err(VerifyError::Malformed(_))
1094 ));
1095 }
1096
1097 #[test]
1098 fn an_absurd_bound_neither_panics_nor_inverts() {
1099 // `max_age_secs` reaches this from JSON with no upper bound. A value
1100 // this large is a typo rather than an attack, but the old `as i64`
1101 // cast would have truncated it into a negative limit that nothing can
1102 // satisfy — turning "almost never expires" into "always expired" —
1103 // and could panic on the negation in a debug build.
1104 let (sk, vk) = keypair(5);
1105 let mut ring = KeyRing::new();
1106 ring.insert(
1107 "silly",
1108 vk,
1109 KeyPolicy::break_glass("silly", Duration::from_secs(u64::MAX / 1000)),
1110 );
1111 let headers = sign(&sk, "silly", b"body", NOW - 10_000);
1112 assert!(
1113 verify(&ring, b"body", &headers, NOW).is_ok(),
1114 "an enormous bound must read as permissive, not as inverted"
1115 );
1116 }
1117
1118 #[test]
1119 fn a_generated_key_round_trips_through_its_encoded_secret() {
1120 // The registry stores the encoded secret; if this did not round trip,
1121 // a backend would come back from a restart unable to sign and every
1122 // agent would start reporting unsigned traffic.
1123 let key = generate_keypair().expect("OS randomness");
1124 let restored = decode_secret(&encode_secret(&key)).expect("decodes");
1125 assert_eq!(restored.to_bytes(), key.to_bytes());
1126
1127 // And the restored key produces signatures the original's public half
1128 // accepts — the property that actually matters across a restart.
1129 let ring = ring_with("backend-1", key.verifying_key());
1130 let headers = sign(&restored, "backend-1", b"after a restart", NOW);
1131 assert!(verify(&ring, b"after a restart", &headers, NOW).is_ok());
1132 }
1133
1134 #[test]
1135 fn a_signer_built_from_the_stored_secret_verifies_against_its_own_ring() {
1136 // The whole backend-side path in one assertion: the encoded secret as
1137 // it rests in the registry, through `Signer`, out as headers, verified
1138 // by the public half an agent was given.
1139 let key = generate_keypair().unwrap();
1140 let signer = Signer::from_secret(&encode_secret(&key), "backend-1").expect("builds");
1141 let ring = ring_with("backend-1", key.verifying_key());
1142
1143 let body = br#"{"id":"job","request_id":"r1"}"#;
1144 let headers = signer.headers(body, NOW);
1145 assert_eq!(verify(&ring, body, &headers, NOW).unwrap().kid, "backend-1");
1146 }
1147
1148 #[test]
1149 fn a_signer_refuses_an_empty_kid_rather_than_signing_under_one() {
1150 // `Kanade-Sig-Kid: ""` matches no keyring entry, so every agent that
1151 // holds keys would report `unknown_key` — the alarm that is supposed to
1152 // mean a rotation went wrong. A backend typo must not be able to raise
1153 // it fleet-wide. (A machine holding none reports `unprovisioned`; the
1154 // fleet this harms is the already-provisioned one.)
1155 let secret = encode_secret(&generate_keypair().unwrap());
1156 assert!(Signer::from_secret(&secret, "").is_err());
1157 assert!(Signer::from_secret(&secret, " ").is_err());
1158 // And a bad secret is still rejected on its own terms.
1159 assert!(Signer::from_secret("not base64!!", "backend-1").is_err());
1160 }
1161
1162 #[test]
1163 fn debugging_a_signer_never_prints_the_key() {
1164 // `SigningKey` derives Debug and prints its bytes, so a derived impl
1165 // would leak the fleet's crown jewel into any log line that formats an
1166 // enclosing struct.
1167 let key = generate_keypair().unwrap();
1168 let signer = Signer::new(key.clone(), "backend-1");
1169 // Pinned exactly rather than by absence: a field added later would
1170 // otherwise have to be *remembered* to be excluded, and the failure
1171 // mode is a secret in a log file.
1172 assert_eq!(format!("{signer:?}"), r#"Signer { kid: "backend-1" }"#);
1173 assert!(!format!("{signer:?}").contains(&encode_secret(&key)));
1174 }
1175
1176 #[test]
1177 fn a_half_configured_pair_names_the_missing_half() {
1178 assert_eq!(
1179 pair(Some("secret"), Some("kid")),
1180 Ok(Some(("secret", "kid")))
1181 );
1182 assert_eq!(pair(None, None), Ok(None));
1183 // The two failures are distinguished because the fixes differ: one
1184 // needs a key generated, the other needs the id it was distributed
1185 // under.
1186 assert_eq!(pair(Some("secret"), None), Err(MissingHalf::Kid));
1187 assert_eq!(pair(None, Some("kid")), Err(MissingHalf::Key));
1188 }
1189
1190 #[test]
1191 fn a_break_glass_entry_carries_the_bound_and_not_the_audit_flag() {
1192 // `max_age_secs` is what makes the agent treat this as break-glass at
1193 // all (`parse_keyring` maps it to `KeyPolicy::break_glass`, which is
1194 // what turns auditing on). Emitting `audit_every_use` too would put a
1195 // field in the operator's file that the agent ignores — adjustable in
1196 // appearance, fixed in fact.
1197 let key = generate_keypair().unwrap();
1198 let entry = break_glass_keyring_entry(
1199 "break-glass-20260730",
1200 &key.verifying_key(),
1201 "break-glass",
1202 Duration::from_secs(900),
1203 );
1204 assert_eq!(entry["kid"], "break-glass-20260730");
1205 assert_eq!(entry["max_age_secs"], 900);
1206 assert_eq!(entry["public_key"], encode_public(&key.verifying_key()));
1207 assert!(
1208 entry.get("audit_every_use").is_none(),
1209 "must not print a field the agent overrides: {entry}"
1210 );
1211 }
1212
1213 #[test]
1214 fn two_generated_keys_differ() {
1215 // Cheap guard against a seeding mistake that returns a constant —
1216 // which would make every backend in existence share one key.
1217 let a = generate_keypair().unwrap();
1218 let b = generate_keypair().unwrap();
1219 assert_ne!(a.to_bytes(), b.to_bytes());
1220 }
1221
1222 #[test]
1223 fn a_malformed_secret_is_rejected_with_its_length() {
1224 assert!(decode_secret("not base64!!").is_err());
1225 let short = base64_encode(&[0u8; 31]);
1226 let err = decode_secret(&short).unwrap_err();
1227 assert!(
1228 err.contains("31"),
1229 "the error should name the length: {err}"
1230 );
1231 }
1232
1233 #[test]
1234 fn the_emitted_keyring_entry_is_what_the_agent_parses() {
1235 // The generator prints this for an operator to distribute. It has to
1236 // match `kanade-agent`'s `parse_keyring` shape exactly — a hand-fixed
1237 // entry that fails to parse takes the entire ring with it, and at
1238 // stage 3 an empty ring rejects every command on that machine.
1239 let key = generate_keypair().unwrap();
1240 let entry = keyring_entry("backend-20260728", &key.verifying_key(), "backend");
1241 assert_eq!(entry["kid"], "backend-20260728");
1242 assert_eq!(entry["label"], "backend");
1243 let pk = entry["public_key"]
1244 .as_str()
1245 .expect("public_key is a string");
1246 assert_eq!(pk, encode_public(&key.verifying_key()));
1247 // 32 bytes base64-encodes to 44 chars with padding.
1248 assert_eq!(pk.len(), 44);
1249 }
1250}