Skip to main content

kanade_shared/wire/
envelope.rs

1//! The versioned command envelope: a command that names its recipient and
2//! carries an absolute expiry **inside the signed bytes**.
3//!
4//! A legacy command is the bare serialized [`Command`], signed over its exact
5//! bytes. That signature is valid for any recipient and, for the backend key,
6//! forever — so a captured command can be replayed to another host, or much
7//! later. The envelope closes both holes by making the recipient and the
8//! deadline part of what the signature covers.
9//!
10//! It is a type distinct from [`Command`] on purpose. `Command` is also built
11//! locally (the scheduler, in-process callers) where a recipient and expiry
12//! mean nothing, and an agent that predates this type fails to parse the
13//! envelope body as a `Command` (required fields are missing) instead of
14//! running it without the recipient and expiry checks.
15//!
16//! # Clock policy
17//!
18//! Every check here compares against the **host's wall clock**, so the whole
19//! scheme assumes host time is trustworthy. Arbitrary clock rollback defeats
20//! any wall-clock freshness bound: a host whose clock is set back accepts an
21//! envelope that has really expired. That is inherent, not something this
22//! module can repair; it narrows the replay window rather than closing it for
23//! a host whose clock the attacker controls.
24//!
25//! The policy is deliberately conservative about *locking hosts out*, because
26//! a rejected envelope is a command that silently does not run:
27//!
28//! * the future side tolerates [`FUTURE_SKEW_ALLOWANCE`] of disagreement
29//!   between the signer's clock and the host's, and is otherwise unbounded —
30//!   ordinary freshness is bounded only by `expires_at`;
31//! * the validity window (`expires_at` minus signing time) may not exceed
32//!   [`MAX_ENVELOPE_VALIDITY`], enforced by the agent independently of
33//!   whatever the signer chose;
34//! * clock-related refusals are distinct [`EnvelopeError`] values, so an
35//!   operator can tell a skewed clock from tampering.
36//!
37//! A host whose clock runs more than the allowance *behind* the signer sees
38//! every envelope as future-dated and refuses all of them: clock offset must
39//! be observed before a host is switched to receive envelopes.
40
41use std::time::Duration;
42
43use chrono::{DateTime, Utc};
44use serde::{Deserialize, Serialize};
45
46use super::Command;
47use crate::signing::{self, KeyRing, SigHeaders, Signer, VerifyError};
48
49/// The `kind` discriminator of the v2 envelope.
50pub const ENVELOPE_KIND_V2: &str = "kanade.command.v2";
51
52/// Protocol name reported for the original, unwrapped signed `Command`.
53pub const PROTOCOL_LEGACY: &str = "legacy";
54/// Protocol name reported for the v2 envelope; equal to its `kind`.
55pub const PROTOCOL_V2: &str = ENVELOPE_KIND_V2;
56
57/// Longest validity window an agent accepts, measured from the signing time to
58/// `expires_at`.
59///
60/// Matches the seven-day retention of the command stream: nothing legitimately
61/// needs to be deliverable for longer, and the agent enforces it itself so a
62/// signer that picks a larger expiry cannot widen the replay window. A
63/// consequence worth knowing: an agent that was offline for more than this
64/// refuses the stale replay of a command it missed, which is intended.
65pub const MAX_ENVELOPE_VALIDITY: Duration = Duration::from_secs(7 * 24 * 60 * 60);
66
67/// How far in the future a signing time may be before the host calls it a clock
68/// disagreement. The same tolerance a break-glass key's window already needs.
69pub const FUTURE_SKEW_ALLOWANCE: Duration = Duration::from_secs(60 * 60);
70
71/// The command protocols this build can verify, as reported in the heartbeat.
72pub fn supported_command_protocols() -> Vec<String> {
73    vec![PROTOCOL_LEGACY.to_owned(), PROTOCOL_V2.to_owned()]
74}
75
76/// The network form of a v2 command.
77#[derive(Serialize, Deserialize, Debug, Clone)]
78#[serde(deny_unknown_fields)]
79pub struct CommandEnvelope {
80    pub kind: String,
81    /// The exact registered pc_id of the one host allowed to run this; case is
82    /// preserved and compared byte for byte.
83    pub target_pc_id: String,
84    /// Absolute UTC deadline for **starting** the command. Not an instruction
85    /// to kill a process that is already running.
86    pub expires_at: DateTime<Utc>,
87    pub command: Command,
88}
89
90/// Why an envelope was not accepted. Each variant is a distinct operational
91/// state; the clock ones are kept apart from signature failures on purpose.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum EnvelopeError {
94    /// The signature did not verify (or there was none).
95    Signature(VerifyError),
96    /// A `kind` this build does not recognise.
97    UnknownKind(String),
98    /// The body is not a well-formed envelope.
99    Malformed(String),
100    /// No `target_pc_id`.
101    MissingRecipient,
102    /// Addressed to a different host.
103    Misaddressed { target: String },
104    /// No `expires_at`.
105    MissingExpiry,
106    /// `expires_at` is not an RFC 3339 timestamp.
107    MalformedExpiry(String),
108    /// Signed further in the future than the allowance: the clocks disagree.
109    ClockAhead { ahead_ms: i128 },
110    /// `expires_at` is before the signing time.
111    Reversed,
112    /// The validity window exceeds [`MAX_ENVELOPE_VALIDITY`].
113    OverCeiling { validity_ms: i128 },
114    /// The effective start deadline has passed.
115    Expired { deadline: DateTime<Utc> },
116    /// The signing key's own freshness bound has passed.
117    KeyAgeExceeded {
118        kid: String,
119        age_ms: i128,
120        max_age_ms: i128,
121    },
122}
123
124impl EnvelopeError {
125    /// Whether the refusal is about time rather than about who or what.
126    pub fn is_clock(&self) -> bool {
127        matches!(
128            self,
129            EnvelopeError::ClockAhead { .. }
130                | EnvelopeError::Reversed
131                | EnvelopeError::OverCeiling { .. }
132                | EnvelopeError::Expired { .. }
133                | EnvelopeError::KeyAgeExceeded { .. }
134        )
135    }
136}
137
138impl std::fmt::Display for EnvelopeError {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        match self {
141            EnvelopeError::Signature(e) => write!(f, "{e}"),
142            EnvelopeError::UnknownKind(k) => write!(f, "unrecognised command kind {k:?}"),
143            EnvelopeError::Malformed(e) => write!(f, "malformed command envelope: {e}"),
144            EnvelopeError::MissingRecipient => write!(f, "envelope names no recipient"),
145            EnvelopeError::Misaddressed { target } => {
146                write!(f, "envelope is addressed to {target:?}, not this host")
147            }
148            EnvelopeError::MissingExpiry => write!(f, "envelope carries no expiry"),
149            EnvelopeError::MalformedExpiry(e) => write!(f, "envelope expiry is malformed: {e}"),
150            EnvelopeError::ClockAhead { ahead_ms } => write!(
151                f,
152                "signed {ahead_ms}ms in the future, beyond the {}s clock allowance — the \
153                 signer's and this host's clocks disagree",
154                FUTURE_SKEW_ALLOWANCE.as_secs()
155            ),
156            EnvelopeError::Reversed => write!(f, "envelope expires before it was signed"),
157            EnvelopeError::OverCeiling { validity_ms } => write!(
158                f,
159                "envelope validity of {validity_ms}ms exceeds the {}s ceiling",
160                MAX_ENVELOPE_VALIDITY.as_secs()
161            ),
162            EnvelopeError::Expired { deadline } => {
163                write!(f, "envelope start deadline {deadline} has passed")
164            }
165            EnvelopeError::KeyAgeExceeded {
166                kid,
167                age_ms,
168                max_age_ms,
169            } => write!(
170                f,
171                "signature by {kid} is {age_ms}ms old, past its {max_age_ms}ms bound"
172            ),
173        }
174    }
175}
176
177impl std::error::Error for EnvelopeError {}
178
179/// An envelope that passed every check.
180#[derive(Debug, Clone)]
181pub struct VerifiedEnvelope {
182    pub command: Command,
183    /// The earlier of `expires_at` and the command's own `deadline_at`. The
184    /// latest moment the command may **start**.
185    pub start_deadline: DateTime<Utc>,
186    pub kid: String,
187}
188
189/// Whether a start deadline has passed. Inclusive: starting exactly at the
190/// deadline is allowed, matching the existing `deadline_at` boundary.
191pub fn start_deadline_passed(deadline: DateTime<Utc>, now: DateTime<Utc>) -> bool {
192    now > deadline
193}
194
195/// Build and sign an envelope. Returns the exact bytes to publish and the
196/// signature headers that cover them.
197///
198/// The bytes are serialized once and signed as-is; verification is over the
199/// received bytes, never over a re-serialised value. Refuses a window that
200/// the agent would refuse anyway, so a bad expiry fails at the signer rather
201/// than as a fleet-wide refusal.
202pub fn sign_envelope(
203    signer: &Signer,
204    target_pc_id: &str,
205    expires_at: DateTime<Utc>,
206    command: Command,
207    now: DateTime<Utc>,
208) -> Result<(Vec<u8>, SigHeaders), EnvelopeError> {
209    if target_pc_id.is_empty() {
210        return Err(EnvelopeError::MissingRecipient);
211    }
212    let validity = expires_at.timestamp_millis() as i128 - now.timestamp_millis() as i128;
213    if validity < 0 {
214        return Err(EnvelopeError::Reversed);
215    }
216    if validity > MAX_ENVELOPE_VALIDITY.as_millis() as i128 {
217        return Err(EnvelopeError::OverCeiling {
218            validity_ms: validity,
219        });
220    }
221    let envelope = CommandEnvelope {
222        kind: ENVELOPE_KIND_V2.to_owned(),
223        target_pc_id: target_pc_id.to_owned(),
224        expires_at,
225        command,
226    };
227    let body =
228        serde_json::to_vec(&envelope).map_err(|e| EnvelopeError::Malformed(e.to_string()))?;
229    let headers = signer.headers(&body, now.timestamp_millis());
230    Ok((body, headers))
231}
232
233/// Verify a received envelope. Pure: the clock is passed in, so the admission
234/// step and the tests can call it without a broker or a real clock.
235///
236/// Order matters. The signature is checked over the received bytes first, and
237/// only then is the body parsed strictly, so forged bytes cannot provoke a
238/// clock or recipient report. After that: kind, recipient, expiry presence,
239/// then the time checks (future skew, reversed, ceiling, expiry, key age).
240pub fn verify_envelope(
241    ring: &KeyRing,
242    body: &[u8],
243    headers: &SigHeaders,
244    my_pc_id: &str,
245    now: DateTime<Utc>,
246) -> Result<VerifiedEnvelope, EnvelopeError> {
247    let auth = signing::verify_signature(ring, body, headers).map_err(EnvelopeError::Signature)?;
248
249    let value: serde_json::Value =
250        serde_json::from_slice(body).map_err(|e| EnvelopeError::Malformed(e.to_string()))?;
251    let obj = value
252        .as_object()
253        .ok_or_else(|| EnvelopeError::Malformed("not a JSON object".into()))?;
254    match obj.get("kind").and_then(|k| k.as_str()) {
255        Some(ENVELOPE_KIND_V2) => {}
256        Some(other) => return Err(EnvelopeError::UnknownKind(other.to_owned())),
257        None => return Err(EnvelopeError::UnknownKind(format!("{:?}", obj.get("kind")))),
258    }
259    if let Some(unknown) = obj.keys().find(|k| {
260        !matches!(
261            k.as_str(),
262            "kind" | "target_pc_id" | "expires_at" | "command"
263        )
264    }) {
265        return Err(EnvelopeError::Malformed(format!(
266            "unknown field {unknown:?}"
267        )));
268    }
269
270    let target = match obj.get("target_pc_id") {
271        None | Some(serde_json::Value::Null) => return Err(EnvelopeError::MissingRecipient),
272        Some(serde_json::Value::String(s)) if !s.is_empty() => s.as_str(),
273        Some(_) => return Err(EnvelopeError::MissingRecipient),
274    };
275    if target != my_pc_id {
276        return Err(EnvelopeError::Misaddressed {
277            target: target.to_owned(),
278        });
279    }
280
281    let expires_at = match obj.get("expires_at") {
282        None | Some(serde_json::Value::Null) => return Err(EnvelopeError::MissingExpiry),
283        Some(serde_json::Value::String(s)) => DateTime::parse_from_rfc3339(s)
284            .map_err(|e| EnvelopeError::MalformedExpiry(e.to_string()))?
285            .with_timezone(&Utc),
286        Some(_) => {
287            return Err(EnvelopeError::MalformedExpiry(
288                "expires_at is not a string".into(),
289            ));
290        }
291    };
292    let command: Command = serde_json::from_value(
293        obj.get("command")
294            .cloned()
295            .ok_or_else(|| EnvelopeError::Malformed("missing command".into()))?,
296    )
297    .map_err(|e| EnvelopeError::Malformed(e.to_string()))?;
298
299    // Wide integers throughout: the signing time is attacker-influenced only
300    // through a genuine signature, but the subtraction must not wrap either way.
301    let now_ms = now.timestamp_millis() as i128;
302    let at_ms = auth.at_ms as i128;
303    let expires_ms = expires_at.timestamp_millis() as i128;
304
305    let ahead_ms = at_ms - now_ms;
306    if ahead_ms > FUTURE_SKEW_ALLOWANCE.as_millis() as i128 {
307        return Err(EnvelopeError::ClockAhead { ahead_ms });
308    }
309    if expires_ms < at_ms {
310        return Err(EnvelopeError::Reversed);
311    }
312    let validity_ms = expires_ms - at_ms;
313    if validity_ms > MAX_ENVELOPE_VALIDITY.as_millis() as i128 {
314        return Err(EnvelopeError::OverCeiling { validity_ms });
315    }
316
317    let start_deadline = command
318        .deadline_at
319        .map_or(expires_at, |d| d.min(expires_at));
320    if start_deadline_passed(start_deadline, now) {
321        return Err(EnvelopeError::Expired {
322            deadline: start_deadline,
323        });
324    }
325
326    if let Some(max_age) = auth.policy.max_age {
327        let age_ms = now_ms - at_ms;
328        let max_age_ms = max_age.as_millis() as i128;
329        if age_ms > max_age_ms {
330            return Err(EnvelopeError::KeyAgeExceeded {
331                kid: auth.kid.to_owned(),
332                age_ms,
333                max_age_ms,
334            });
335        }
336    }
337
338    Ok(VerifiedEnvelope {
339        command,
340        start_deadline,
341        kid: auth.kid.to_owned(),
342    })
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::signing::{KeyPolicy, generate_keypair};
349    use crate::wire::{RunAs, Shell, Staleness};
350    use chrono::TimeZone;
351
352    fn t(secs: i64) -> DateTime<Utc> {
353        Utc.timestamp_opt(1_800_000_000 + secs, 0).unwrap()
354    }
355
356    fn command() -> Command {
357        Command {
358            id: "echo".into(),
359            version: "1.0.0".into(),
360            request_id: "req-1".into(),
361            exec_id: Some("dep-1".into()),
362            shell: Shell::Sh,
363            script: "echo hi".into(),
364            script_object: None,
365            script_object_sha256: None,
366            timeout_secs: 30,
367            bypass_local_limit: false,
368            jitter_secs: None,
369            run_as: RunAs::System,
370            cwd: None,
371            deadline_at: None,
372            staleness: Staleness::Cached,
373            emit: None,
374            check: None,
375            collect: None,
376            retry: None,
377            finalize: None,
378        }
379    }
380
381    fn signer() -> Signer {
382        Signer::new(generate_keypair().unwrap(), "backend-1")
383    }
384
385    fn ring(signer: &Signer, policy: KeyPolicy) -> KeyRing {
386        let mut ring = KeyRing::new();
387        ring.insert(signer.kid(), signer.verifying_key(), policy);
388        ring
389    }
390
391    fn signed(s: &Signer, expires: i64) -> (Vec<u8>, SigHeaders) {
392        sign_envelope(s, "PC-A", t(expires), command(), t(0)).unwrap()
393    }
394
395    /// Re-sign arbitrary bytes, for cases the typed builder refuses to make.
396    fn resign(s: &Signer, body: &str, at: i64) -> (Vec<u8>, SigHeaders) {
397        let b = body.as_bytes().to_vec();
398        let h = s.headers(&b, t(at).timestamp_millis());
399        (b, h)
400    }
401
402    #[test]
403    fn round_trip_verifies_and_carries_the_command() {
404        let s = signer();
405        let (body, h) = signed(&s, 3600);
406        let v =
407            verify_envelope(&ring(&s, KeyPolicy::backend("b")), &body, &h, "PC-A", t(10)).unwrap();
408        assert_eq!(v.command.request_id, "req-1");
409        assert_eq!(v.start_deadline, t(3600));
410        assert_eq!(v.kid, "backend-1");
411    }
412
413    #[test]
414    fn tampering_with_any_field_fails_the_signature() {
415        let s = signer();
416        let r = ring(&s, KeyPolicy::backend("b"));
417        let (body, h) = signed(&s, 3600);
418        let text = String::from_utf8(body).unwrap();
419        let exp = t(3600).to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true);
420        for (from, to) in [
421            ("echo hi", "rm -rf x"),
422            ("PC-A", "PC-B"),
423            (exp.as_str(), "2099-01-01T00:00:00Z"),
424            ("req-1", "req-2"),
425        ] {
426            assert!(text.contains(from), "fixture lacks {from}");
427            let tampered = text.replace(from, to);
428            let err = verify_envelope(&r, tampered.as_bytes(), &h, "PC-B", t(10)).unwrap_err();
429            assert!(
430                matches!(
431                    err,
432                    EnvelopeError::Signature(VerifyError::BadSignature { .. })
433                ),
434                "{from}: {err}"
435            );
436        }
437    }
438
439    #[test]
440    fn an_envelope_for_one_host_is_refused_on_another() {
441        let s = signer();
442        let (body, h) = signed(&s, 3600);
443        let r = ring(&s, KeyPolicy::backend("b"));
444        let err = verify_envelope(&r, &body, &h, "PC-B", t(10)).unwrap_err();
445        assert!(matches!(err, EnvelopeError::Misaddressed { .. }), "{err}");
446        // Case is preserved, not folded.
447        let err = verify_envelope(&r, &body, &h, "pc-a", t(10)).unwrap_err();
448        assert!(matches!(err, EnvelopeError::Misaddressed { .. }), "{err}");
449    }
450
451    #[test]
452    fn expired_is_refused_and_the_boundary_is_inclusive() {
453        let s = signer();
454        let (body, h) = signed(&s, 100);
455        let r = ring(&s, KeyPolicy::backend("b"));
456        assert!(verify_envelope(&r, &body, &h, "PC-A", t(100)).is_ok());
457        let err = verify_envelope(&r, &body, &h, "PC-A", t(101)).unwrap_err();
458        assert!(matches!(err, EnvelopeError::Expired { .. }), "{err}");
459    }
460
461    #[test]
462    fn the_commands_own_deadline_wins_when_earlier() {
463        let s = signer();
464        let mut c = command();
465        c.deadline_at = Some(t(50));
466        let (body, h) = sign_envelope(&s, "PC-A", t(3600), c, t(0)).unwrap();
467        let r = ring(&s, KeyPolicy::backend("b"));
468        let v = verify_envelope(&r, &body, &h, "PC-A", t(10)).unwrap();
469        assert_eq!(v.start_deadline, t(50));
470        let err = verify_envelope(&r, &body, &h, "PC-A", t(60)).unwrap_err();
471        assert!(matches!(err, EnvelopeError::Expired { deadline } if deadline == t(50)));
472    }
473
474    #[test]
475    fn over_ceiling_is_refused_by_the_agent_even_if_the_signer_allowed_it() {
476        let s = signer();
477        let r = ring(&s, KeyPolicy::backend("b"));
478        let far = MAX_ENVELOPE_VALIDITY.as_secs() as i64 + 1;
479        // The builder refuses it ...
480        assert!(matches!(
481            sign_envelope(&s, "PC-A", t(far), command(), t(0)),
482            Err(EnvelopeError::OverCeiling { .. })
483        ));
484        // ... and the verifier does not rely on that.
485        let body = serde_json::to_string(&CommandEnvelope {
486            kind: ENVELOPE_KIND_V2.into(),
487            target_pc_id: "PC-A".into(),
488            expires_at: t(far),
489            command: command(),
490        })
491        .unwrap();
492        let (b, h) = resign(&s, &body, 0);
493        let err = verify_envelope(&r, &b, &h, "PC-A", t(10)).unwrap_err();
494        assert!(matches!(err, EnvelopeError::OverCeiling { .. }), "{err}");
495        // Exactly the ceiling is fine.
496        let ok = body.replace(
497            &t(far).to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true),
498            &t(far - 1).to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true),
499        );
500        let (b, h) = resign(&s, &ok, 0);
501        assert!(verify_envelope(&r, &b, &h, "PC-A", t(10)).is_ok());
502    }
503
504    #[test]
505    fn future_dated_beyond_the_allowance_is_a_clock_refusal() {
506        let s = signer();
507        let r = ring(&s, KeyPolicy::backend("b"));
508        let (body, h) = signed(&s, 7200);
509        // Host clock 1h behind the signer: exactly at the allowance still ok.
510        let at_edge = t(0) - chrono::Duration::seconds(FUTURE_SKEW_ALLOWANCE.as_secs() as i64);
511        assert!(verify_envelope(&r, &body, &h, "PC-A", at_edge).is_ok());
512        let behind = at_edge - chrono::Duration::seconds(1);
513        let err = verify_envelope(&r, &body, &h, "PC-A", behind).unwrap_err();
514        assert!(matches!(err, EnvelopeError::ClockAhead { .. }), "{err}");
515        assert!(err.is_clock());
516    }
517
518    #[test]
519    fn reversed_range_is_refused_distinctly() {
520        let s = signer();
521        let r = ring(&s, KeyPolicy::backend("b"));
522        let body = serde_json::to_string(&CommandEnvelope {
523            kind: ENVELOPE_KIND_V2.into(),
524            target_pc_id: "PC-A".into(),
525            expires_at: t(-100),
526            command: command(),
527        })
528        .unwrap();
529        let (b, h) = resign(&s, &body, 0);
530        let err = verify_envelope(&r, &b, &h, "PC-A", t(-200)).unwrap_err();
531        assert_eq!(err, EnvelopeError::Reversed);
532    }
533
534    #[test]
535    fn a_break_glass_keys_own_shorter_bound_still_applies() {
536        let s = signer();
537        let r = ring(&s, KeyPolicy::break_glass("bg", Duration::from_secs(300)));
538        let (body, h) = signed(&s, 3600);
539        assert!(verify_envelope(&r, &body, &h, "PC-A", t(299)).is_ok());
540        let err = verify_envelope(&r, &body, &h, "PC-A", t(301)).unwrap_err();
541        assert!(matches!(err, EnvelopeError::KeyAgeExceeded { .. }), "{err}");
542    }
543
544    #[test]
545    fn missing_or_malformed_fields_are_distinct_refusals() {
546        let s = signer();
547        let r = ring(&s, KeyPolicy::backend("b"));
548        let cmd = serde_json::to_string(&command()).unwrap();
549        let cases = [
550            (
551                format!(
552                    r#"{{"kind":"kanade.command.v2","expires_at":"2027-01-01T00:00:00Z","command":{cmd}}}"#
553                ),
554                EnvelopeError::MissingRecipient,
555            ),
556            (
557                format!(r#"{{"kind":"kanade.command.v2","target_pc_id":"PC-A","command":{cmd}}}"#),
558                EnvelopeError::MissingExpiry,
559            ),
560        ];
561        for (body, want) in cases {
562            let (b, h) = resign(&s, &body, 0);
563            assert_eq!(verify_envelope(&r, &b, &h, "PC-A", t(1)).unwrap_err(), want);
564        }
565        for bad in [r#""soon""#, "12345", "true"] {
566            let body = format!(
567                r#"{{"kind":"kanade.command.v2","target_pc_id":"PC-A","expires_at":{bad},"command":{cmd}}}"#
568            );
569            let (b, h) = resign(&s, &body, 0);
570            let err = verify_envelope(&r, &b, &h, "PC-A", t(1)).unwrap_err();
571            assert!(
572                matches!(err, EnvelopeError::MalformedExpiry(_)),
573                "{bad}: {err}"
574            );
575        }
576    }
577
578    #[test]
579    fn unknown_kind_and_unknown_fields_are_refused() {
580        let s = signer();
581        let r = ring(&s, KeyPolicy::backend("b"));
582        let cmd = serde_json::to_string(&command()).unwrap();
583        for kind in [r#""kanade.command.v3""#, "null", "7"] {
584            let body = format!(
585                r#"{{"kind":{kind},"target_pc_id":"PC-A","expires_at":"2027-01-01T00:00:00Z","command":{cmd}}}"#
586            );
587            let (b, h) = resign(&s, &body, 0);
588            let err = verify_envelope(&r, &b, &h, "PC-A", t(1)).unwrap_err();
589            assert!(
590                matches!(err, EnvelopeError::UnknownKind(_)),
591                "{kind}: {err}"
592            );
593        }
594        let body = format!(
595            r#"{{"kind":"kanade.command.v2","target_pc_id":"PC-A","expires_at":"2027-01-01T00:00:00Z","extra":1,"command":{cmd}}}"#
596        );
597        let (b, h) = resign(&s, &body, 0);
598        assert!(matches!(
599            verify_envelope(&r, &b, &h, "PC-A", t(1)).unwrap_err(),
600            EnvelopeError::Malformed(_)
601        ));
602    }
603
604    #[test]
605    fn a_forged_envelope_cannot_provoke_a_clock_report() {
606        let s = signer();
607        let other = signer();
608        let r = ring(&s, KeyPolicy::backend("b"));
609        // Expired and future-dated, but signed by a key that is not on the ring.
610        let (body, h) = sign_envelope(&other, "PC-A", t(10), command(), t(0)).unwrap();
611        let err = verify_envelope(&r, &body, &h, "PC-A", t(1_000_000)).unwrap_err();
612        assert!(matches!(err, EnvelopeError::Signature(_)), "{err}");
613    }
614
615    #[test]
616    fn the_envelope_is_not_a_legacy_command() {
617        let s = signer();
618        let (body, _) = signed(&s, 3600);
619        assert!(serde_json::from_slice::<Command>(&body).is_err());
620    }
621
622    #[test]
623    fn the_supported_protocol_set_names_both() {
624        assert_eq!(
625            supported_command_protocols(),
626            vec!["legacy".to_owned(), "kanade.command.v2".to_owned()]
627        );
628    }
629}