diff --git a/code-rs/Cargo.lock b/code-rs/Cargo.lock index 664be79ab6..f2d66d0c2c 100644 --- a/code-rs/Cargo.lock +++ b/code-rs/Cargo.lock @@ -1745,6 +1745,23 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" +[[package]] +name = "code-agent-run-provenance" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "fslock", + "pretty_assertions", + "rand 0.9.3", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.18", + "url", +] + [[package]] name = "codex-agent-graph-store" version = "0.0.0" diff --git a/code-rs/Cargo.toml b/code-rs/Cargo.toml index dd1ce32f9d..af6a553543 100644 --- a/code-rs/Cargo.toml +++ b/code-rs/Cargo.toml @@ -4,6 +4,7 @@ members = [ "analytics", "agent-graph-store", "agent-identity", + "code-agent-run-provenance", "backend-client", "builtin-mcps", "bwrap", @@ -126,6 +127,7 @@ app_test_support = { path = "app-server/tests/common" } codex-analytics = { path = "analytics" } codex-agent-graph-store = { path = "agent-graph-store" } codex-agent-identity = { path = "agent-identity" } +code-agent-run-provenance = { path = "code-agent-run-provenance" } codex-ansi-escape = { path = "ansi-escape" } codex-api = { path = "codex-api" } codex-aws-auth = { path = "aws-auth" } @@ -276,6 +278,7 @@ env-flags = "0.1.1" env_logger = "0.11.9" eventsource-stream = "0.2.3" flate2 = "1.1.8" +fslock = "0.2.1" futures = { version = "0.3", default-features = false } gethostname = "1.1.0" gix = { version = "0.81.0", default-features = false, features = ["sha1"] } diff --git a/code-rs/code-agent-run-provenance/BUILD.bazel b/code-rs/code-agent-run-provenance/BUILD.bazel new file mode 100644 index 0000000000..9b3326787d --- /dev/null +++ b/code-rs/code-agent-run-provenance/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "code-agent-run-provenance", + crate_name = "code_agent_run_provenance", +) diff --git a/code-rs/code-agent-run-provenance/Cargo.toml b/code-rs/code-agent-run-provenance/Cargo.toml new file mode 100644 index 0000000000..e5abb1d151 --- /dev/null +++ b/code-rs/code-agent-run-provenance/Cargo.toml @@ -0,0 +1,28 @@ +[package] +edition.workspace = true +license.workspace = true +name = "code-agent-run-provenance" +version.workspace = true + +[lib] +doctest = false +name = "code_agent_run_provenance" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +base64 = { workspace = true } +ed25519-dalek = { workspace = true } +fslock = { workspace = true } +rand = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/code-rs/code-agent-run-provenance/src/lib.rs b/code-rs/code-agent-run-provenance/src/lib.rs new file mode 100644 index 0000000000..eeb758b785 --- /dev/null +++ b/code-rs/code-agent-run-provenance/src/lib.rs @@ -0,0 +1,1834 @@ +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::fmt; +use std::fs::OpenOptions; +use std::io::Read; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ed25519_dalek::Signature; +use ed25519_dalek::Signer as _; +use ed25519_dalek::SigningKey; +use ed25519_dalek::Verifier as _; +use ed25519_dalek::VerifyingKey; +use ed25519_dalek::pkcs8::DecodePrivateKey; +use ed25519_dalek::pkcs8::EncodePrivateKey; +use fslock::LockFile; +use rand::TryRngCore; +use rand::rngs::OsRng; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use sha2::Digest as _; +use sha2::Sha256; +use thiserror::Error; +use url::Url; + +pub const CHALLENGE_VERSION: &str = "every-code.agent-run-challenge.v1"; +pub const CLAIMS_VERSION: &str = "every-code.agent-run-provenance.v1"; +pub const TRANSCRIPT_FORMAT: &str = "every-code.transcript-bytes.v1"; +pub const MAX_VERIFICATION_LEEWAY_SECONDS: u64 = 300; + +#[derive(Debug, Error)] +pub enum ProvenanceError { + #[error("invalid {0}")] + InvalidField(&'static str), + #[error("challenge is not active")] + ChallengeNotActive, + #[error("challenge constraint does not match runtime-observed {0}")] + ChallengeMismatch(&'static str), + #[error("invalid compact JWS")] + InvalidCompactJws, + #[error("unsupported compact JWS header")] + UnsupportedJwsHeader, + #[error("JWS key id is not trusted")] + UntrustedKey, + #[error("JWS signature verification failed")] + InvalidSignature, + #[error("token issuer does not match")] + IssuerMismatch, + #[error("token audience does not match")] + AudienceMismatch, + #[error("token is not yet valid")] + TokenNotYetValid, + #[error("token has expired")] + TokenExpired, + #[error("token was issued in the future")] + TokenIssuedInFuture, + #[error("token nonce does not match the challenge")] + NonceMismatch, + #[error("token challenge digest does not match")] + ChallengeDigestMismatch, + #[error("token model family does not match the runtime model slug")] + ModelFamilyMismatch, + #[error("issuance replay rejected")] + Replay, + #[error("run role conflict rejected")] + RoleConflict, + #[error("invalid issuance ledger")] + InvalidLedger, + #[error("JSON processing failed")] + Json(#[from] serde_json::Error), + #[error("I/O operation failed")] + Io(#[from] std::io::Error), +} + +pub type Result = std::result::Result; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RunRole { + Implementer, + Reviewer, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ChallengeTarget { + pub repository: String, + pub pull_request: Option, + pub head_sha: String, + pub tree_sha: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +/// A verifier-issued constraint set. +/// +/// The challenge digest binds these values into the provenance token, but the +/// challenge is not self-authenticating. Issuers must receive it over an +/// authenticated channel, and verifiers must compare against the original +/// challenge from their own trusted storage. +pub struct AgentRunChallenge { + pub version: String, + pub challenge_id: String, + pub audience: String, + pub nonce: String, + pub issued_at: u64, + pub not_before: u64, + pub expires_at: u64, + pub role: RunRole, + pub run_id: String, + pub target: ChallengeTarget, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RunIdentity { + pub run_id: String, + pub session_id: String, + pub root_thread_id: String, + pub thread_id: String, + pub turn_id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeModelIdentity { + pub provider: String, + pub model: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ModelClaims { + pub provider: String, + pub model: String, + pub family: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct TaskIdentity { + pub kind: String, + pub id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeIdentity { + pub runtime_id: String, + pub harness: String, + pub version: String, + pub operating_system: String, + pub architecture: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct TranscriptEvidence { + pub format: String, + pub sha256: String, + pub bytes: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct AgentRunClaims { + pub version: String, + pub iss: String, + pub aud: String, + pub kid: String, + pub jti: String, + pub iat: u64, + pub nbf: u64, + pub exp: u64, + pub nonce: String, + pub challenge_sha256: String, + pub role: RunRole, + pub target: ChallengeTarget, + pub run: RunIdentity, + pub model: ModelClaims, + pub task: TaskIdentity, + pub transcript: TranscriptEvidence, + pub runtime: RuntimeIdentity, +} + +#[derive(Clone)] +pub struct ObservedRepository { + remote_url: String, + pub pull_request: Option, + pub head_sha: String, + pub tree_sha: String, +} + +impl ObservedRepository { + pub fn new( + remote_url: impl Into, + pull_request: Option, + head_sha: impl Into, + tree_sha: impl Into, + ) -> Self { + Self { + remote_url: remote_url.into(), + pull_request, + head_sha: head_sha.into(), + tree_sha: tree_sha.into(), + } + } + + fn normalized_target(&self) -> Result { + Ok(ChallengeTarget { + repository: normalize_remote_url(&self.remote_url)?, + pull_request: self.pull_request, + head_sha: self.head_sha.clone(), + tree_sha: self.tree_sha.clone(), + }) + } +} + +impl fmt::Debug for ObservedRepository { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ObservedRepository") + .field("remote_url", &"") + .field("pull_request", &self.pull_request) + .field("head_sha", &self.head_sha) + .field("tree_sha", &self.tree_sha) + .finish() + } +} + +#[derive(Clone, Debug)] +pub struct ObservedAgentRun { + pub role: RunRole, + pub repository: ObservedRepository, + pub run: RunIdentity, + pub model: RuntimeModelIdentity, + pub task: TaskIdentity, + pub transcript: TranscriptEvidence, + pub runtime: RuntimeIdentity, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IssueParameters { + pub issuer: String, + pub jti: String, + pub issued_at: u64, +} + +impl IssueParameters { + pub fn generate(issuer: impl Into, issued_at: u64) -> Result { + let mut bytes = [0u8; 16]; + OsRng + .try_fill_bytes(&mut bytes) + .map_err(|_| ProvenanceError::InvalidField("jti entropy"))?; + Ok(Self { + issuer: issuer.into(), + jti: URL_SAFE_NO_PAD.encode(bytes), + issued_at, + }) + } +} + +pub struct TranscriptHasher { + hasher: Sha256, + bytes: u64, +} + +impl TranscriptHasher { + pub fn new() -> Self { + let mut hasher = Sha256::new(); + hasher.update(TRANSCRIPT_FORMAT.as_bytes()); + hasher.update([0]); + Self { + hasher, + bytes: 0, + } + } + + pub fn update(&mut self, bytes: &[u8]) { + self.hasher.update(bytes); + self.bytes = self.bytes.saturating_add(bytes.len() as u64); + } + + pub fn finalize(self) -> TranscriptEvidence { + TranscriptEvidence { + format: TRANSCRIPT_FORMAT.to_string(), + sha256: hex_lower(&self.hasher.finalize()), + bytes: self.bytes, + } + } +} + +impl Default for TranscriptHasher { + fn default() -> Self { + Self::new() + } +} + +impl Write for TranscriptHasher { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + self.update(buffer); + Ok(buffer.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +pub struct ProvenanceSigningKey { + kid: String, + signing_key: SigningKey, +} + +impl fmt::Debug for ProvenanceSigningKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProvenanceSigningKey") + .field("kid", &self.kid) + .field("signing_key", &"") + .finish() + } +} + +impl ProvenanceSigningKey { + pub fn generate(kid: impl Into) -> Result { + let mut seed = [0u8; 32]; + OsRng + .try_fill_bytes(&mut seed) + .map_err(|_| ProvenanceError::InvalidField("signing key entropy"))?; + Self::from_seed(kid, seed) + } + + pub fn from_seed(kid: impl Into, seed: [u8; 32]) -> Result { + let kid = kid.into(); + require_nonempty(&kid, "key id")?; + Ok(Self { + kid, + signing_key: SigningKey::from_bytes(&seed), + }) + } + + pub fn from_pkcs8_der(kid: impl Into, bytes: &[u8]) -> Result { + let kid = kid.into(); + require_nonempty(&kid, "key id")?; + let signing_key = SigningKey::from_pkcs8_der(bytes) + .map_err(|_| ProvenanceError::InvalidField("PKCS#8 signing key"))?; + Ok(Self { kid, signing_key }) + } + + pub fn from_pkcs8_base64(kid: impl Into, encoded: &str) -> Result { + let bytes = BASE64_STANDARD + .decode(encoded) + .map_err(|_| ProvenanceError::InvalidField("base64 PKCS#8 signing key"))?; + Self::from_pkcs8_der(kid, &bytes) + } + + pub fn to_pkcs8_base64(&self) -> Result { + let document = self + .signing_key + .to_pkcs8_der() + .map_err(|_| ProvenanceError::InvalidField("PKCS#8 signing key"))?; + Ok(BASE64_STANDARD.encode(document.as_bytes())) + } + + pub fn kid(&self) -> &str { + &self.kid + } + + pub fn public_jwk(&self) -> Ed25519Jwk { + Ed25519Jwk { + kty: "OKP".to_string(), + crv: "Ed25519".to_string(), + key_use: Some("sig".to_string()), + alg: Some("EdDSA".to_string()), + key_ops: None, + kid: self.kid.clone(), + x: URL_SAFE_NO_PAD.encode(self.signing_key.verifying_key().as_bytes()), + } + } + + pub fn jwks(&self) -> Ed25519Jwks { + Ed25519Jwks { + keys: vec![self.public_jwk()], + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct Ed25519Jwks { + pub keys: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct Ed25519Jwk { + pub kty: String, + pub crv: String, + #[serde(default, rename = "use", skip_serializing_if = "Option::is_none")] + pub key_use: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_ops: Option>, + pub kid: String, + pub x: String, +} + +#[derive(Clone, Debug)] +pub struct VerificationContext<'a> { + pub issuer: &'a str, + pub audience: &'a str, + pub challenge: &'a AgentRunChallenge, + pub now: u64, + pub leeway_seconds: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct JwsHeader { + alg: String, + typ: String, + kid: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct LedgerEntry { + jti: String, + run_id: String, + role: RunRole, + issued_at: u64, + token_sha256: String, +} + +pub struct IssuanceLedger { + path: PathBuf, + lock_path: PathBuf, + entries: Vec, + jtis: BTreeSet, + runs: BTreeMap, +} + +impl IssuanceLedger { + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + OpenOptions::new().create(true).append(true).open(&path)?; + let lock_path = path.with_extension("lock"); + let mut lock = LockFile::open(&lock_path)?; + lock.lock()?; + let mut ledger = Self { + path, + lock_path, + entries: Vec::new(), + jtis: BTreeSet::new(), + runs: BTreeMap::new(), + }; + ledger.reload()?; + Ok(ledger) + } + + pub fn entries_len(&self) -> usize { + self.entries.len() + } + + fn load_entry(&mut self, entry: LedgerEntry) -> Result<()> { + if !self.jtis.insert(entry.jti.clone()) || self.runs.contains_key(&entry.run_id) { + return Err(ProvenanceError::InvalidLedger); + } + self.runs.insert(entry.run_id.clone(), entry.role); + self.entries.push(entry); + Ok(()) + } + + fn record(&mut self, claims: &AgentRunClaims, token: &str) -> Result<()> { + let mut lock = LockFile::open(&self.lock_path)?; + lock.lock()?; + self.reload()?; + if self.jtis.contains(&claims.jti) { + return Err(ProvenanceError::Replay); + } + if let Some(role) = self.runs.get(&claims.run.run_id) { + return if *role == claims.role { + Err(ProvenanceError::Replay) + } else { + Err(ProvenanceError::RoleConflict) + }; + } + + let entry = LedgerEntry { + jti: claims.jti.clone(), + run_id: claims.run.run_id.clone(), + role: claims.role, + issued_at: claims.iat, + token_sha256: sha256_hex(token.as_bytes()), + }; + let mut line = canonical_json_bytes(&entry)?; + line.push(b'\n'); + let mut file = OpenOptions::new().append(true).open(&self.path)?; + file.write_all(&line)?; + file.sync_data()?; + self.jtis.insert(entry.jti.clone()); + self.runs.insert(entry.run_id.clone(), entry.role); + self.entries.push(entry); + Ok(()) + } + + fn reload(&mut self) -> Result<()> { + let mut file = OpenOptions::new() + .read(true) + .write(true) + .open(&self.path)?; + let mut contents = Vec::new(); + file.read_to_end(&mut contents)?; + if !contents.is_empty() && !contents.ends_with(b"\n") { + let complete_len = contents + .iter() + .rposition(|byte| *byte == b'\n') + .map_or(0, |position| position + 1); + let final_fragment = &contents[complete_len..]; + if serde_json::from_slice::(final_fragment).is_ok() { + file.write_all(b"\n")?; + contents.push(b'\n'); + } else { + file.set_len(complete_len as u64)?; + contents.truncate(complete_len); + } + file.sync_data()?; + } + self.entries.clear(); + self.jtis.clear(); + self.runs.clear(); + for line in contents.split(|byte| *byte == b'\n') { + if line.iter().all(|byte| byte.is_ascii_whitespace()) { + continue; + } + let entry: LedgerEntry = + serde_json::from_slice(line).map_err(|_| ProvenanceError::InvalidLedger)?; + self.load_entry(entry)?; + } + Ok(()) + } +} + +pub fn issue_agent_run_provenance( + signing_key: &ProvenanceSigningKey, + challenge: &AgentRunChallenge, + observed: &ObservedAgentRun, + parameters: &IssueParameters, + ledger: &mut IssuanceLedger, +) -> Result<(AgentRunClaims, String)> { + let claims = build_claims(signing_key, challenge, observed, parameters)?; + let token = sign_claims(signing_key, &claims)?; + ledger.record(&claims, &token)?; + Ok((claims, token)) +} + +pub fn build_claims( + signing_key: &ProvenanceSigningKey, + challenge: &AgentRunChallenge, + observed: &ObservedAgentRun, + parameters: &IssueParameters, +) -> Result { + validate_challenge(challenge, parameters.issued_at, 0)?; + require_nonempty(¶meters.issuer, "issuer")?; + require_nonempty(¶meters.jti, "jti")?; + validate_observed(observed)?; + + let target = observed.repository.normalized_target()?; + if observed.role != challenge.role { + return Err(ProvenanceError::ChallengeMismatch("role")); + } + if observed.run.run_id != challenge.run_id { + return Err(ProvenanceError::ChallengeMismatch("run id")); + } + if target != challenge.target { + return Err(ProvenanceError::ChallengeMismatch("repository target")); + } + + Ok(AgentRunClaims { + version: CLAIMS_VERSION.to_string(), + iss: parameters.issuer.clone(), + aud: challenge.audience.clone(), + kid: signing_key.kid().to_string(), + jti: parameters.jti.clone(), + iat: parameters.issued_at, + nbf: challenge.not_before, + exp: challenge.expires_at, + nonce: challenge.nonce.clone(), + challenge_sha256: challenge_digest(challenge)?, + role: observed.role, + target, + run: observed.run.clone(), + model: ModelClaims { + provider: observed.model.provider.clone(), + model: observed.model.model.clone(), + family: derive_model_family(&observed.model.model)?, + }, + task: observed.task.clone(), + transcript: observed.transcript.clone(), + runtime: observed.runtime.clone(), + }) +} + +pub fn verify_agent_run_provenance( + token: &str, + jwks: &Ed25519Jwks, + context: &VerificationContext<'_>, +) -> Result { + let (header_segment, payload_segment, signature_segment) = split_compact_jws(token)?; + let header_bytes = URL_SAFE_NO_PAD + .decode(header_segment) + .map_err(|_| ProvenanceError::InvalidCompactJws)?; + let header: JwsHeader = serde_json::from_slice(&header_bytes) + .map_err(|_| ProvenanceError::InvalidCompactJws)?; + if header.alg != "EdDSA" || header.typ != "JWT" || header.kid.is_empty() { + return Err(ProvenanceError::UnsupportedJwsHeader); + } + + let matching_keys = jwks + .keys + .iter() + .filter(|candidate| candidate.kid == header.kid) + .collect::>(); + let [jwk] = matching_keys.as_slice() else { + return Err(ProvenanceError::UntrustedKey); + }; + let verifying_key = verifying_key_from_jwk(jwk)?; + let signature_bytes = URL_SAFE_NO_PAD + .decode(signature_segment) + .map_err(|_| ProvenanceError::InvalidCompactJws)?; + let signature = Signature::from_slice(&signature_bytes) + .map_err(|_| ProvenanceError::InvalidCompactJws)?; + verifying_key + .verify( + format!("{header_segment}.{payload_segment}").as_bytes(), + &signature, + ) + .map_err(|_| ProvenanceError::InvalidSignature)?; + + let payload_bytes = URL_SAFE_NO_PAD + .decode(payload_segment) + .map_err(|_| ProvenanceError::InvalidCompactJws)?; + let claims: AgentRunClaims = serde_json::from_slice(&payload_bytes) + .map_err(|_| ProvenanceError::InvalidCompactJws)?; + validate_verified_claims(&claims, &header, context)?; + Ok(claims) +} + +pub fn challenge_digest(challenge: &AgentRunChallenge) -> Result { + Ok(sha256_hex(&canonical_json_bytes(challenge)?)) +} + +pub fn normalize_remote_url(remote_url: &str) -> Result { + let remote_url = remote_url.trim(); + if remote_url.is_empty() { + return Err(ProvenanceError::InvalidField("repository remote")); + } + + if let Ok(url) = Url::parse(remote_url) { + if !matches!(url.scheme(), "http" | "https" | "ssh" | "git") { + return Err(ProvenanceError::InvalidField("repository remote")); + } + let host = url + .host_str() + .ok_or(ProvenanceError::InvalidField("repository remote"))? + .to_ascii_lowercase(); + let host = if host.contains(':') { + format!("[{host}]") + } else { + host + }; + let port = url.port().filter(|port| { + Some(*port) + != match url.scheme() { + "http" => Some(80), + "https" => Some(443), + "ssh" => Some(22), + "git" => Some(9418), + _ => None, + } + }); + let authority = match port { + Some(port) => format!("{host}:{port}"), + None => host, + }; + return normalized_repository(authority, url.path()); + } + + let without_user = remote_url + .rsplit_once('@') + .map_or(remote_url, |(_, remainder)| remainder); + let (host, path) = without_user + .split_once(':') + .ok_or(ProvenanceError::InvalidField("repository remote"))?; + if host.is_empty() || host.contains('/') || path.contains('\\') { + return Err(ProvenanceError::InvalidField("repository remote")); + } + normalized_repository(host.to_ascii_lowercase(), path) +} + +pub fn derive_model_family(runtime_model_slug: &str) -> Result { + let normalized = runtime_model_slug.trim().to_ascii_lowercase(); + require_nonempty(&normalized, "runtime model slug")?; + if runtime_model_slug.trim() != runtime_model_slug + || normalized.chars().any(char::is_whitespace) + || normalized.chars().any(char::is_control) + { + return Err(ProvenanceError::InvalidField("runtime model slug")); + } + let slug = normalized.rsplit('/').next().unwrap_or(&normalized); + let slug = slug.strip_prefix("code-").unwrap_or(slug); + require_nonempty(slug, "runtime model slug")?; + let parts = slug.split('-').collect::>(); + if parts.iter().any(|part| part.is_empty()) { + return Err(ProvenanceError::InvalidField("runtime model slug")); + } + + if parts.first() == Some(&"gpt") && parts.len() >= 2 { + return Ok(format!("gpt-{}", parts[1])); + } + if parts + .first() + .is_some_and(|part| { + part.len() > 1 + && part.starts_with('o') + && part[1..].chars().all(|character| character.is_ascii_digit()) + }) + { + return Ok(parts[0].to_string()); + } + if parts.first() == Some(&"gemini") && parts.len() >= 2 { + return Ok(format!("gemini-{}", parts[1])); + } + if parts.first() == Some(&"claude") && parts.len() >= 3 { + return Ok(parts[..3].join("-")); + } + + const VARIANT_SUFFIXES: &[&str] = &[ + "chat", "instruct", "latest", "mini", "nano", "preview", "pro", + ]; + if parts.len() > 1 && VARIANT_SUFFIXES.contains(&parts[parts.len() - 1]) { + return Ok(parts[..parts.len() - 1].join("-")); + } + Ok(slug.to_string()) +} + +pub fn canonical_json_bytes(value: &T) -> Result> { + let value = serde_json::to_value(value)?; + let mut output = Vec::new(); + write_canonical_json(&value, &mut output)?; + Ok(output) +} + +fn validate_challenge( + challenge: &AgentRunChallenge, + now: u64, + leeway_seconds: u64, +) -> Result<()> { + validate_leeway(leeway_seconds)?; + if challenge.version != CHALLENGE_VERSION { + return Err(ProvenanceError::InvalidField("challenge version")); + } + require_nonempty(&challenge.challenge_id, "challenge id")?; + require_nonempty(&challenge.audience, "audience")?; + require_nonempty(&challenge.nonce, "nonce")?; + require_nonempty(&challenge.run_id, "run id")?; + validate_target(&challenge.target)?; + if challenge.issued_at > challenge.not_before + || challenge.not_before > challenge.expires_at + { + return Err(ProvenanceError::ChallengeNotActive); + } + if is_before_with_leeway(now, challenge.not_before, leeway_seconds) + || is_expired_with_leeway(now, challenge.expires_at, leeway_seconds) + { + return Err(ProvenanceError::ChallengeNotActive); + } + Ok(()) +} + +fn validate_observed(observed: &ObservedAgentRun) -> Result<()> { + validate_run_identity(&observed.run)?; + require_nonempty(&observed.model.provider, "runtime model provider")?; + require_nonempty(&observed.model.model, "runtime model slug")?; + require_nonempty(&observed.task.kind, "task kind")?; + require_nonempty(&observed.task.id, "task id")?; + require_nonempty(&observed.runtime.runtime_id, "runtime id")?; + require_nonempty(&observed.runtime.harness, "runtime harness")?; + require_nonempty(&observed.runtime.version, "runtime version")?; + require_nonempty(&observed.runtime.operating_system, "runtime operating system")?; + require_nonempty(&observed.runtime.architecture, "runtime architecture")?; + if observed.transcript.format != TRANSCRIPT_FORMAT { + return Err(ProvenanceError::InvalidField("transcript format")); + } + validate_digest(&observed.transcript.sha256, "transcript digest")?; + let target = observed.repository.normalized_target()?; + validate_target(&target) +} + +fn validate_verified_claims( + claims: &AgentRunClaims, + header: &JwsHeader, + context: &VerificationContext<'_>, +) -> Result<()> { + validate_leeway(context.leeway_seconds)?; + validate_challenge(context.challenge, context.now, 0)?; + if claims.version != CLAIMS_VERSION { + return Err(ProvenanceError::InvalidField("claims version")); + } + if claims.kid != header.kid { + return Err(ProvenanceError::UntrustedKey); + } + if claims.iss != context.issuer { + return Err(ProvenanceError::IssuerMismatch); + } + if claims.aud != context.audience || claims.aud != context.challenge.audience { + return Err(ProvenanceError::AudienceMismatch); + } + require_nonempty(&claims.jti, "jti")?; + if is_before_with_leeway(context.now, claims.iat, context.leeway_seconds) { + return Err(ProvenanceError::TokenIssuedInFuture); + } + if is_before_with_leeway(context.now, claims.nbf, context.leeway_seconds) { + return Err(ProvenanceError::TokenNotYetValid); + } + if is_expired_with_leeway(context.now, claims.exp, context.leeway_seconds) { + return Err(ProvenanceError::TokenExpired); + } + if claims.iat < context.challenge.issued_at + || claims.iat < claims.nbf + || claims.iat >= claims.exp + { + return Err(ProvenanceError::InvalidField("token issued-at time")); + } + if claims.nbf != context.challenge.not_before || claims.exp != context.challenge.expires_at { + return Err(ProvenanceError::ChallengeMismatch("validity window")); + } + if claims.nonce != context.challenge.nonce { + return Err(ProvenanceError::NonceMismatch); + } + if claims.challenge_sha256 != challenge_digest(context.challenge)? { + return Err(ProvenanceError::ChallengeDigestMismatch); + } + if claims.role != context.challenge.role { + return Err(ProvenanceError::ChallengeMismatch("role")); + } + if claims.run.run_id != context.challenge.run_id { + return Err(ProvenanceError::ChallengeMismatch("run id")); + } + if claims.target != context.challenge.target { + return Err(ProvenanceError::ChallengeMismatch("repository target")); + } + validate_target(&claims.target)?; + validate_run_identity(&claims.run)?; + require_nonempty(&claims.model.provider, "runtime model provider")?; + if claims.model.family != derive_model_family(&claims.model.model)? { + return Err(ProvenanceError::ModelFamilyMismatch); + } + require_nonempty(&claims.task.kind, "task kind")?; + require_nonempty(&claims.task.id, "task id")?; + if claims.transcript.format != TRANSCRIPT_FORMAT { + return Err(ProvenanceError::InvalidField("transcript format")); + } + validate_digest(&claims.transcript.sha256, "transcript digest")?; + require_nonempty(&claims.runtime.runtime_id, "runtime id")?; + require_nonempty(&claims.runtime.harness, "runtime harness")?; + require_nonempty(&claims.runtime.version, "runtime version")?; + require_nonempty(&claims.runtime.operating_system, "runtime operating system")?; + require_nonempty(&claims.runtime.architecture, "runtime architecture")?; + Ok(()) +} + +fn validate_target(target: &ChallengeTarget) -> Result<()> { + require_nonempty(&target.repository, "repository")?; + let reparsed = normalize_remote_url(&format!("ssh://{}", target.repository))?; + if reparsed != target.repository { + return Err(ProvenanceError::InvalidField("normalized repository")); + } + if target.pull_request == Some(0) { + return Err(ProvenanceError::InvalidField("pull request")); + } + validate_git_sha(&target.head_sha, "head SHA")?; + validate_git_sha(&target.tree_sha, "tree SHA") +} + +fn validate_run_identity(run: &RunIdentity) -> Result<()> { + require_nonempty(&run.run_id, "run id")?; + require_nonempty(&run.session_id, "session id")?; + require_nonempty(&run.root_thread_id, "root thread id")?; + require_nonempty(&run.thread_id, "thread id")?; + require_nonempty(&run.turn_id, "turn id") +} + +fn validate_git_sha(value: &str, field: &'static str) -> Result<()> { + if !matches!(value.len(), 40 | 64) + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ProvenanceError::InvalidField(field)); + } + Ok(()) +} + +fn validate_digest(value: &str, field: &'static str) -> Result<()> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ProvenanceError::InvalidField(field)); + } + Ok(()) +} + +fn validate_leeway(leeway_seconds: u64) -> Result<()> { + if leeway_seconds > MAX_VERIFICATION_LEEWAY_SECONDS { + return Err(ProvenanceError::InvalidField("verification leeway")); + } + Ok(()) +} + +fn is_before_with_leeway(now: u64, timestamp: u64, leeway_seconds: u64) -> bool { + timestamp > now && timestamp - now > leeway_seconds +} + +fn is_expired_with_leeway(now: u64, expires_at: u64, leeway_seconds: u64) -> bool { + now >= expires_at && now - expires_at >= leeway_seconds +} + +fn require_nonempty(value: &str, field: &'static str) -> Result<()> { + if value.trim().is_empty() { + return Err(ProvenanceError::InvalidField(field)); + } + Ok(()) +} + +fn normalized_repository(host: String, path: &str) -> Result { + let mut path = path.trim_matches('/'); + if let Some(stripped) = path.strip_suffix(".git") { + path = stripped; + } + if host.is_empty() + || path.is_empty() + || path.contains('@') + || path.contains('?') + || path.contains('#') + { + return Err(ProvenanceError::InvalidField("repository remote")); + } + Ok(format!("{host}/{path}")) +} + +fn sign_claims(signing_key: &ProvenanceSigningKey, claims: &AgentRunClaims) -> Result { + let header = JwsHeader { + alg: "EdDSA".to_string(), + typ: "JWT".to_string(), + kid: signing_key.kid.clone(), + }; + let header = URL_SAFE_NO_PAD.encode(canonical_json_bytes(&header)?); + let payload = URL_SAFE_NO_PAD.encode(canonical_json_bytes(claims)?); + let signing_input = format!("{header}.{payload}"); + let signature = signing_key.signing_key.sign(signing_input.as_bytes()); + Ok(format!( + "{signing_input}.{}", + URL_SAFE_NO_PAD.encode(signature.to_bytes()) + )) +} + +fn split_compact_jws(token: &str) -> Result<(&str, &str, &str)> { + let mut parts = token.split('.'); + let segments = match (parts.next(), parts.next(), parts.next()) { + (Some(header), Some(payload), Some(signature)) + if !header.is_empty() && !payload.is_empty() && !signature.is_empty() => + { + (header, payload, signature) + } + _ => return Err(ProvenanceError::InvalidCompactJws), + }; + if parts.next().is_some() { + return Err(ProvenanceError::InvalidCompactJws); + } + Ok(segments) +} + +fn verifying_key_from_jwk(jwk: &Ed25519Jwk) -> Result { + if jwk.kty != "OKP" + || jwk.crv != "Ed25519" + || jwk + .key_use + .as_deref() + .is_some_and(|key_use| key_use != "sig") + || jwk.alg.as_deref().is_some_and(|alg| alg != "EdDSA") + || jwk.key_ops.as_ref().is_some_and(|key_ops| { + !key_ops.iter().any(|operation| operation == "verify") + }) + { + return Err(ProvenanceError::UntrustedKey); + } + let bytes = URL_SAFE_NO_PAD + .decode(&jwk.x) + .map_err(|_| ProvenanceError::UntrustedKey)?; + let bytes: [u8; 32] = bytes + .try_into() + .map_err(|_| ProvenanceError::UntrustedKey)?; + VerifyingKey::from_bytes(&bytes).map_err(|_| ProvenanceError::UntrustedKey) +} + +fn write_canonical_json(value: &Value, output: &mut Vec) -> Result<()> { + match value { + Value::Null => output.extend_from_slice(b"null"), + Value::Bool(true) => output.extend_from_slice(b"true"), + Value::Bool(false) => output.extend_from_slice(b"false"), + Value::Number(number) => output.extend_from_slice(number.to_string().as_bytes()), + Value::String(string) => output.extend_from_slice(serde_json::to_string(string)?.as_bytes()), + Value::Array(values) => { + output.push(b'['); + for (index, child) in values.iter().enumerate() { + if index > 0 { + output.push(b','); + } + write_canonical_json(child, output)?; + } + output.push(b']'); + } + Value::Object(values) => { + output.push(b'{'); + let mut entries = values.iter().collect::>(); + entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right)); + for (index, (key, child)) in entries.into_iter().enumerate() { + if index > 0 { + output.push(b','); + } + output.extend_from_slice(serde_json::to_string(key)?.as_bytes()); + output.push(b':'); + write_canonical_json(child, output)?; + } + output.push(b'}'); + } + } + Ok(()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex_lower(&Sha256::digest(bytes)) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +#[cfg(test)] +mod tests { + use std::fs; + + use pretty_assertions::assert_eq; + use serde::Deserialize; + use serde::Serialize; + use tempfile::TempDir; + + use super::*; + + const FIXED_CLOCK: u64 = 1_785_931_200; + const FIXED_ISSUER: &str = "https://code.every.dev/agent-run-provenance"; + const FIXED_KID: &str = "every-code-test-2026-08-05"; + + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] + struct GoldenVerification { + issuer: String, + audience: String, + now: u64, + leeway_seconds: u64, + } + + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] + struct GoldenNegativeCase { + name: String, + token: String, + verify_at: u64, + expected_error: String, + } + + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] + struct GoldenVector { + schema: String, + fixed_seed_sha256: String, + fixed_clock: u64, + jwks: Ed25519Jwks, + challenge: AgentRunChallenge, + canonical_challenge_json: String, + claims: AgentRunClaims, + canonical_claims_json: String, + token: String, + verification: GoldenVerification, + negative_cases: Vec, + } + + #[test] + fn signs_and_verifies_runtime_observed_claims() -> Result<()> { + let temp_dir = TempDir::new()?; + let mut ledger = IssuanceLedger::open(temp_dir.path().join("ledger.jsonl"))?; + let key = fixed_key()?; + let challenge = fixed_challenge(); + let observed = fixed_observed(); + let parameters = fixed_parameters("jti-round-trip"); + + let (claims, token) = issue_agent_run_provenance( + &key, + &challenge, + &observed, + ¶meters, + &mut ledger, + )?; + let verified = verify_agent_run_provenance( + &token, + &key.jwks(), + &fixed_verification_context(&challenge, FIXED_CLOCK), + )?; + + assert_eq!(verified, claims); + assert_eq!(verified.model.model, "code-gpt-5.6-sol"); + assert_eq!(verified.model.family, "gpt-5.6"); + assert_eq!(verified.target.repository, "github.com/cbusillo/code"); + assert!(!token.contains("oauth2")); + assert!(!token.contains("secret")); + assert_eq!(ledger.entries_len(), 1); + Ok(()) + } + + #[test] + fn challenge_cannot_override_runtime_run_role_or_target() -> Result<()> { + let key = fixed_key()?; + let mut challenge = fixed_challenge(); + let observed = fixed_observed(); + let parameters = fixed_parameters("jti-constraints"); + + challenge.run_id = "caller-selected-run".to_string(); + assert!(matches!( + build_claims(&key, &challenge, &observed, ¶meters), + Err(ProvenanceError::ChallengeMismatch("run id")) + )); + + challenge = fixed_challenge(); + challenge.role = RunRole::Reviewer; + assert!(matches!( + build_claims(&key, &challenge, &observed, ¶meters), + Err(ProvenanceError::ChallengeMismatch("role")) + )); + + challenge = fixed_challenge(); + challenge.target.head_sha = "c".repeat(40); + assert!(matches!( + build_claims(&key, &challenge, &observed, ¶meters), + Err(ProvenanceError::ChallengeMismatch("repository target")) + )); + + let mut challenge_json = serde_json::to_value(fixed_challenge())?; + challenge_json["model"] = Value::String("caller-model".to_string()); + assert!(serde_json::from_value::(challenge_json).is_err()); + Ok(()) + } + + #[test] + fn transcript_digest_streams_without_disclosure() -> Result<()> { + let mut streamed = TranscriptHasher::new(); + streamed.update(b"private "); + streamed.write_all(b"transcript")?; + + let mut contiguous = TranscriptHasher::new(); + contiguous.update(b"private transcript"); + + let evidence = streamed.finalize(); + assert_eq!(evidence, contiguous.finalize()); + assert_eq!(evidence.format, TRANSCRIPT_FORMAT); + assert_eq!(evidence.bytes, 18); + let serialized = serde_json::to_string(&evidence)?; + assert!(!serialized.contains("private")); + assert!(!serialized.contains("private transcript")); + Ok(()) + } + + #[test] + fn digest_validation_requires_canonical_lowercase_hex() { + assert!(validate_digest(&"a".repeat(64), "digest").is_ok()); + assert!(matches!( + validate_digest(&"A".repeat(64), "digest"), + Err(ProvenanceError::InvalidField("digest")) + )); + } + + #[test] + fn remote_normalization_removes_credentials_and_transport_details() -> Result<()> { + assert_eq!( + normalize_remote_url( + "https://oauth2:secret@GitHub.com/cbusillo/code.git?token=hidden#fragment" + )?, + "github.com/cbusillo/code" + ); + assert_eq!( + normalize_remote_url("git@GitHub.com:cbusillo/code.git")?, + "github.com/cbusillo/code" + ); + assert_eq!( + normalize_remote_url("ssh://git@github.com:2222/cbusillo/code.git")?, + "github.com:2222/cbusillo/code" + ); + assert_eq!( + normalize_remote_url("ssh://git@github.com:22/cbusillo/code.git")?, + "github.com/cbusillo/code" + ); + assert_eq!( + normalize_remote_url("https://github.com:443/cbusillo/code.git")?, + "github.com/cbusillo/code" + ); + + let observed = ObservedRepository::new( + "https://oauth2:secret@github.com/cbusillo/code.git", + Some(442), + "a".repeat(40), + "b".repeat(40), + ); + let debug = format!("{observed:?}"); + assert!(!debug.contains("oauth2")); + assert!(!debug.contains("secret")); + assert!(debug.contains("")); + Ok(()) + } + + #[test] + fn model_family_is_derived_from_runtime_slug() -> Result<()> { + assert_eq!(derive_model_family("code-gpt-5.6-sol")?, "gpt-5.6"); + assert_eq!(derive_model_family("openai/o3-mini")?, "o3"); + assert_eq!( + derive_model_family("anthropic/claude-sonnet-4.6")?, + "claude-sonnet-4.6" + ); + assert_eq!( + derive_model_family("google/gemini-3.1-pro")?, + "gemini-3.1" + ); + for invalid in ["openai/", "code-", "gpt--preview", " gpt-5.6", "gpt 5.6"] { + assert!(derive_model_family(invalid).is_err(), "{invalid}"); + } + Ok(()) + } + + #[test] + fn signing_keys_round_trip_through_pkcs8_and_jwks() -> Result<()> { + let key = fixed_key()?; + let encoded = key.to_pkcs8_base64()?; + let loaded = ProvenanceSigningKey::from_pkcs8_base64(FIXED_KID, &encoded)?; + assert_eq!(loaded.public_jwk(), key.public_jwk()); + assert!(!format!("{loaded:?}").contains(&encoded)); + + let generated = ProvenanceSigningKey::generate("generated-key")?; + assert_eq!(generated.public_jwk().x.len(), 43); + Ok(()) + } + + #[test] + fn ledger_rejects_replay_and_role_conflict_after_reopen() -> Result<()> { + let temp_dir = TempDir::new()?; + let ledger_path = temp_dir.path().join("ledger.jsonl"); + let key = fixed_key()?; + let challenge = fixed_challenge(); + let observed = fixed_observed(); + let mut ledger = IssuanceLedger::open(&ledger_path)?; + + issue_agent_run_provenance( + &key, + &challenge, + &observed, + &fixed_parameters("jti-first"), + &mut ledger, + )?; + assert!(matches!( + issue_agent_run_provenance( + &key, + &challenge, + &observed, + &fixed_parameters("jti-second"), + &mut ledger, + ), + Err(ProvenanceError::Replay) + )); + + let mut second_run_challenge = challenge.clone(); + second_run_challenge.run_id = "run-443-fixed".to_string(); + let mut second_run_observed = observed.clone(); + second_run_observed.run.run_id = "run-443-fixed".to_string(); + assert!(matches!( + issue_agent_run_provenance( + &key, + &second_run_challenge, + &second_run_observed, + &fixed_parameters("jti-first"), + &mut ledger, + ), + Err(ProvenanceError::Replay) + )); + + let mut reviewer_challenge = challenge.clone(); + reviewer_challenge.role = RunRole::Reviewer; + let mut reviewer_observed = observed.clone(); + reviewer_observed.role = RunRole::Reviewer; + assert!(matches!( + issue_agent_run_provenance( + &key, + &reviewer_challenge, + &reviewer_observed, + &fixed_parameters("jti-reviewer"), + &mut ledger, + ), + Err(ProvenanceError::RoleConflict) + )); + + drop(ledger); + let reopened = IssuanceLedger::open(&ledger_path)?; + assert_eq!(reopened.entries_len(), 1); + assert_eq!(fs::read_to_string(ledger_path)?.lines().count(), 1); + Ok(()) + } + + #[test] + fn ledger_discards_only_an_incomplete_final_write() -> Result<()> { + let temp_dir = TempDir::new()?; + let ledger_path = temp_dir.path().join("ledger.jsonl"); + let key = fixed_key()?; + let challenge = fixed_challenge(); + let observed = fixed_observed(); + let mut ledger = IssuanceLedger::open(&ledger_path)?; + issue_agent_run_provenance( + &key, + &challenge, + &observed, + &fixed_parameters("jti-complete"), + &mut ledger, + )?; + drop(ledger); + + let mut file = OpenOptions::new().append(true).open(&ledger_path)?; + file.write_all(br#"{"jti":"partial""#)?; + file.sync_data()?; + drop(file); + + let recovered = IssuanceLedger::open(&ledger_path)?; + assert_eq!(recovered.entries_len(), 1); + assert!(fs::read(&ledger_path)?.ends_with(b"\n")); + + fs::write(&ledger_path, b"not-json\n")?; + assert!(matches!( + IssuanceLedger::open(&ledger_path), + Err(ProvenanceError::InvalidLedger) + )); + Ok(()) + } + + #[test] + fn ledger_preserves_a_complete_final_entry_without_newline() -> Result<()> { + let temp_dir = TempDir::new()?; + let ledger_path = temp_dir.path().join("ledger.jsonl"); + let key = fixed_key()?; + let challenge = fixed_challenge(); + let observed = fixed_observed(); + let mut ledger = IssuanceLedger::open(&ledger_path)?; + issue_agent_run_provenance( + &key, + &challenge, + &observed, + &fixed_parameters("jti-no-newline"), + &mut ledger, + )?; + drop(ledger); + + let mut contents = fs::read(&ledger_path)?; + assert_eq!(contents.pop(), Some(b'\n')); + assert!(serde_json::from_slice::(&contents).is_ok()); + fs::write(&ledger_path, contents)?; + + let recovered = IssuanceLedger::open(&ledger_path)?; + assert_eq!(recovered.entries_len(), 1); + assert!(fs::read(&ledger_path)?.ends_with(b"\n")); + Ok(()) + } + + #[test] + fn ledger_serializes_concurrent_role_conflicts() -> Result<()> { + use std::sync::Arc; + use std::sync::Barrier; + use std::thread; + + let temp_dir = TempDir::new()?; + let ledger_path = temp_dir.path().join("concurrent-ledger.jsonl"); + let barrier = Arc::new(Barrier::new(2)); + let spawn_issuer = |role: RunRole, jti: &'static str| { + let barrier = Arc::clone(&barrier); + let ledger_path = ledger_path.clone(); + thread::spawn(move || -> Result<(AgentRunClaims, String)> { + let key = fixed_key()?; + let mut challenge = fixed_challenge(); + challenge.role = role; + let mut observed = fixed_observed(); + observed.role = role; + let mut ledger = IssuanceLedger::open(ledger_path)?; + barrier.wait(); + issue_agent_run_provenance( + &key, + &challenge, + &observed, + &fixed_parameters(jti), + &mut ledger, + ) + }) + }; + + let implementer = spawn_issuer(RunRole::Implementer, "jti-concurrent-implementer"); + let reviewer = spawn_issuer(RunRole::Reviewer, "jti-concurrent-reviewer"); + let implementer = implementer + .join() + .map_err(|_| ProvenanceError::InvalidField("implementer thread"))?; + let reviewer = reviewer + .join() + .map_err(|_| ProvenanceError::InvalidField("reviewer thread"))?; + + assert_eq!( + [implementer.is_ok(), reviewer.is_ok()] + .into_iter() + .filter(|accepted| *accepted) + .count(), + 1 + ); + assert_eq!( + [&implementer, &reviewer] + .into_iter() + .filter(|outcome| matches!(outcome, Err(ProvenanceError::RoleConflict))) + .count(), + 1 + ); + assert_eq!(IssuanceLedger::open(&ledger_path)?.entries_len(), 1); + Ok(()) + } + + #[test] + fn verifier_applies_leeway_and_accepts_standard_jwk_extensions() -> Result<()> { + let key = fixed_key()?; + let challenge = fixed_challenge(); + let claims = build_claims( + &key, + &challenge, + &fixed_observed(), + &fixed_parameters("jti-leeway"), + )?; + let token = sign_claims(&key, &claims)?; + let mut jwks_value = serde_json::to_value(key.jwks())?; + let jwk = jwks_value["keys"][0] + .as_object_mut() + .ok_or(ProvenanceError::InvalidField("test JWK"))?; + jwk.remove("use"); + jwk.remove("alg"); + jwk.insert("key_ops".to_string(), serde_json::json!(["verify"])); + jwk.insert( + "standard_extension".to_string(), + Value::String("accepted".to_string()), + ); + let jwks: Ed25519Jwks = serde_json::from_value(jwks_value)?; + let context = VerificationContext { + issuer: FIXED_ISSUER, + audience: "launchplane", + challenge: &challenge, + now: FIXED_CLOCK - 30, + leeway_seconds: 60, + }; + + assert_eq!(verify_agent_run_provenance(&token, &jwks, &context)?, claims); + Ok(()) + } + + #[test] + fn verifier_rejects_excessive_clock_leeway() -> Result<()> { + let key = fixed_key()?; + let challenge = fixed_challenge(); + let claims = build_claims( + &key, + &challenge, + &fixed_observed(), + &fixed_parameters("jti-excessive-leeway"), + )?; + let token = sign_claims(&key, &claims)?; + let context = VerificationContext { + issuer: FIXED_ISSUER, + audience: "launchplane", + challenge: &challenge, + now: u64::MAX, + leeway_seconds: u64::MAX, + }; + + assert!(matches!( + verify_agent_run_provenance(&token, &key.jwks(), &context), + Err(ProvenanceError::InvalidField("verification leeway")) + )); + Ok(()) + } + + #[test] + fn verifier_rejects_all_golden_negative_cases() -> Result<()> { + let vector = build_golden_vector()?; + for negative in &vector.negative_cases { + let context = fixed_verification_context(&vector.challenge, negative.verify_at); + let error = verify_agent_run_provenance(&negative.token, &vector.jwks, &context) + .err() + .ok_or(ProvenanceError::InvalidField("negative vector acceptance"))?; + assert_eq!(error_code(&error), negative.expected_error, "{}", negative.name); + } + Ok(()) + } + + #[test] + fn golden_vector_matches_fixture() -> Result<()> { + let vector = build_golden_vector()?; + let generated = format!("{}\n", serde_json::to_string_pretty(&vector)?); + if std::env::var_os("PRINT_AGENT_RUN_PROVENANCE_VECTOR").is_some() { + println!("BEGIN_AGENT_RUN_PROVENANCE_VECTOR\n{generated}END_AGENT_RUN_PROVENANCE_VECTOR"); + return Ok(()); + } + let fixture = include_str!("../tests/fixtures/agent_run_provenance_v1.json"); + assert_eq!(generated, fixture); + Ok(()) + } + + fn build_golden_vector() -> Result { + let key = fixed_key()?; + let challenge = fixed_challenge(); + let claims = build_claims( + &key, + &challenge, + &fixed_observed(), + &fixed_parameters("jti-fixed-2026-08-05"), + )?; + let token = sign_claims(&key, &claims)?; + let negative_cases = negative_cases(&key, &claims, &token)?; + Ok(GoldenVector { + schema: CLAIMS_VERSION.to_string(), + fixed_seed_sha256: sha256_hex(&[0x42; 32]), + fixed_clock: FIXED_CLOCK, + jwks: key.jwks(), + challenge: challenge.clone(), + canonical_challenge_json: String::from_utf8(canonical_json_bytes(&challenge)?) + .map_err(|_| ProvenanceError::InvalidField("canonical challenge UTF-8"))?, + claims: claims.clone(), + canonical_claims_json: String::from_utf8(canonical_json_bytes(&claims)?) + .map_err(|_| ProvenanceError::InvalidField("canonical claims UTF-8"))?, + token, + verification: GoldenVerification { + issuer: FIXED_ISSUER.to_string(), + audience: challenge.audience.clone(), + now: FIXED_CLOCK, + leeway_seconds: 0, + }, + negative_cases, + }) + } + + fn negative_cases( + key: &ProvenanceSigningKey, + claims: &AgentRunClaims, + token: &str, + ) -> Result> { + let mut cases = Vec::new(); + let mut add_signed = + |name: &str, expected_error: &str, mutated: AgentRunClaims, verify_at: u64| { + cases.push(GoldenNegativeCase { + name: name.to_string(), + token: sign_claims(key, &mutated)?, + verify_at, + expected_error: expected_error.to_string(), + }); + Result::Ok(()) + }; + + let mut mutated = claims.clone(); + mutated.iss = "https://invalid.example/issuer".to_string(); + add_signed("issuer_tampered", "issuer_mismatch", mutated, FIXED_CLOCK)?; + + let mut mutated = claims.clone(); + mutated.aud = "wrong-audience".to_string(); + add_signed("audience_tampered", "audience_mismatch", mutated, FIXED_CLOCK)?; + + let mut mutated = claims.clone(); + mutated.exp = FIXED_CLOCK; + add_signed("expired", "token_expired", mutated, FIXED_CLOCK)?; + + let mut mutated = claims.clone(); + mutated.iat = FIXED_CLOCK + 1; + add_signed( + "issued_in_future", + "token_issued_in_future", + mutated, + FIXED_CLOCK, + )?; + + let mut mutated = claims.clone(); + mutated.nbf = FIXED_CLOCK + 1; + add_signed( + "not_yet_valid", + "token_not_yet_valid", + mutated, + FIXED_CLOCK, + )?; + + let mut mutated = claims.clone(); + mutated.iat = claims.nbf - 1; + add_signed( + "issued_before_not_before", + "invalid_token_issued_at", + mutated, + FIXED_CLOCK, + )?; + + let mut mutated = claims.clone(); + mutated.nonce = "wrong-nonce".to_string(); + add_signed("nonce_tampered", "nonce_mismatch", mutated, FIXED_CLOCK)?; + + let mut mutated = claims.clone(); + mutated.challenge_sha256 = "0".repeat(64); + add_signed( + "challenge_digest_tampered", + "challenge_digest_mismatch", + mutated, + FIXED_CLOCK, + )?; + + let mut mutated = claims.clone(); + mutated.target.repository = "github.com/other/repository".to_string(); + add_signed( + "repository_tampered", + "challenge_target_mismatch", + mutated, + FIXED_CLOCK, + )?; + + let mut mutated = claims.clone(); + mutated.target.pull_request = Some(443); + add_signed( + "pull_request_tampered", + "challenge_target_mismatch", + mutated, + FIXED_CLOCK, + )?; + + let mut mutated = claims.clone(); + mutated.target.head_sha = "c".repeat(40); + add_signed( + "head_sha_tampered", + "challenge_target_mismatch", + mutated, + FIXED_CLOCK, + )?; + + let mut mutated = claims.clone(); + mutated.target.tree_sha = "d".repeat(40); + add_signed( + "tree_sha_tampered", + "challenge_target_mismatch", + mutated, + FIXED_CLOCK, + )?; + + let mut mutated = claims.clone(); + mutated.role = RunRole::Reviewer; + add_signed( + "role_tampered", + "challenge_role_mismatch", + mutated, + FIXED_CLOCK, + )?; + + let mut mutated = claims.clone(); + mutated.run.run_id = "run-caller-selected".to_string(); + add_signed( + "run_identity_tampered", + "challenge_run_mismatch", + mutated, + FIXED_CLOCK, + )?; + + let mut mutated = claims.clone(); + mutated.model.family = "gpt-4".to_string(); + add_signed( + "model_family_tampered", + "model_family_mismatch", + mutated, + FIXED_CLOCK, + )?; + + cases.push(GoldenNegativeCase { + name: "challenge_expired".to_string(), + token: token.to_string(), + verify_at: claims.exp, + expected_error: "challenge_not_active".to_string(), + }); + + let mut bad_signature = token.as_bytes().to_vec(); + if let Some(last) = bad_signature.last_mut() { + *last = if *last == b'A' { b'B' } else { b'A' }; + } + cases.push(GoldenNegativeCase { + name: "signature_tampered".to_string(), + token: String::from_utf8(bad_signature) + .map_err(|_| ProvenanceError::InvalidCompactJws)?, + verify_at: FIXED_CLOCK, + expected_error: "invalid_signature".to_string(), + }); + + Ok(cases) + } + + fn fixed_key() -> Result { + ProvenanceSigningKey::from_seed(FIXED_KID, [0x42; 32]) + } + + fn fixed_challenge() -> AgentRunChallenge { + AgentRunChallenge { + version: CHALLENGE_VERSION.to_string(), + challenge_id: "challenge-442-fixed".to_string(), + audience: "launchplane".to_string(), + nonce: "bm9uY2UtNDQyLWZpeGVk".to_string(), + issued_at: FIXED_CLOCK - 60, + not_before: FIXED_CLOCK - 60, + expires_at: FIXED_CLOCK + 600, + role: RunRole::Implementer, + run_id: "run-442-fixed".to_string(), + target: ChallengeTarget { + repository: "github.com/cbusillo/code".to_string(), + pull_request: Some(442), + head_sha: "a".repeat(40), + tree_sha: "b".repeat(40), + }, + } + } + + fn fixed_observed() -> ObservedAgentRun { + let mut transcript = TranscriptHasher::new(); + transcript.update(b"private transcript bytes are never disclosed"); + ObservedAgentRun { + role: RunRole::Implementer, + repository: ObservedRepository::new( + "https://oauth2:secret@github.com/cbusillo/code.git", + Some(442), + "a".repeat(40), + "b".repeat(40), + ), + run: RunIdentity { + run_id: "run-442-fixed".to_string(), + session_id: "session-442-fixed".to_string(), + root_thread_id: "root-thread-442-fixed".to_string(), + thread_id: "thread-442-fixed".to_string(), + turn_id: "turn-7".to_string(), + }, + model: RuntimeModelIdentity { + provider: "openai".to_string(), + model: "code-gpt-5.6-sol".to_string(), + }, + task: TaskIdentity { + kind: "github_issue".to_string(), + id: "cbusillo/code#442".to_string(), + }, + transcript: transcript.finalize(), + runtime: RuntimeIdentity { + runtime_id: "every-code-runtime-test".to_string(), + harness: "every-code-cli".to_string(), + version: "0.0.0-test".to_string(), + operating_system: "macos".to_string(), + architecture: "aarch64".to_string(), + }, + } + } + + fn fixed_parameters(jti: &str) -> IssueParameters { + IssueParameters { + issuer: FIXED_ISSUER.to_string(), + jti: jti.to_string(), + issued_at: FIXED_CLOCK, + } + } + + fn fixed_verification_context<'a>( + challenge: &'a AgentRunChallenge, + now: u64, + ) -> VerificationContext<'a> { + VerificationContext { + issuer: FIXED_ISSUER, + audience: "launchplane", + challenge, + now, + leeway_seconds: 0, + } + } + + fn error_code(error: &ProvenanceError) -> String { + match error { + ProvenanceError::InvalidSignature => "invalid_signature", + ProvenanceError::IssuerMismatch => "issuer_mismatch", + ProvenanceError::AudienceMismatch => "audience_mismatch", + ProvenanceError::ChallengeNotActive => "challenge_not_active", + ProvenanceError::TokenIssuedInFuture => "token_issued_in_future", + ProvenanceError::TokenNotYetValid => "token_not_yet_valid", + ProvenanceError::TokenExpired => "token_expired", + ProvenanceError::InvalidField("token issued-at time") => { + "invalid_token_issued_at" + } + ProvenanceError::NonceMismatch => "nonce_mismatch", + ProvenanceError::ChallengeDigestMismatch => "challenge_digest_mismatch", + ProvenanceError::ChallengeMismatch("repository target") => { + "challenge_target_mismatch" + } + ProvenanceError::ChallengeMismatch("role") => "challenge_role_mismatch", + ProvenanceError::ChallengeMismatch("run id") => "challenge_run_mismatch", + ProvenanceError::ModelFamilyMismatch => "model_family_mismatch", + _ => "unexpected_error", + } + .to_string() + } +} diff --git a/code-rs/code-agent-run-provenance/tests/fixtures/agent_run_provenance_v1.json b/code-rs/code-agent-run-provenance/tests/fixtures/agent_run_provenance_v1.json new file mode 100644 index 0000000000..d08c42dadd --- /dev/null +++ b/code-rs/code-agent-run-provenance/tests/fixtures/agent_run_provenance_v1.json @@ -0,0 +1,194 @@ +{ + "schema": "every-code.agent-run-provenance.v1", + "fixed_seed_sha256": "425ed4e4a36b30ea21b90e21c712c649e8214c29b7eaf68089d1039c6e55384c", + "fixed_clock": 1785931200, + "jwks": { + "keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "use": "sig", + "alg": "EdDSA", + "kid": "every-code-test-2026-08-05", + "x": "IVL40Zt5HSRFMkLhXy6rbLfP-ntqXtMAl5YOBpiB2xI" + } + ] + }, + "challenge": { + "version": "every-code.agent-run-challenge.v1", + "challenge_id": "challenge-442-fixed", + "audience": "launchplane", + "nonce": "bm9uY2UtNDQyLWZpeGVk", + "issued_at": 1785931140, + "not_before": 1785931140, + "expires_at": 1785931800, + "role": "implementer", + "run_id": "run-442-fixed", + "target": { + "repository": "github.com/cbusillo/code", + "pull_request": 442, + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "tree_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + }, + "canonical_challenge_json": "{\"audience\":\"launchplane\",\"challenge_id\":\"challenge-442-fixed\",\"expires_at\":1785931800,\"issued_at\":1785931140,\"nonce\":\"bm9uY2UtNDQyLWZpeGVk\",\"not_before\":1785931140,\"role\":\"implementer\",\"run_id\":\"run-442-fixed\",\"target\":{\"head_sha\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"pull_request\":442,\"repository\":\"github.com/cbusillo/code\",\"tree_sha\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"},\"version\":\"every-code.agent-run-challenge.v1\"}", + "claims": { + "version": "every-code.agent-run-provenance.v1", + "iss": "https://code.every.dev/agent-run-provenance", + "aud": "launchplane", + "kid": "every-code-test-2026-08-05", + "jti": "jti-fixed-2026-08-05", + "iat": 1785931200, + "nbf": 1785931140, + "exp": 1785931800, + "nonce": "bm9uY2UtNDQyLWZpeGVk", + "challenge_sha256": "3cdf09c76027a472f9ab8e5976a4941767ffe51be026533d3336a9ecbb99230c", + "role": "implementer", + "target": { + "repository": "github.com/cbusillo/code", + "pull_request": 442, + "head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "tree_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "run": { + "run_id": "run-442-fixed", + "session_id": "session-442-fixed", + "root_thread_id": "root-thread-442-fixed", + "thread_id": "thread-442-fixed", + "turn_id": "turn-7" + }, + "model": { + "provider": "openai", + "model": "code-gpt-5.6-sol", + "family": "gpt-5.6" + }, + "task": { + "kind": "github_issue", + "id": "cbusillo/code#442" + }, + "transcript": { + "format": "every-code.transcript-bytes.v1", + "sha256": "c9440f31c61bdbde6fe0e52bd1f48e5e0e851a089003d642bb136efe4325927c", + "bytes": 44 + }, + "runtime": { + "runtime_id": "every-code-runtime-test", + "harness": "every-code-cli", + "version": "0.0.0-test", + "operating_system": "macos", + "architecture": "aarch64" + } + }, + "canonical_claims_json": "{\"aud\":\"launchplane\",\"challenge_sha256\":\"3cdf09c76027a472f9ab8e5976a4941767ffe51be026533d3336a9ecbb99230c\",\"exp\":1785931800,\"iat\":1785931200,\"iss\":\"https://code.every.dev/agent-run-provenance\",\"jti\":\"jti-fixed-2026-08-05\",\"kid\":\"every-code-test-2026-08-05\",\"model\":{\"family\":\"gpt-5.6\",\"model\":\"code-gpt-5.6-sol\",\"provider\":\"openai\"},\"nbf\":1785931140,\"nonce\":\"bm9uY2UtNDQyLWZpeGVk\",\"role\":\"implementer\",\"run\":{\"root_thread_id\":\"root-thread-442-fixed\",\"run_id\":\"run-442-fixed\",\"session_id\":\"session-442-fixed\",\"thread_id\":\"thread-442-fixed\",\"turn_id\":\"turn-7\"},\"runtime\":{\"architecture\":\"aarch64\",\"harness\":\"every-code-cli\",\"operating_system\":\"macos\",\"runtime_id\":\"every-code-runtime-test\",\"version\":\"0.0.0-test\"},\"target\":{\"head_sha\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"pull_request\":442,\"repository\":\"github.com/cbusillo/code\",\"tree_sha\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"},\"task\":{\"id\":\"cbusillo/code#442\",\"kind\":\"github_issue\"},\"transcript\":{\"bytes\":44,\"format\":\"every-code.transcript-bytes.v1\",\"sha256\":\"c9440f31c61bdbde6fe0e52bd1f48e5e0e851a089003d642bb136efe4325927c\"},\"version\":\"every-code.agent-run-provenance.v1\"}", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTE0MCwibm9uY2UiOiJibTl1WTJVdE5EUXlMV1pwZUdWayIsInJvbGUiOiJpbXBsZW1lbnRlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi00NDItZml4ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwicHVsbF9yZXF1ZXN0Ijo0NDIsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL2NidXNpbGxvL2NvZGUiLCJ0cmVlX3NoYSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.9zyNHUwtbX8xPxbuGJLW8JDbANhM-hv4k3c79jlA5mK29qKgjS7g5HvKycAawNZQvZCBjO5kJ7ims6t4V38mCw", + "verification": { + "issuer": "https://code.every.dev/agent-run-provenance", + "audience": "launchplane", + "now": 1785931200, + "leeway_seconds": 0 + }, + "negative_cases": [ + { + "name": "issuer_tampered", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vaW52YWxpZC5leGFtcGxlL2lzc3VlciIsImp0aSI6Imp0aS1maXhlZC0yMDI2LTA4LTA1Iiwia2lkIjoiZXZlcnktY29kZS10ZXN0LTIwMjYtMDgtMDUiLCJtb2RlbCI6eyJmYW1pbHkiOiJncHQtNS42IiwibW9kZWwiOiJjb2RlLWdwdC01LjYtc29sIiwicHJvdmlkZXIiOiJvcGVuYWkifSwibmJmIjoxNzg1OTMxMTQwLCJub25jZSI6ImJtOXVZMlV0TkRReUxXWnBlR1ZrIiwicm9sZSI6ImltcGxlbWVudGVyIiwicnVuIjp7InJvb3RfdGhyZWFkX2lkIjoicm9vdC10aHJlYWQtNDQyLWZpeGVkIiwicnVuX2lkIjoicnVuLTQ0Mi1maXhlZCIsInNlc3Npb25faWQiOiJzZXNzaW9uLTQ0Mi1maXhlZCIsInRocmVhZF9pZCI6InRocmVhZC00NDItZml4ZWQiLCJ0dXJuX2lkIjoidHVybi03In0sInJ1bnRpbWUiOnsiYXJjaGl0ZWN0dXJlIjoiYWFyY2g2NCIsImhhcm5lc3MiOiJldmVyeS1jb2RlLWNsaSIsIm9wZXJhdGluZ19zeXN0ZW0iOiJtYWNvcyIsInJ1bnRpbWVfaWQiOiJldmVyeS1jb2RlLXJ1bnRpbWUtdGVzdCIsInZlcnNpb24iOiIwLjAuMC10ZXN0In0sInRhcmdldCI6eyJoZWFkX3NoYSI6ImFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEiLCJwdWxsX3JlcXVlc3QiOjQ0MiwicmVwb3NpdG9yeSI6ImdpdGh1Yi5jb20vY2J1c2lsbG8vY29kZSIsInRyZWVfc2hhIjoiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYiJ9LCJ0YXNrIjp7ImlkIjoiY2J1c2lsbG8vY29kZSM0NDIiLCJraW5kIjoiZ2l0aHViX2lzc3VlIn0sInRyYW5zY3JpcHQiOnsiYnl0ZXMiOjQ0LCJmb3JtYXQiOiJldmVyeS1jb2RlLnRyYW5zY3JpcHQtYnl0ZXMudjEiLCJzaGEyNTYiOiJjOTQ0MGYzMWM2MWJkYmRlNmZlMGU1MmJkMWY0OGU1ZTBlODUxYTA4OTAwM2Q2NDJiYjEzNmVmZTQzMjU5MjdjIn0sInZlcnNpb24iOiJldmVyeS1jb2RlLmFnZW50LXJ1bi1wcm92ZW5hbmNlLnYxIn0.LfznmzCPCythL4AHzWvTouDvIkPIlezeu6RB8EczIbeAdr0p95762dSj0imMePNKd0HTmMZk40ASEmW9HJaRBQ", + "verify_at": 1785931200, + "expected_error": "issuer_mismatch" + }, + { + "name": "audience_tampered", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJ3cm9uZy1hdWRpZW5jZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTE0MCwibm9uY2UiOiJibTl1WTJVdE5EUXlMV1pwZUdWayIsInJvbGUiOiJpbXBsZW1lbnRlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi00NDItZml4ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwicHVsbF9yZXF1ZXN0Ijo0NDIsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL2NidXNpbGxvL2NvZGUiLCJ0cmVlX3NoYSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.jaZg2PNgSza1PBQrxCWwnOCdKUacvl-EB_Hh9XrKavpwGfgcWTv0CJaF41UNXlZMbIjvvb1xIbuFizPwUwxgAQ", + "verify_at": 1785931200, + "expected_error": "audience_mismatch" + }, + { + "name": "expired", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxMjAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTE0MCwibm9uY2UiOiJibTl1WTJVdE5EUXlMV1pwZUdWayIsInJvbGUiOiJpbXBsZW1lbnRlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi00NDItZml4ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwicHVsbF9yZXF1ZXN0Ijo0NDIsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL2NidXNpbGxvL2NvZGUiLCJ0cmVlX3NoYSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.jom6yPrT6TXngyBmzrhGdqd0P4YobsPgid-eWDJNboUmPVSjbkwwZuLH2oPgaB11c6r8FHlyVPQXtUPJ7b9UDQ", + "verify_at": 1785931200, + "expected_error": "token_expired" + }, + { + "name": "issued_in_future", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDEsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTE0MCwibm9uY2UiOiJibTl1WTJVdE5EUXlMV1pwZUdWayIsInJvbGUiOiJpbXBsZW1lbnRlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi00NDItZml4ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwicHVsbF9yZXF1ZXN0Ijo0NDIsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL2NidXNpbGxvL2NvZGUiLCJ0cmVlX3NoYSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.IwpF0xu3eH0K6SAgowt0FZ_gKXiW_8IkIkFW7IdlX3FhgQdjJUT2_hVWWUkaeqc75uref8ryRmhoxBOBOC6jBw", + "verify_at": 1785931200, + "expected_error": "token_issued_in_future" + }, + { + "name": "not_yet_valid", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTIwMSwibm9uY2UiOiJibTl1WTJVdE5EUXlMV1pwZUdWayIsInJvbGUiOiJpbXBsZW1lbnRlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi00NDItZml4ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwicHVsbF9yZXF1ZXN0Ijo0NDIsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL2NidXNpbGxvL2NvZGUiLCJ0cmVlX3NoYSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.M0Yng6wIBz8j7Km1sIukySrY3xa3K6ko74mqUvZl3C7et6swVb4xiFlnMkwbwKpfZgPoKqt1d3gVCjNaQ1LRCw", + "verify_at": 1785931200, + "expected_error": "token_not_yet_valid" + }, + { + "name": "issued_before_not_before", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzExMzksImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTE0MCwibm9uY2UiOiJibTl1WTJVdE5EUXlMV1pwZUdWayIsInJvbGUiOiJpbXBsZW1lbnRlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi00NDItZml4ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwicHVsbF9yZXF1ZXN0Ijo0NDIsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL2NidXNpbGxvL2NvZGUiLCJ0cmVlX3NoYSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.tdYCUgFQd7GjdmQtTwzHsUmMDAfljEkYOOaOsVFqSanzYRgs05oU339CVAJ7yK7Oon1jlqS4IuTUd5ToKsYSBQ", + "verify_at": 1785931200, + "expected_error": "invalid_token_issued_at" + }, + { + "name": "nonce_tampered", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTE0MCwibm9uY2UiOiJ3cm9uZy1ub25jZSIsInJvbGUiOiJpbXBsZW1lbnRlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi00NDItZml4ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwicHVsbF9yZXF1ZXN0Ijo0NDIsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL2NidXNpbGxvL2NvZGUiLCJ0cmVlX3NoYSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.mTeGjwnxsYNLyaLJeVWy1gbUuU_Pg4f7rv-MmK8I0JU2giWLTu0e0es2atRvVKUn5exXzB4l0PUu6TmDxz0JCQ", + "verify_at": 1785931200, + "expected_error": "nonce_mismatch" + }, + { + "name": "challenge_digest_tampered", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTE0MCwibm9uY2UiOiJibTl1WTJVdE5EUXlMV1pwZUdWayIsInJvbGUiOiJpbXBsZW1lbnRlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi00NDItZml4ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwicHVsbF9yZXF1ZXN0Ijo0NDIsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL2NidXNpbGxvL2NvZGUiLCJ0cmVlX3NoYSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.SZTTLVTKFNiRqTVhm5Fv9k0RMJMJclf7MSlNju4esBN72PjYFzkQpu2AmaEGtLsffX_NW6hKTQHdJ19-_yT2DA", + "verify_at": 1785931200, + "expected_error": "challenge_digest_mismatch" + }, + { + "name": "repository_tampered", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTE0MCwibm9uY2UiOiJibTl1WTJVdE5EUXlMV1pwZUdWayIsInJvbGUiOiJpbXBsZW1lbnRlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi00NDItZml4ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwicHVsbF9yZXF1ZXN0Ijo0NDIsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL290aGVyL3JlcG9zaXRvcnkiLCJ0cmVlX3NoYSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.ur6b5CX8mz6y4zJ7kxp5qOfkdzxPQ4OA5V7j0u_eP5d0y0Fw9vRU5S5TvVbv1hADhMqD_Gib1FcNq59_Z3eFDA", + "verify_at": 1785931200, + "expected_error": "challenge_target_mismatch" + }, + { + "name": "pull_request_tampered", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTE0MCwibm9uY2UiOiJibTl1WTJVdE5EUXlMV1pwZUdWayIsInJvbGUiOiJpbXBsZW1lbnRlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi00NDItZml4ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwicHVsbF9yZXF1ZXN0Ijo0NDMsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL2NidXNpbGxvL2NvZGUiLCJ0cmVlX3NoYSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.wmKbqXKMgTdoR_xPZY9TvRyETMp-jlUBLM3FOcCmyUD_z3XvW2Pj8XPWns4oFDj5gFvdHvTOalHcW1-0QK8_Dw", + "verify_at": 1785931200, + "expected_error": "challenge_target_mismatch" + }, + { + "name": "head_sha_tampered", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTE0MCwibm9uY2UiOiJibTl1WTJVdE5EUXlMV1pwZUdWayIsInJvbGUiOiJpbXBsZW1lbnRlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi00NDItZml4ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjIiwicHVsbF9yZXF1ZXN0Ijo0NDIsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL2NidXNpbGxvL2NvZGUiLCJ0cmVlX3NoYSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.RY5PB0M0dRynI-77fLCjuIcgvtqDeAoDvmg5rFbSA4JMB-p7bKC-x1YInJioJhWh7TydioOIcpc1Ex2HSnxhAg", + "verify_at": 1785931200, + "expected_error": "challenge_target_mismatch" + }, + { + "name": "tree_sha_tampered", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTE0MCwibm9uY2UiOiJibTl1WTJVdE5EUXlMV1pwZUdWayIsInJvbGUiOiJpbXBsZW1lbnRlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi00NDItZml4ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwicHVsbF9yZXF1ZXN0Ijo0NDIsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL2NidXNpbGxvL2NvZGUiLCJ0cmVlX3NoYSI6ImRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGQifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.EtyI-fwvxIl8RxodUTkllMqABGhjgNRtOLv5zIuajgH3Enm-aAouREcInNMthqBZS9XwGdPrZGepKMma3mxPDw", + "verify_at": 1785931200, + "expected_error": "challenge_target_mismatch" + }, + { + "name": "role_tampered", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTE0MCwibm9uY2UiOiJibTl1WTJVdE5EUXlMV1pwZUdWayIsInJvbGUiOiJyZXZpZXdlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi00NDItZml4ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwicHVsbF9yZXF1ZXN0Ijo0NDIsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL2NidXNpbGxvL2NvZGUiLCJ0cmVlX3NoYSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.gaNfkYBa7FRrMlQfAe621GXdK3mgeVO1SjM9G4SljzAL-3YwamhroMDUQLfVEL2z4PXqBw0LgmlpgvoCyFM8Aw", + "verify_at": 1785931200, + "expected_error": "challenge_role_mismatch" + }, + { + "name": "run_identity_tampered", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTE0MCwibm9uY2UiOiJibTl1WTJVdE5EUXlMV1pwZUdWayIsInJvbGUiOiJpbXBsZW1lbnRlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi1jYWxsZXItc2VsZWN0ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwicHVsbF9yZXF1ZXN0Ijo0NDIsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL2NidXNpbGxvL2NvZGUiLCJ0cmVlX3NoYSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.ItebppZAgtyzpY6tiPasx-lCOUzojX9HoVVMWZHW2gvA4hAMEHENv9hmIZ-gCtCaIA8ztZpaWse84G2GWRHmAQ", + "verify_at": 1785931200, + "expected_error": "challenge_run_mismatch" + }, + { + "name": "model_family_tampered", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTQiLCJtb2RlbCI6ImNvZGUtZ3B0LTUuNi1zb2wiLCJwcm92aWRlciI6Im9wZW5haSJ9LCJuYmYiOjE3ODU5MzExNDAsIm5vbmNlIjoiYm05dVkyVXRORFF5TFdacGVHVmsiLCJyb2xlIjoiaW1wbGVtZW50ZXIiLCJydW4iOnsicm9vdF90aHJlYWRfaWQiOiJyb290LXRocmVhZC00NDItZml4ZWQiLCJydW5faWQiOiJydW4tNDQyLWZpeGVkIiwic2Vzc2lvbl9pZCI6InNlc3Npb24tNDQyLWZpeGVkIiwidGhyZWFkX2lkIjoidGhyZWFkLTQ0Mi1maXhlZCIsInR1cm5faWQiOiJ0dXJuLTcifSwicnVudGltZSI6eyJhcmNoaXRlY3R1cmUiOiJhYXJjaDY0IiwiaGFybmVzcyI6ImV2ZXJ5LWNvZGUtY2xpIiwib3BlcmF0aW5nX3N5c3RlbSI6Im1hY29zIiwicnVudGltZV9pZCI6ImV2ZXJ5LWNvZGUtcnVudGltZS10ZXN0IiwidmVyc2lvbiI6IjAuMC4wLXRlc3QifSwidGFyZ2V0Ijp7ImhlYWRfc2hhIjoiYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYSIsInB1bGxfcmVxdWVzdCI6NDQyLCJyZXBvc2l0b3J5IjoiZ2l0aHViLmNvbS9jYnVzaWxsby9jb2RlIiwidHJlZV9zaGEiOiJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiIn0sInRhc2siOnsiaWQiOiJjYnVzaWxsby9jb2RlIzQ0MiIsImtpbmQiOiJnaXRodWJfaXNzdWUifSwidHJhbnNjcmlwdCI6eyJieXRlcyI6NDQsImZvcm1hdCI6ImV2ZXJ5LWNvZGUudHJhbnNjcmlwdC1ieXRlcy52MSIsInNoYTI1NiI6ImM5NDQwZjMxYzYxYmRiZGU2ZmUwZTUyYmQxZjQ4ZTVlMGU4NTFhMDg5MDAzZDY0MmJiMTM2ZWZlNDMyNTkyN2MifSwidmVyc2lvbiI6ImV2ZXJ5LWNvZGUuYWdlbnQtcnVuLXByb3ZlbmFuY2UudjEifQ.agpadUyQk7liuHC4wD6vTcEfrG0iMES0VCN0rqnF4qxrb38e6jTP_-qz-qtT2tAf9dOVe9qyTvRt8-DKLtonBA", + "verify_at": 1785931200, + "expected_error": "model_family_mismatch" + }, + { + "name": "challenge_expired", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTE0MCwibm9uY2UiOiJibTl1WTJVdE5EUXlMV1pwZUdWayIsInJvbGUiOiJpbXBsZW1lbnRlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi00NDItZml4ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwicHVsbF9yZXF1ZXN0Ijo0NDIsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL2NidXNpbGxvL2NvZGUiLCJ0cmVlX3NoYSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.9zyNHUwtbX8xPxbuGJLW8JDbANhM-hv4k3c79jlA5mK29qKgjS7g5HvKycAawNZQvZCBjO5kJ7ims6t4V38mCw", + "verify_at": 1785931800, + "expected_error": "challenge_not_active" + }, + { + "name": "signature_tampered", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJsYXVuY2hwbGFuZSIsImNoYWxsZW5nZV9zaGEyNTYiOiIzY2RmMDljNzYwMjdhNDcyZjlhYjhlNTk3NmE0OTQxNzY3ZmZlNTFiZTAyNjUzM2QzMzM2YTllY2JiOTkyMzBjIiwiZXhwIjoxNzg1OTMxODAwLCJpYXQiOjE3ODU5MzEyMDAsImlzcyI6Imh0dHBzOi8vY29kZS5ldmVyeS5kZXYvYWdlbnQtcnVuLXByb3ZlbmFuY2UiLCJqdGkiOiJqdGktZml4ZWQtMjAyNi0wOC0wNSIsImtpZCI6ImV2ZXJ5LWNvZGUtdGVzdC0yMDI2LTA4LTA1IiwibW9kZWwiOnsiZmFtaWx5IjoiZ3B0LTUuNiIsIm1vZGVsIjoiY29kZS1ncHQtNS42LXNvbCIsInByb3ZpZGVyIjoib3BlbmFpIn0sIm5iZiI6MTc4NTkzMTE0MCwibm9uY2UiOiJibTl1WTJVdE5EUXlMV1pwZUdWayIsInJvbGUiOiJpbXBsZW1lbnRlciIsInJ1biI6eyJyb290X3RocmVhZF9pZCI6InJvb3QtdGhyZWFkLTQ0Mi1maXhlZCIsInJ1bl9pZCI6InJ1bi00NDItZml4ZWQiLCJzZXNzaW9uX2lkIjoic2Vzc2lvbi00NDItZml4ZWQiLCJ0aHJlYWRfaWQiOiJ0aHJlYWQtNDQyLWZpeGVkIiwidHVybl9pZCI6InR1cm4tNyJ9LCJydW50aW1lIjp7ImFyY2hpdGVjdHVyZSI6ImFhcmNoNjQiLCJoYXJuZXNzIjoiZXZlcnktY29kZS1jbGkiLCJvcGVyYXRpbmdfc3lzdGVtIjoibWFjb3MiLCJydW50aW1lX2lkIjoiZXZlcnktY29kZS1ydW50aW1lLXRlc3QiLCJ2ZXJzaW9uIjoiMC4wLjAtdGVzdCJ9LCJ0YXJnZXQiOnsiaGVhZF9zaGEiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwicHVsbF9yZXF1ZXN0Ijo0NDIsInJlcG9zaXRvcnkiOiJnaXRodWIuY29tL2NidXNpbGxvL2NvZGUiLCJ0cmVlX3NoYSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIifSwidGFzayI6eyJpZCI6ImNidXNpbGxvL2NvZGUjNDQyIiwia2luZCI6ImdpdGh1Yl9pc3N1ZSJ9LCJ0cmFuc2NyaXB0Ijp7ImJ5dGVzIjo0NCwiZm9ybWF0IjoiZXZlcnktY29kZS50cmFuc2NyaXB0LWJ5dGVzLnYxIiwic2hhMjU2IjoiYzk0NDBmMzFjNjFiZGJkZTZmZTBlNTJiZDFmNDhlNWUwZTg1MWEwODkwMDNkNjQyYmIxMzZlZmU0MzI1OTI3YyJ9LCJ2ZXJzaW9uIjoiZXZlcnktY29kZS5hZ2VudC1ydW4tcHJvdmVuYW5jZS52MSJ9.9zyNHUwtbX8xPxbuGJLW8JDbANhM-hv4k3c79jlA5mK29qKgjS7g5HvKycAawNZQvZCBjO5kJ7ims6t4V38mCA", + "verify_at": 1785931200, + "expected_error": "invalid_signature" + } + ] +} diff --git a/code-rs/code-agent-run-provenance/tests/verify_vector.py b/code-rs/code-agent-run-provenance/tests/verify_vector.py new file mode 100644 index 0000000000..9c77f5af25 --- /dev/null +++ b/code-rs/code-agent-run-provenance/tests/verify_vector.py @@ -0,0 +1,193 @@ +import base64 +import hashlib +import importlib +import json +from pathlib import Path +from typing import Any + + +jwt = importlib.import_module("jwt") + + +FIXTURE_PATH = Path(__file__).parent / "fixtures" / "agent_run_provenance_v1.json" +MAX_VERIFICATION_LEEWAY_SECONDS = 300 + + +def canonical_json(value: object) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def model_family(model: str) -> str: + slug = model.lower().rsplit("/", 1)[-1] + if slug.startswith("code-"): + slug = slug[5:] + parts = slug.split("-") + if parts[0] == "gpt" and len(parts) >= 2: + return f"gpt-{parts[1]}" + if parts[0].startswith("o") and parts[0][1:].isdigit(): + return parts[0] + if parts[0] == "gemini" and len(parts) >= 2: + return f"gemini-{parts[1]}" + if parts[0] == "claude" and len(parts) >= 3: + return "-".join(parts[:3]) + if len(parts) > 1 and parts[-1] in { + "chat", + "instruct", + "latest", + "mini", + "nano", + "preview", + "pro", + }: + return "-".join(parts[:-1]) + return slug + + +def decode_signed(token: str, public_key: Any) -> dict[str, Any]: + return jwt.decode( + token, + public_key, + algorithms=["EdDSA"], + options={ + "require": ["iss", "aud", "iat", "nbf", "exp"], + "verify_aud": False, + "verify_exp": False, + "verify_iat": False, + "verify_iss": False, + "verify_nbf": False, + }, + ) + + +def rejection_code( + token: str, + public_key: Any, + fixture: dict[str, Any], + verify_at: int, +) -> str | None: + try: + claims = decode_signed(token, public_key) + except jwt.InvalidSignatureError: + return "invalid_signature" + + challenge = fixture["challenge"] + verification = fixture["verification"] + leeway = verification["leeway_seconds"] + if leeway > MAX_VERIFICATION_LEEWAY_SECONDS: + return "invalid_verification_leeway" + if verify_at < challenge["not_before"] or verify_at >= challenge["expires_at"]: + return "challenge_not_active" + header = jwt.get_unverified_header(token) + if claims["kid"] != header.get("kid"): + return "untrusted_key" + if claims["version"] != "every-code.agent-run-provenance.v1": + return "invalid_claims_version" + if claims["iss"] != verification["issuer"]: + return "issuer_mismatch" + if claims["aud"] != verification["audience"] or claims["aud"] != challenge["audience"]: + return "audience_mismatch" + if claims["iat"] > verify_at and claims["iat"] - verify_at > leeway: + return "token_issued_in_future" + if claims["nbf"] > verify_at and claims["nbf"] - verify_at > leeway: + return "token_not_yet_valid" + if verify_at >= claims["exp"] and verify_at - claims["exp"] >= leeway: + return "token_expired" + if ( + claims["iat"] < challenge["issued_at"] + or claims["iat"] < claims["nbf"] + or claims["iat"] >= claims["exp"] + ): + return "invalid_token_issued_at" + if claims["nbf"] != challenge["not_before"] or claims["exp"] != challenge["expires_at"]: + return "challenge_validity_mismatch" + if claims["nonce"] != challenge["nonce"]: + return "nonce_mismatch" + digest = hashlib.sha256(canonical_json(challenge).encode()).hexdigest() + if claims["challenge_sha256"] != digest: + return "challenge_digest_mismatch" + if claims["target"] != challenge["target"]: + return "challenge_target_mismatch" + if claims["role"] != challenge["role"]: + return "challenge_role_mismatch" + if claims["run"]["run_id"] != challenge["run_id"]: + return "challenge_run_mismatch" + if claims["model"]["family"] != model_family(claims["model"]["model"]): + return "model_family_mismatch" + return None + + +def main() -> None: + fixture_text = FIXTURE_PATH.read_text(encoding="utf-8") + fixture = json.loads(fixture_text) + expected_keys = { + "canonical_challenge_json", + "canonical_claims_json", + "challenge", + "claims", + "fixed_clock", + "fixed_seed_sha256", + "jwks", + "negative_cases", + "schema", + "token", + "verification", + } + assert set(fixture) == expected_keys + assert "oauth2" not in fixture_text + assert "secret@" not in fixture_text + assert "private transcript bytes" not in fixture_text + + challenge_json = canonical_json(fixture["challenge"]) + claims_json = canonical_json(fixture["claims"]) + assert challenge_json == fixture["canonical_challenge_json"] + assert claims_json == fixture["canonical_claims_json"] + assert hashlib.sha256(challenge_json.encode()).hexdigest() == fixture["claims"][ + "challenge_sha256" + ] + + jwks = fixture["jwks"] + assert len(jwks["keys"]) == 1 + jwk = jwks["keys"][0] + assert jwk["kty"] == "OKP" + assert jwk["crv"] == "Ed25519" + assert jwk["alg"] == "EdDSA" + public_key = jwt.PyJWK.from_dict(jwk).key + + header_segment, payload_segment, signature_segment = fixture["token"].split(".") + assert signature_segment + decode_segment = lambda segment: base64.urlsafe_b64decode( + segment + "=" * (-len(segment) % 4) + ) + assert decode_segment(header_segment) == canonical_json( + {"alg": "EdDSA", "kid": jwk["kid"], "typ": "JWT"} + ).encode() + assert decode_segment(payload_segment) == claims_json.encode() + assert jwt.get_unverified_header(fixture["token"])["kid"] == jwk["kid"] + assert decode_signed(fixture["token"], public_key) == fixture["claims"] + excessive_leeway = json.loads(json.dumps(fixture)) + excessive_leeway["verification"]["leeway_seconds"] = MAX_VERIFICATION_LEEWAY_SECONDS + 1 + assert ( + rejection_code( + excessive_leeway["token"], + public_key, + excessive_leeway, + excessive_leeway["fixed_clock"], + ) + == "invalid_verification_leeway" + ) + + for negative in fixture["negative_cases"]: + actual = rejection_code( + negative["token"], public_key, fixture, negative["verify_at"] + ) + assert actual == negative["expected_error"], ( + negative["name"], + negative["expected_error"], + actual, + ) + + print("PyJWT agent-run provenance vector verification: OK") + + +if __name__ == "__main__": + main() diff --git a/docs/agent-run-provenance.md b/docs/agent-run-provenance.md new file mode 100644 index 0000000000..d993862872 --- /dev/null +++ b/docs/agent-run-provenance.md @@ -0,0 +1,82 @@ +# Signed Agent-Run Provenance + +Every Code signs agent-run provenance as an Ed25519 compact JWS. The library +contract lives in `code-rs/code-agent-run-provenance`; CLI and exec export surfaces +are intentionally outside this contract slice. + +## Trust Boundary + +- Runtime observations are the only source for repository, pull request, + commit/tree, run, conversation, model/provider/family, task, transcript, and + runtime identity claims. +- A challenge constrains role, run, target, audience, nonce, and validity. It + cannot supply claim values: issuance fails when a constraint differs from an + observation. +- A challenge is not self-authenticating. The issuer must receive it through an + authenticated channel, and the verifier must compare the token against the + original challenge retained in trusted verifier storage. The digest proves + which challenge was signed; it does not establish who created the challenge. +- Model family is derived from the observed model slug. Callers cannot provide + it. +- Repository remotes are reduced to `host[:non-default-port]/path` before + signing. User names, passwords, query strings, fragments, default transport + ports, and raw remote URLs are never emitted. +- Transcript bytes are streamed into a domain-separated SHA-256 digest and + discarded. The token records the `every-code.transcript-bytes.v1` format, + digest, and byte count, but never transcript contents. +- Provenance keys are generated and loaded independently from account-scoped + backend agent-identity keys. +- Possession of a provenance signing key defines the trusted Every Code runtime + boundary. The future exec integration must construct `ObservedAgentRun` only + from runtime state; caller text and challenge fields are not observation + sources. +- This crate prevents duplicate or conflicting issuance. Verifier-side proof + consumption, challenge-origin authentication, approval policy, and accepted + token replay handling remain Launchplane responsibilities. + +## Wire Contract + +The protected header is canonical JSON with `alg=EdDSA`, `typ=JWT`, and `kid`. +Claims use ordinary JWT `iss`, `aud`, `jti`, `iat`, `nbf`, and `exp` fields plus +the challenge nonce and SHA-256 digest. Challenge and JWS JSON use UTF-8, +lexicographically sorted schema keys, no insignificant whitespace, and JSON +string escaping without ASCII-only rewriting before hashing or signing. Python +implementations can reproduce this with `sort_keys=True`, `ensure_ascii=False`, +and separators `(',', ':')`; signed contract structs reject unknown fields. + +Verification requires the expected issuer/audience, a matching Ed25519 JWKS +key, a live validity window, the original challenge, normalized target/run/role +equality, and a model family re-derived from the signed runtime model slug. +Verifier clock leeway is bounded to 300 seconds so configuration cannot silently +disable token expiry. Leeway applies to token timestamps only; it never extends +the verifier-issued challenge authorization window. + +## Replay Rules + +The issuance ledger is append-only JSON Lines. A per-ledger OS file lock covers +reload, uniqueness checks, one-buffer append, and sync so independent issuers +cannot race. A `jti` can be recorded once. A run ID can also be recorded once: +a second issuance with the same role is a replay, while a different role is a +role conflict. Existing entries are checked when the ledger opens. The ledger +assumes its directory is writable only by the trusted Every Code runtime; it is +not an integrity anchor against an attacker who can rewrite local state. If a +crash leaves an incomplete final JSON line, reopening truncates only that +uncommitted suffix. A complete final entry missing only its newline is preserved +and repaired; complete malformed entries remain a hard failure. + +## Interoperability Vector + +The fixture under +`code-rs/code-agent-run-provenance/tests/fixtures/agent_run_provenance_v1.json` +uses a fixed Ed25519 seed and fixed clock. It contains the public JWKS, +challenge, claims, compact token, verifier inputs, and signed negative tokens. +PyJWT verifiers should select `kid`, require `EdDSA`, `iss`, `aud`, `iat`, +`nbf`, and `exp`, then apply the challenge and Every Code claim checks described +above. + +Run the independent cross-language fixture check with: + +```console +uv run --with pyjwt --with cryptography python \ + code-rs/code-agent-run-provenance/tests/verify_vector.py +```