1use std::fmt;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
6use byteor_core::config::Environment;
7use ed25519_dalek::{Signature, Verifier as _, VerifyingKey};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use time::{format_description::well_known::Rfc3339, OffsetDateTime};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ApprovalResolution {
15 pub approval_token_present: bool,
17 pub required_capabilities: Vec<String>,
19 pub spec_hash: String,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ApprovalTrustSource {
27 Refreshed,
29 Cached,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct TrustedApprovalKeyCache {
36 pub source: ApprovalTrustSource,
38 pub last_refresh_at: Option<String>,
40 pub keys: Vec<TrustedApprovalKey>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
46pub struct TrustedApprovalKey {
47 pub kid: String,
49 pub algorithm: String,
51 pub public_key_pem: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ApprovalRefreshBootstrap {
58 pub api_base_url: String,
60 pub agent_id: String,
62 pub agent_api_key: String,
64}
65
66#[derive(Debug, Deserialize)]
67struct TrustedApprovalKeyEnvelope {
68 keys: Vec<TrustedApprovalKey>,
69}
70
71#[derive(Debug, Deserialize, Serialize)]
72struct TrustedApprovalKeyCacheEnvelope {
73 source: ApprovalTrustSource,
74 #[serde(default)]
75 last_refresh_at: Option<String>,
76 keys: Vec<TrustedApprovalKey>,
77}
78
79#[derive(Debug, Deserialize)]
80struct SigningKeysResponseEnvelope {
81 data: SigningKeysResponseData,
82}
83
84#[derive(Debug, Deserialize)]
85struct SigningKeysResponseData {
86 keys: Vec<TrustedApprovalKey>,
87}
88
89#[derive(Debug, Deserialize)]
90struct ApprovalCredentialPayload {
91 v: u32,
92 kid: String,
93 spec_hash: String,
94 capabilities: Vec<String>,
95 environment_posture: String,
96 expires_at: String,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum ApprovalVerificationError {
102 MissingKeySet,
104 InvalidFormat,
106 InvalidBase64,
108 InvalidPayloadJson,
110 UnsupportedVersion(u32),
112 UnknownKeyId(String),
114 UnsupportedAlgorithm(String),
116 InvalidPublicKeyPem(String),
118 InvalidSignature,
120 SpecHashMismatch,
122 EnvironmentMismatch,
124 Expired,
126 InsufficientCapabilities(Vec<String>),
128}
129
130impl fmt::Display for ApprovalVerificationError {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 match self {
133 Self::MissingKeySet => {
134 write!(f, "approval key set required for credential verification")
135 }
136 Self::InvalidFormat => write!(f, "approval credential must be <payload>.<signature>"),
137 Self::InvalidBase64 => write!(f, "approval credential is not valid base64url"),
138 Self::InvalidPayloadJson => write!(f, "approval credential payload is not valid json"),
139 Self::UnsupportedVersion(version) => {
140 write!(f, "unsupported approval credential version {version}")
141 }
142 Self::UnknownKeyId(kid) => write!(f, "approval signing key {kid} is not trusted"),
143 Self::UnsupportedAlgorithm(algorithm) => {
144 write!(f, "unsupported approval signing algorithm {algorithm}")
145 }
146 Self::InvalidPublicKeyPem(kid) => {
147 write!(f, "approval signing key {kid} has invalid public key pem")
148 }
149 Self::InvalidSignature => write!(f, "approval credential signature is invalid"),
150 Self::SpecHashMismatch => write!(
151 f,
152 "approval credential spec hash does not match runtime spec"
153 ),
154 Self::EnvironmentMismatch => {
155 write!(
156 f,
157 "approval credential environment does not match runtime environment"
158 )
159 }
160 Self::Expired => write!(f, "approval credential has expired"),
161 Self::InsufficientCapabilities(capabilities) => write!(
162 f,
163 "approval credential is missing required capabilities: {}",
164 capabilities.join(", ")
165 ),
166 }
167 }
168}
169
170impl std::error::Error for ApprovalVerificationError {}
171
172pub fn resolve_runtime_approval(
175 canonical_spec_kv: &str,
176 env: Environment,
177 approval_credential: Option<&str>,
178 trusted_keys: &[TrustedApprovalKey],
179) -> Result<ApprovalResolution, ApprovalVerificationError> {
180 let required_capabilities = required_approval_capabilities_for_spec_kv(canonical_spec_kv);
181 let spec_hash = approval_spec_hash_from_canonical_spec_kv(canonical_spec_kv);
182
183 let approval_token_present = match approval_credential {
184 Some(credential) => {
185 verify_approval_credential(
186 credential,
187 trusted_keys,
188 &spec_hash,
189 environment_posture(env),
190 &required_capabilities,
191 )?;
192 true
193 }
194 None => false,
195 };
196
197 Ok(ApprovalResolution {
198 approval_token_present,
199 required_capabilities,
200 spec_hash,
201 })
202}
203
204pub fn load_trusted_approval_keys_from_paths(
206 paths: &[impl AsRef<Path>],
207) -> Result<Vec<TrustedApprovalKey>, String> {
208 let mut keys = Vec::new();
209 for path in paths {
210 keys.extend(load_trusted_approval_keys_from_file(path.as_ref())?);
211 }
212 Ok(keys)
213}
214
215pub fn discover_trusted_approval_key_paths(spec_path: &Path) -> Vec<PathBuf> {
217 let Some(candidate) = default_trusted_approval_key_cache_path(spec_path) else {
218 return Vec::new();
219 };
220 if candidate.is_file() {
221 vec![candidate]
222 } else {
223 Vec::new()
224 }
225}
226
227pub fn default_trusted_approval_key_cache_path(spec_path: &Path) -> Option<PathBuf> {
229 Some(spec_path.parent()?.join("signing_keys.json"))
230}
231
232pub fn default_approval_credential_path(spec_path: &Path) -> Option<PathBuf> {
234 Some(spec_path.parent()?.join("approval.cred"))
235}
236
237pub fn load_approval_credential_from_bundle(spec_path: &Path) -> Result<Option<String>, String> {
239 let Some(path) = default_approval_credential_path(spec_path) else {
240 return Ok(None);
241 };
242 if !path.is_file() {
243 return Ok(None);
244 }
245
246 let credential = std::fs::read_to_string(&path)
247 .map_err(|error| format!("read approval credential {}: {error}", path.display()))?;
248 let trimmed = credential.trim();
249 if trimmed.is_empty() {
250 return Ok(None);
251 }
252
253 Ok(Some(trimmed.to_string()))
254}
255
256pub fn load_trusted_approval_keys_from_file(
258 path: &Path,
259) -> Result<Vec<TrustedApprovalKey>, String> {
260 Ok(load_trusted_approval_key_cache_from_file(path)?.keys)
261}
262
263pub fn load_trusted_approval_key_cache_from_file(
265 path: &Path,
266) -> Result<TrustedApprovalKeyCache, String> {
267 let raw = std::fs::read_to_string(path)
268 .map_err(|error| format!("read approval key set {}: {error}", path.display()))?;
269
270 if let Ok(envelope) = serde_json::from_str::<TrustedApprovalKeyCacheEnvelope>(&raw) {
271 if !envelope.keys.is_empty() {
272 return Ok(TrustedApprovalKeyCache {
273 source: envelope.source,
274 last_refresh_at: envelope.last_refresh_at,
275 keys: envelope.keys,
276 });
277 }
278 }
279
280 if let Ok(envelope) = serde_json::from_str::<TrustedApprovalKeyEnvelope>(&raw) {
281 if !envelope.keys.is_empty() {
282 return Ok(TrustedApprovalKeyCache {
283 source: ApprovalTrustSource::Cached,
284 last_refresh_at: None,
285 keys: envelope.keys,
286 });
287 }
288 }
289 if let Ok(keys) = serde_json::from_str::<Vec<TrustedApprovalKey>>(&raw) {
290 if !keys.is_empty() {
291 return Ok(TrustedApprovalKeyCache {
292 source: ApprovalTrustSource::Cached,
293 last_refresh_at: None,
294 keys,
295 });
296 }
297 }
298 if let Ok(key) = serde_json::from_str::<TrustedApprovalKey>(&raw) {
299 return Ok(TrustedApprovalKeyCache {
300 source: ApprovalTrustSource::Cached,
301 last_refresh_at: None,
302 keys: vec![key],
303 });
304 }
305
306 Err(format!(
307 "approval key set {} must be a signing-key json object or array",
308 path.display()
309 ))
310}
311
312pub fn load_trusted_approval_key_cache_from_paths(
314 paths: &[impl AsRef<Path>],
315) -> Result<TrustedApprovalKeyCache, String> {
316 if paths.is_empty() {
317 return Err("approval key set required for credential verification".to_string());
318 }
319
320 let mut last_refresh_at: Option<String> = None;
321 let mut keys = Vec::new();
322 for path in paths {
323 let cache = load_trusted_approval_key_cache_from_file(path.as_ref())?;
324 if let Some(refresh_at) = cache.last_refresh_at {
325 if last_refresh_at
326 .as_ref()
327 .map_or(true, |current| refresh_at > *current)
328 {
329 last_refresh_at = Some(refresh_at);
330 }
331 }
332 keys.extend(cache.keys);
333 }
334
335 if keys.is_empty() {
336 return Err("approval key set required for credential verification".to_string());
337 }
338
339 keys.sort_by(|left, right| left.kid.cmp(&right.kid));
340 keys.dedup_by(|left, right| left.kid == right.kid);
341
342 Ok(TrustedApprovalKeyCache {
343 source: ApprovalTrustSource::Cached,
344 last_refresh_at,
345 keys,
346 })
347}
348
349pub fn write_trusted_approval_key_cache(
351 path: &Path,
352 cache: &TrustedApprovalKeyCache,
353) -> Result<(), String> {
354 let payload = serde_json::to_vec_pretty(&TrustedApprovalKeyCacheEnvelope {
355 source: cache.source.clone(),
356 last_refresh_at: cache.last_refresh_at.clone(),
357 keys: cache.keys.clone(),
358 })
359 .map_err(|error| format!("serialize approval key set {}: {error}", path.display()))?;
360 std::fs::write(path, payload)
361 .map_err(|error| format!("write approval key set {}: {error}", path.display()))
362}
363
364pub fn approval_refresh_bootstrap_from_env() -> Result<Option<ApprovalRefreshBootstrap>, String> {
366 let api_base_url = std::env::var("BYTEOR_API_BASE_URL").ok();
367 let agent_id = std::env::var("BYTEOR_AGENT_ID").ok();
368 let agent_api_key = std::env::var("BYTEOR_AGENT_API_KEY").ok();
369
370 if api_base_url.is_none() && agent_id.is_none() && agent_api_key.is_none() {
371 return Ok(None);
372 }
373
374 let mut missing = Vec::new();
375 if api_base_url.as_deref().map(str::is_empty).unwrap_or(true) {
376 missing.push("BYTEOR_API_BASE_URL");
377 }
378 if agent_id.as_deref().map(str::is_empty).unwrap_or(true) {
379 missing.push("BYTEOR_AGENT_ID");
380 }
381 if agent_api_key.as_deref().map(str::is_empty).unwrap_or(true) {
382 missing.push("BYTEOR_AGENT_API_KEY");
383 }
384 if !missing.is_empty() {
385 return Err(format!(
386 "approval refresh bootstrap is incomplete; missing {}",
387 missing.join(", ")
388 ));
389 }
390
391 Ok(Some(ApprovalRefreshBootstrap {
392 api_base_url: api_base_url.expect("api base url present"),
393 agent_id: agent_id.expect("agent id present"),
394 agent_api_key: agent_api_key.expect("agent api key present"),
395 }))
396}
397
398pub fn load_runtime_trusted_approval_key_cache(
400 spec_path: &Path,
401 configured_paths: &[PathBuf],
402 bootstrap: Option<&ApprovalRefreshBootstrap>,
403) -> Result<TrustedApprovalKeyCache, String> {
404 let local_paths = effective_local_key_paths(spec_path, configured_paths);
405
406 if let Some(bootstrap) = bootstrap {
407 match refresh_runtime_trusted_approval_key_cache(spec_path, configured_paths, bootstrap) {
408 Ok(cache) => return Ok(cache),
409 Err(refresh_error) => {
410 return load_trusted_approval_key_cache_from_paths(&local_paths)
411 .map_err(|cache_error| format!("{refresh_error}; {cache_error}"));
412 }
413 }
414 }
415
416 load_trusted_approval_key_cache_from_paths(&local_paths)
417}
418
419pub fn refresh_runtime_trusted_approval_key_cache(
421 spec_path: &Path,
422 configured_paths: &[PathBuf],
423 bootstrap: &ApprovalRefreshBootstrap,
424) -> Result<TrustedApprovalKeyCache, String> {
425 let cache_path = runtime_key_cache_write_path(spec_path, configured_paths)
426 .ok_or_else(|| "approval key cache path unavailable for runtime startup".to_string())?;
427 let refreshed = fetch_trusted_approval_keys_from_control_plane(bootstrap)?;
428 write_trusted_approval_key_cache(&cache_path, &refreshed)?;
429 Ok(refreshed)
430}
431
432pub fn required_approval_capabilities_for_spec_kv(spec_kv: &str) -> Vec<String> {
434 let mut capabilities = Vec::new();
435 if spec_kv.contains("http_post:") {
436 capabilities.push("http_post".to_string());
437 }
438 if spec_kv.contains("exec:") {
439 capabilities.push("exec".to_string());
440 }
441 if !capabilities.is_empty() {
442 capabilities.push("execute_side_effects".to_string());
443 }
444 capabilities.sort();
445 capabilities.dedup();
446 capabilities
447}
448
449pub fn approval_spec_hash_from_canonical_spec_kv(spec_kv: &str) -> String {
451 let mut hasher = Sha256::new();
452 hasher.update(spec_kv.as_bytes());
453 format!("sha256:{:x}", hasher.finalize())
454}
455
456pub fn approval_credential_expires_at(credential: &str) -> Result<String, String> {
458 let (payload_b64, _) = credential.split_once('.').ok_or_else(|| {
459 "approval credential is not formatted as <payload>.<signature>".to_string()
460 })?;
461 let payload_bytes = URL_SAFE_NO_PAD
462 .decode(payload_b64)
463 .map_err(|_| "approval credential payload is not valid base64url".to_string())?;
464 let payload: ApprovalCredentialPayload = serde_json::from_slice(&payload_bytes)
465 .map_err(|_| "approval credential payload is not valid json".to_string())?;
466 Ok(payload.expires_at)
467}
468
469fn environment_posture(env: Environment) -> &'static str {
470 match env {
471 Environment::Dev => "dev",
472 Environment::Staging => "staging",
473 Environment::Prod => "prod",
474 }
475}
476
477fn verify_approval_credential(
478 credential: &str,
479 trusted_keys: &[TrustedApprovalKey],
480 spec_hash: &str,
481 environment_posture: &str,
482 required_capabilities: &[String],
483) -> Result<(), ApprovalVerificationError> {
484 if trusted_keys.is_empty() {
485 return Err(ApprovalVerificationError::MissingKeySet);
486 }
487
488 let (payload_b64, signature_b64) = credential
489 .split_once('.')
490 .ok_or(ApprovalVerificationError::InvalidFormat)?;
491 let payload_bytes = URL_SAFE_NO_PAD
492 .decode(payload_b64)
493 .map_err(|_| ApprovalVerificationError::InvalidBase64)?;
494 let signature_bytes = URL_SAFE_NO_PAD
495 .decode(signature_b64)
496 .map_err(|_| ApprovalVerificationError::InvalidBase64)?;
497 let payload: ApprovalCredentialPayload = serde_json::from_slice(&payload_bytes)
498 .map_err(|_| ApprovalVerificationError::InvalidPayloadJson)?;
499
500 if payload.v != 1 {
501 return Err(ApprovalVerificationError::UnsupportedVersion(payload.v));
502 }
503
504 let trusted_key = trusted_keys
505 .iter()
506 .find(|key| key.kid == payload.kid)
507 .ok_or_else(|| ApprovalVerificationError::UnknownKeyId(payload.kid.clone()))?;
508 if trusted_key.algorithm != "Ed25519" {
509 return Err(ApprovalVerificationError::UnsupportedAlgorithm(
510 trusted_key.algorithm.clone(),
511 ));
512 }
513
514 let verifying_key = parse_verifying_key_from_pem(&trusted_key.public_key_pem)
515 .map_err(|_| ApprovalVerificationError::InvalidPublicKeyPem(trusted_key.kid.clone()))?;
516 let signature = Signature::try_from(signature_bytes.as_slice())
517 .map_err(|_| ApprovalVerificationError::InvalidSignature)?;
518 verifying_key
519 .verify(&payload_bytes, &signature)
520 .map_err(|_| ApprovalVerificationError::InvalidSignature)?;
521
522 if payload.spec_hash != spec_hash {
523 return Err(ApprovalVerificationError::SpecHashMismatch);
524 }
525 if payload.environment_posture != environment_posture {
526 return Err(ApprovalVerificationError::EnvironmentMismatch);
527 }
528
529 let expires_at = OffsetDateTime::parse(payload.expires_at.as_str(), &Rfc3339)
530 .map_err(|_| ApprovalVerificationError::InvalidPayloadJson)?;
531 if OffsetDateTime::now_utc() >= expires_at {
532 return Err(ApprovalVerificationError::Expired);
533 }
534
535 let missing_capabilities = required_capabilities
536 .iter()
537 .filter(|capability| !payload.capabilities.contains(*capability))
538 .cloned()
539 .collect::<Vec<_>>();
540 if !missing_capabilities.is_empty() {
541 return Err(ApprovalVerificationError::InsufficientCapabilities(
542 missing_capabilities,
543 ));
544 }
545
546 Ok(())
547}
548
549fn parse_verifying_key_from_pem(pem: &str) -> Result<VerifyingKey, ()> {
550 let der_bytes = pem
551 .lines()
552 .filter(|line| !line.starts_with("-----"))
553 .collect::<String>();
554 let der = base64::engine::general_purpose::STANDARD
555 .decode(der_bytes.as_bytes())
556 .map_err(|_| ())?;
557 let public_key_bytes: [u8; 32] = der
558 .get(der.len().saturating_sub(32)..)
559 .ok_or(())?
560 .try_into()
561 .map_err(|_| ())?;
562 VerifyingKey::from_bytes(&public_key_bytes).map_err(|_| ())
563}
564
565fn effective_local_key_paths(spec_path: &Path, configured_paths: &[PathBuf]) -> Vec<PathBuf> {
566 if configured_paths.is_empty() {
567 discover_trusted_approval_key_paths(spec_path)
568 } else {
569 configured_paths.to_vec()
570 }
571}
572
573fn runtime_key_cache_write_path(spec_path: &Path, configured_paths: &[PathBuf]) -> Option<PathBuf> {
574 if configured_paths.is_empty() {
575 default_trusted_approval_key_cache_path(spec_path)
576 } else {
577 configured_paths.first().cloned()
578 }
579}
580
581fn fetch_trusted_approval_keys_from_control_plane(
582 bootstrap: &ApprovalRefreshBootstrap,
583) -> Result<TrustedApprovalKeyCache, String> {
584 let url = format!(
585 "{}/api/v1/agents/{}/signing-keys",
586 bootstrap.api_base_url.trim_end_matches('/'),
587 bootstrap.agent_id
588 );
589 let response = ureq::AgentBuilder::new()
590 .timeout(Duration::from_secs(10))
591 .build()
592 .get(&url)
593 .set(
594 "authorization",
595 &format!("Bearer {}", bootstrap.agent_api_key),
596 )
597 .set("accept", "application/json")
598 .set("user-agent", "byteor-runtime/0 (api/v1)")
599 .call()
600 .map_err(|error| format!("refresh approval key set from {url}: {error}"))?;
601
602 let envelope: SigningKeysResponseEnvelope = response
603 .into_json()
604 .map_err(|error| format!("decode approval key set from {url}: {error}"))?;
605 if envelope.data.keys.is_empty() {
606 return Err(format!(
607 "refresh approval key set from {url}: empty key set"
608 ));
609 }
610
611 let last_refresh_at = OffsetDateTime::now_utc()
612 .format(&Rfc3339)
613 .map_err(|error| format!("format approval key refresh timestamp: {error}"))?;
614
615 Ok(TrustedApprovalKeyCache {
616 source: ApprovalTrustSource::Refreshed,
617 last_refresh_at: Some(last_refresh_at),
618 keys: envelope.data.keys,
619 })
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
627 use ed25519_dalek::{pkcs8::EncodePublicKey, Signer as _, SigningKey};
628 use serde_json::json;
629 use std::fs;
630 use std::io::{Read, Write};
631 use std::net::TcpListener;
632
633 fn test_signing_key() -> SigningKey {
634 SigningKey::from_bytes(&[0x24; 32])
635 }
636
637 fn trusted_key() -> TrustedApprovalKey {
638 let signing_key = test_signing_key();
639 let public_key_pem = signing_key
640 .verifying_key()
641 .to_public_key_pem(Default::default())
642 .expect("public key pem");
643 TrustedApprovalKey {
644 kid: "01JKEY000TEST0000000000000".to_string(),
645 algorithm: "Ed25519".to_string(),
646 public_key_pem,
647 }
648 }
649
650 fn sign_payload(payload: serde_json::Value) -> String {
651 let signing_key = test_signing_key();
652 let payload = serde_json::to_vec(&payload).expect("payload json");
653 let signature = signing_key.sign(&payload);
654 format!(
655 "{}.{}",
656 URL_SAFE_NO_PAD.encode(payload),
657 URL_SAFE_NO_PAD.encode(signature.to_bytes())
658 )
659 }
660
661 fn sign_credential(spec_hash: &str, capabilities: &[&str], posture: &str) -> String {
662 sign_payload(json!({
663 "v": 1,
664 "kid": "01JKEY000TEST0000000000000",
665 "approval_id": "01JAPR000TEST0000000000000",
666 "org_id": "01JORG000TEST0000000000000",
667 "project_id": "01JPROJ00TEST000000000000",
668 "environment_id": "01JENV000TEST000000000000",
669 "environment_posture": posture,
670 "spec_hash": spec_hash,
671 "capabilities": capabilities,
672 "issued_by": "01JUSER00TEST000000000000",
673 "issued_at": "2026-03-20T14:32:00Z",
674 "expires_at": "2099-03-21T14:32:00Z"
675 }))
676 }
677
678 #[test]
679 fn resolves_verified_runtime_approval() {
680 let spec_kv = "stage=http_post:https://example.com\n";
681 let spec_hash = approval_spec_hash_from_canonical_spec_kv(spec_kv);
682 let credential =
683 sign_credential(&spec_hash, &["execute_side_effects", "http_post"], "prod");
684
685 let resolution = resolve_runtime_approval(
686 spec_kv,
687 Environment::Prod,
688 Some(credential.as_str()),
689 &[trusted_key()],
690 )
691 .expect("verified approval");
692
693 assert!(resolution.approval_token_present);
694 assert_eq!(resolution.spec_hash, spec_hash);
695 assert_eq!(
696 resolution.required_capabilities,
697 vec!["execute_side_effects".to_string(), "http_post".to_string()]
698 );
699 }
700
701 #[test]
702 fn rejects_untrusted_or_incomplete_credential() {
703 let spec_kv = "stage=exec:/usr/bin/true\n";
704 let spec_hash = approval_spec_hash_from_canonical_spec_kv(spec_kv);
705 let credential = sign_credential(&spec_hash, &["execute_side_effects"], "prod");
706
707 let error = resolve_runtime_approval(
708 spec_kv,
709 Environment::Prod,
710 Some(credential.as_str()),
711 &[trusted_key()],
712 )
713 .expect_err("credential should be rejected");
714
715 assert!(matches!(
716 error,
717 ApprovalVerificationError::InsufficientCapabilities(_)
718 ));
719 }
720
721 #[test]
722 fn rejects_approval_with_unknown_key_id() {
723 let spec_kv = "stage=http_post:https://example.com\n";
724 let spec_hash = approval_spec_hash_from_canonical_spec_kv(spec_kv);
725 let credential = sign_payload(json!({
726 "v": 1,
727 "kid": "01JKEY000UNKNOWN0000000000",
728 "approval_id": "01JAPR000TEST0000000000000",
729 "org_id": "01JORG000TEST0000000000000",
730 "project_id": "01JPROJ00TEST000000000000",
731 "environment_id": "01JENV000TEST000000000000",
732 "environment_posture": "prod",
733 "spec_hash": spec_hash,
734 "capabilities": ["execute_side_effects", "http_post"],
735 "issued_by": "01JUSER00TEST000000000000",
736 "issued_at": "2026-03-20T14:32:00Z",
737 "expires_at": "2099-03-21T14:32:00Z"
738 }));
739
740 let error = resolve_runtime_approval(
741 spec_kv,
742 Environment::Prod,
743 Some(credential.as_str()),
744 &[trusted_key()],
745 )
746 .expect_err("credential should be rejected");
747
748 assert!(matches!(
749 error,
750 ApprovalVerificationError::UnknownKeyId(ref kid)
751 if kid == "01JKEY000UNKNOWN0000000000"
752 ));
753 }
754
755 #[test]
756 fn rejects_approval_with_spec_hash_mismatch() {
757 let spec_kv = "stage=http_post:https://example.com\n";
758 let credential = sign_credential(
759 "sha256:deadbeef",
760 &["execute_side_effects", "http_post"],
761 "prod",
762 );
763
764 let error = resolve_runtime_approval(
765 spec_kv,
766 Environment::Prod,
767 Some(credential.as_str()),
768 &[trusted_key()],
769 )
770 .expect_err("credential should be rejected");
771
772 assert_eq!(error, ApprovalVerificationError::SpecHashMismatch);
773 }
774
775 #[test]
776 fn rejects_approval_with_environment_mismatch() {
777 let spec_kv = "stage=http_post:https://example.com\n";
778 let spec_hash = approval_spec_hash_from_canonical_spec_kv(spec_kv);
779 let credential = sign_credential(&spec_hash, &["execute_side_effects", "http_post"], "staging");
780
781 let error = resolve_runtime_approval(
782 spec_kv,
783 Environment::Prod,
784 Some(credential.as_str()),
785 &[trusted_key()],
786 )
787 .expect_err("credential should be rejected");
788
789 assert_eq!(error, ApprovalVerificationError::EnvironmentMismatch);
790 }
791
792 #[test]
793 fn rejects_expired_approval_credential() {
794 let spec_kv = "stage=http_post:https://example.com\n";
795 let spec_hash = approval_spec_hash_from_canonical_spec_kv(spec_kv);
796 let credential = sign_payload(json!({
797 "v": 1,
798 "kid": "01JKEY000TEST0000000000000",
799 "approval_id": "01JAPR000TEST0000000000000",
800 "org_id": "01JORG000TEST0000000000000",
801 "project_id": "01JPROJ00TEST000000000000",
802 "environment_id": "01JENV000TEST000000000000",
803 "environment_posture": "prod",
804 "spec_hash": spec_hash,
805 "capabilities": ["execute_side_effects", "http_post"],
806 "issued_by": "01JUSER00TEST000000000000",
807 "issued_at": "2026-03-20T14:32:00Z",
808 "expires_at": "2020-03-21T14:32:00Z"
809 }));
810
811 let error = resolve_runtime_approval(
812 spec_kv,
813 Environment::Prod,
814 Some(credential.as_str()),
815 &[trusted_key()],
816 )
817 .expect_err("credential should be rejected");
818
819 assert_eq!(error, ApprovalVerificationError::Expired);
820 }
821
822 #[test]
823 fn rejects_approval_with_invalid_signature() {
824 let spec_kv = "stage=http_post:https://example.com\n";
825 let spec_hash = approval_spec_hash_from_canonical_spec_kv(spec_kv);
826 let credential = sign_credential(&spec_hash, &["execute_side_effects", "http_post"], "prod");
827 let (payload_b64, signature_b64) = credential.split_once('.').expect("credential parts");
828 let mut payload: serde_json::Value = serde_json::from_slice(
829 &URL_SAFE_NO_PAD.decode(payload_b64).expect("decode payload"),
830 )
831 .expect("payload json");
832 payload["environment_posture"] = json!("staging");
833 let tampered_payload = serde_json::to_vec(&payload).expect("tampered payload json");
834 let tampered_credential = format!(
835 "{}.{}",
836 URL_SAFE_NO_PAD.encode(tampered_payload),
837 signature_b64
838 );
839
840 let error = resolve_runtime_approval(
841 spec_kv,
842 Environment::Prod,
843 Some(tampered_credential.as_str()),
844 &[trusted_key()],
845 )
846 .expect_err("credential should be rejected");
847
848 assert_eq!(error, ApprovalVerificationError::InvalidSignature);
849 }
850
851 #[test]
852 fn reads_and_writes_trusted_key_cache_metadata() {
853 let temp_dir = std::env::temp_dir().join(format!(
854 "byteor-approval-cache-{}",
855 OffsetDateTime::now_utc().unix_timestamp_nanos()
856 ));
857 fs::create_dir_all(&temp_dir).expect("temp dir");
858 let cache_path = temp_dir.join("signing_keys.json");
859 let cache = TrustedApprovalKeyCache {
860 source: ApprovalTrustSource::Refreshed,
861 last_refresh_at: Some("2026-03-20T14:35:00Z".to_string()),
862 keys: vec![trusted_key()],
863 };
864
865 write_trusted_approval_key_cache(&cache_path, &cache).expect("write cache");
866 let loaded = load_trusted_approval_key_cache_from_file(&cache_path).expect("load cache");
867
868 assert_eq!(loaded.source, ApprovalTrustSource::Refreshed);
869 assert_eq!(
870 loaded.last_refresh_at.as_deref(),
871 Some("2026-03-20T14:35:00Z")
872 );
873 assert_eq!(loaded.keys.len(), 1);
874
875 fs::remove_dir_all(&temp_dir).ok();
876 }
877
878 #[test]
879 fn refreshes_trusted_keys_from_control_plane_and_persists_cache() {
880 let temp_dir = std::env::temp_dir().join(format!(
881 "byteor-approval-refresh-{}",
882 OffsetDateTime::now_utc().unix_timestamp_nanos()
883 ));
884 fs::create_dir_all(&temp_dir).expect("temp dir");
885 let spec_path = temp_dir.join("spec.kv");
886 fs::write(&spec_path, "stage=http_post:https://example.com\n").expect("write spec");
887
888 let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
889 let addr = listener.local_addr().expect("listener addr");
890 let response_key = trusted_key();
891 let response_body = serde_json::to_string(&json!({
892 "data": {
893 "keys": [response_key]
894 }
895 }))
896 .expect("response json");
897
898 let server = std::thread::spawn(move || {
899 let (mut stream, _) = listener.accept().expect("accept connection");
900 let mut request = [0_u8; 4096];
901 let bytes_read = stream.read(&mut request).expect("read request");
902 let request_text = String::from_utf8_lossy(&request[..bytes_read]);
903 assert!(request_text.contains("GET /api/v1/agents/01JAGENTTEST/signing-keys HTTP/1.1"));
904 assert!(request_text.contains("authorization: Bearer byteor_ak_test"));
905
906 let response = format!(
907 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
908 response_body.len(),
909 response_body
910 );
911 stream
912 .write_all(response.as_bytes())
913 .expect("write response");
914 });
915
916 let bootstrap = ApprovalRefreshBootstrap {
917 api_base_url: format!("http://{addr}"),
918 agent_id: "01JAGENTTEST".to_string(),
919 agent_api_key: "byteor_ak_test".to_string(),
920 };
921
922 let cache = refresh_runtime_trusted_approval_key_cache(&spec_path, &[], &bootstrap)
923 .expect("refresh cache");
924
925 assert_eq!(cache.source, ApprovalTrustSource::Refreshed);
926 assert!(cache.last_refresh_at.is_some());
927 assert_eq!(cache.keys.len(), 1);
928
929 let persisted =
930 load_trusted_approval_key_cache_from_file(&temp_dir.join("signing_keys.json"))
931 .expect("load persisted cache");
932 assert_eq!(persisted.source, ApprovalTrustSource::Refreshed);
933 assert_eq!(persisted.keys.len(), 1);
934
935 server.join().expect("server thread");
936 fs::remove_dir_all(&temp_dir).ok();
937 }
938
939 #[test]
940 fn falls_back_to_cached_keys_when_online_refresh_fails() {
941 let temp_dir = std::env::temp_dir().join(format!(
942 "byteor-approval-refresh-fallback-{}",
943 OffsetDateTime::now_utc().unix_timestamp_nanos()
944 ));
945 fs::create_dir_all(&temp_dir).expect("temp dir");
946 let spec_path = temp_dir.join("spec.kv");
947 fs::write(&spec_path, "stage=http_post:https://example.com\n").expect("write spec");
948 write_trusted_approval_key_cache(
949 &temp_dir.join("signing_keys.json"),
950 &TrustedApprovalKeyCache {
951 source: ApprovalTrustSource::Refreshed,
952 last_refresh_at: Some("2026-03-20T14:35:00Z".to_string()),
953 keys: vec![trusted_key()],
954 },
955 )
956 .expect("write cached key set");
957
958 let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
959 let addr = listener.local_addr().expect("listener addr");
960 let server = std::thread::spawn(move || {
961 let (mut stream, _) = listener.accept().expect("accept connection");
962 let mut request = [0_u8; 2048];
963 let _ = stream.read(&mut request).expect("read request");
964 stream
965 .write_all(
966 b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
967 )
968 .expect("write response");
969 });
970
971 let bootstrap = ApprovalRefreshBootstrap {
972 api_base_url: format!("http://{addr}"),
973 agent_id: "01JAGENTTEST".to_string(),
974 agent_api_key: "byteor_ak_test".to_string(),
975 };
976
977 let cache = load_runtime_trusted_approval_key_cache(&spec_path, &[], Some(&bootstrap))
978 .expect("load cached fallback");
979
980 assert_eq!(cache.source, ApprovalTrustSource::Cached);
981 assert_eq!(
982 cache.last_refresh_at.as_deref(),
983 Some("2026-03-20T14:35:00Z")
984 );
985 assert_eq!(cache.keys.len(), 1);
986
987 server.join().expect("server thread");
988 fs::remove_dir_all(&temp_dir).ok();
989 }
990}