1use 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
49pub const ENVELOPE_KIND_V2: &str = "kanade.command.v2";
51
52pub const PROTOCOL_LEGACY: &str = "legacy";
54pub const PROTOCOL_V2: &str = ENVELOPE_KIND_V2;
56
57pub const MAX_ENVELOPE_VALIDITY: Duration = Duration::from_secs(7 * 24 * 60 * 60);
66
67pub const FUTURE_SKEW_ALLOWANCE: Duration = Duration::from_secs(60 * 60);
70
71pub fn supported_command_protocols() -> Vec<String> {
73 vec![PROTOCOL_LEGACY.to_owned(), PROTOCOL_V2.to_owned()]
74}
75
76#[derive(Serialize, Deserialize, Debug, Clone)]
78#[serde(deny_unknown_fields)]
79pub struct CommandEnvelope {
80 pub kind: String,
81 pub target_pc_id: String,
84 pub expires_at: DateTime<Utc>,
87 pub command: Command,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum EnvelopeError {
94 Signature(VerifyError),
96 UnknownKind(String),
98 Malformed(String),
100 MissingRecipient,
102 Misaddressed { target: String },
104 MissingExpiry,
106 MalformedExpiry(String),
108 ClockAhead { ahead_ms: i128 },
110 Reversed,
112 OverCeiling { validity_ms: i128 },
114 Expired { deadline: DateTime<Utc> },
116 KeyAgeExceeded {
118 kid: String,
119 age_ms: i128,
120 max_age_ms: i128,
121 },
122}
123
124impl EnvelopeError {
125 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#[derive(Debug, Clone)]
181pub struct VerifiedEnvelope {
182 pub command: Command,
183 pub start_deadline: DateTime<Utc>,
186 pub kid: String,
187}
188
189pub fn start_deadline_passed(deadline: DateTime<Utc>, now: DateTime<Utc>) -> bool {
192 now > deadline
193}
194
195pub 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
233pub 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 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 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 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 assert!(matches!(
481 sign_envelope(&s, "PC-A", t(far), command(), t(0)),
482 Err(EnvelopeError::OverCeiling { .. })
483 ));
484 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 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 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 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}