byteor_app_kit/
approval.rs

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/// Derived approval state for a runtime spec after local credential verification.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ApprovalResolution {
15    /// Whether the runtime may treat approval as present for policy evaluation.
16    pub approval_token_present: bool,
17    /// Capabilities inferred from the canonical spec that must be covered by the credential.
18    pub required_capabilities: Vec<String>,
19    /// Canonical `sha256:<hex>` digest of the runtime spec used for verification.
20    pub spec_hash: String,
21}
22
23/// Source of the trusted signing keys currently available to the runtime.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ApprovalTrustSource {
27    /// The key set was refreshed while the control plane was reachable.
28    Refreshed,
29    /// The runtime is using its last locally cached key set.
30    Cached,
31}
32
33/// A locally persisted trusted key set plus minimal refresh metadata.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct TrustedApprovalKeyCache {
36    /// Source classification for the current key set.
37    pub source: ApprovalTrustSource,
38    /// When the key set was most recently refreshed online.
39    pub last_refresh_at: Option<String>,
40    /// Trusted signing keys available to the runtime.
41    pub keys: Vec<TrustedApprovalKey>,
42}
43
44/// Trusted approval signing key material accepted by enterprise runtimes.
45#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
46pub struct TrustedApprovalKey {
47    /// Stable key identifier announced by the control plane.
48    pub kid: String,
49    /// Signature algorithm name, currently `Ed25519`.
50    pub algorithm: String,
51    /// Public verification key in PEM/SPKI format.
52    pub public_key_pem: String,
53}
54
55/// Control-plane bootstrap required to refresh trusted approval keys online.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ApprovalRefreshBootstrap {
58    /// Base URL for the control plane API, for example `https://control.example.com`.
59    pub api_base_url: String,
60    /// Agent ULID used for agent-scoped API requests.
61    pub agent_id: String,
62    /// Per-agent API key used for bearer authentication.
63    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/// Errors returned when an approval credential cannot be verified locally.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum ApprovalVerificationError {
102    /// No trusted signing keys were provided for credential verification.
103    MissingKeySet,
104    /// The credential was not formatted as `<payload>.<signature>`.
105    InvalidFormat,
106    /// The payload or signature segment was not valid base64url.
107    InvalidBase64,
108    /// The decoded payload was not valid approval credential JSON.
109    InvalidPayloadJson,
110    /// The credential version is not supported by this runtime.
111    UnsupportedVersion(u32),
112    /// The credential referenced a key id outside the trusted key set.
113    UnknownKeyId(String),
114    /// The trusted key advertised an unsupported signing algorithm.
115    UnsupportedAlgorithm(String),
116    /// The trusted key PEM could not be parsed into an Ed25519 verifying key.
117    InvalidPublicKeyPem(String),
118    /// The Ed25519 signature did not validate against the payload bytes.
119    InvalidSignature,
120    /// The credential's spec hash did not match the runtime spec hash.
121    SpecHashMismatch,
122    /// The credential's environment posture did not match the runtime environment.
123    EnvironmentMismatch,
124    /// The credential expiry time has passed.
125    Expired,
126    /// The credential did not cover every capability required by the runtime spec.
127    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
172/// Resolve runtime approval presence by verifying an optional credential against the
173/// canonical spec hash, target environment, and required side-effect capabilities.
174pub 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
204/// Load trusted approval signing keys from one or more json files.
205pub 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
215/// Return default signing-key file locations associated with a spec path.
216pub 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
227/// Return the default local cache path used for trusted signing keys next to a spec file.
228pub fn default_trusted_approval_key_cache_path(spec_path: &Path) -> Option<PathBuf> {
229    Some(spec_path.parent()?.join("signing_keys.json"))
230}
231
232/// Return the default local path used for a bundle-adjacent approval credential.
233pub fn default_approval_credential_path(spec_path: &Path) -> Option<PathBuf> {
234    Some(spec_path.parent()?.join("approval.cred"))
235}
236
237/// Load an approval credential from an extracted deploy bundle when present.
238pub 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
256/// Load trusted approval signing keys from a json file.
257pub 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
263/// Load a trusted approval key cache envelope from disk.
264pub 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
312/// Load trusted approval signing keys from one or more local cache files.
313pub 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
349/// Persist a trusted approval key cache envelope to disk.
350pub 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
364/// Read approval-refresh bootstrap configuration from the runtime environment.
365pub 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
398/// Load trusted approval keys for runtime startup, refreshing from the control plane when possible.
399pub 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
419/// Refresh trusted approval keys from the control plane and persist them into the local runtime cache.
420pub 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
432/// Derive the approval capabilities required by a canonical spec.
433pub 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
449/// Compute the canonical `sha256:<hex>` digest expected inside approval credentials.
450pub 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
456/// Extract the credential expiry timestamp from an approval credential payload.
457pub 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}