From e035df9e7b9dcbf4a115b7e8e98a80799372465f Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 8 Sep 2026 01:54:10 +0900 Subject: [PATCH] feat: add Runtime Workspace issuer trust --- crates/worker-runtime/src/lib.rs | 1 + crates/worker-runtime/src/main.rs | 351 +++++- crates/worker-runtime/src/workspace_issuer.rs | 1033 +++++++++++++++++ docs/development/server-runtime-auth.md | 49 +- 4 files changed, 1410 insertions(+), 24 deletions(-) create mode 100644 crates/worker-runtime/src/workspace_issuer.rs diff --git a/crates/worker-runtime/src/lib.rs b/crates/worker-runtime/src/lib.rs index 8f481903..8bf7c342 100644 --- a/crates/worker-runtime/src/lib.rs +++ b/crates/worker-runtime/src/lib.rs @@ -28,6 +28,7 @@ mod runtime; pub mod worker_backend; pub mod worker_source; pub mod working_directory; +pub mod workspace_issuer; #[cfg(feature = "fs-store")] pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions}; diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index d064f711..3c2f3c04 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -12,6 +12,7 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use worker_runtime::auth::{ @@ -24,6 +25,11 @@ use worker_runtime::http_server::{ }; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use worker_runtime::working_directory::RuntimeGitCacheMaterializer; +use worker_runtime::workspace_issuer::{ + WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord, add_workspace_issuer_trust, + replace_workspace_issuer_trust, revoke_workspace_issuer_trust, + validate_workspace_issuer_trust_records, +}; use worker_runtime::{Runtime, RuntimeOptions}; fn main() -> ExitCode { @@ -72,7 +78,7 @@ fn run() -> Result<(), ProcessError> { } if matches!( args.first().map(String::as_str), - Some("identity" | "trust-server") + Some("identity" | "trust-server" | "trust-workspace") ) { return run_auth_command(args); } @@ -170,7 +176,7 @@ fn run_migration_command(mut args: Vec) -> Result<(), ProcessError> { println!( "{}", serde_json::to_string_pretty(&plan) - .map_err(|error| ProcessError::Auth(format!("encode migration plan: {error}")))? + .map_err(|error| ProcessError::auth(format!("encode migration plan: {error}")))? ); Ok(()) } @@ -198,12 +204,12 @@ fn build_runtime(config: &ProcessConfig) -> Result { > = None; if let Some(endpoint) = config.backend_resource_endpoint.clone() { let identity = runtime_auth.identity.as_ref().ok_or_else(|| { - ProcessError::Auth( + ProcessError::auth( "--backend-resource-endpoint requires a configured Runtime identity".to_owned(), ) })?; let [trusted_server] = runtime_auth.trusted_servers.as_slice() else { - return Err(ProcessError::Auth( + return Err(ProcessError::auth( "--backend-resource-endpoint requires exactly one trusted Server identity" .to_owned(), )); @@ -468,6 +474,10 @@ impl ProcessError { fn usage(message: String) -> Self { Self::Usage(message) } + + fn auth(message: impl Into) -> Self { + Self::Auth(message.into()) + } } impl fmt::Display for ProcessError { @@ -512,6 +522,8 @@ struct RuntimeAuthFile { identity: Option, #[serde(default)] trusted_servers: Vec, + #[serde(default)] + workspace_issuers: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -527,6 +539,134 @@ fn runtime_public_identity_view(identity: &RuntimeIdentityMaterial) -> RuntimePu } } +fn run_trust_workspace_command(mut args: VecDeque) -> Result<(), ProcessError> { + let subcommand = args.pop_front().ok_or_else(|| { + ProcessError::usage( + "trust-workspace requires add, list, show, replace, or revoke".to_string(), + ) + })?; + match subcommand.as_str() { + "add" | "replace" => { + let bundle_path = take_required_auth_option(&mut args, "--bundle")?; + let config = parse_auth_storage_flags(&mut args)?; + let auth_path = runtime_auth_path(&config); + let mut auth = read_runtime_auth_file(&auth_path)?; + let bundle_bytes = std::fs::read(&bundle_path).map_err(|_| { + ProcessError::auth("Workspace signing public identity bundle is unavailable") + })?; + let bundle = serde_json::from_slice(&bundle_bytes).map_err(|_| { + ProcessError::auth("Workspace signing public identity bundle is invalid") + })?; + let now_unix = unix_now_i64()?; + let (mutation, record) = if subcommand == "add" { + add_workspace_issuer_trust(&mut auth.workspace_issuers, bundle, now_unix) + } else { + replace_workspace_issuer_trust(&mut auth.workspace_issuers, bundle, now_unix) + } + .map_err(|error| ProcessError::auth(error.to_string()))?; + if mutation != WorkspaceIssuerTrustMutation::Unchanged { + write_runtime_auth_file(&auth_path, &auth)?; + } + print_workspace_trust_mutation(mutation, &record); + Ok(()) + } + "list" => { + let config = parse_auth_storage_flags(&mut args)?; + let auth_path = runtime_auth_path(&config); + let mut records = read_runtime_auth_file(&auth_path)?.workspace_issuers; + records.sort_by(|left, right| left.workspace_id.cmp(&right.workspace_id)); + let output = serde_json::to_string_pretty(&records) + .map_err(|_| ProcessError::auth("Workspace issuer trust output failed"))?; + println!("{output}"); + Ok(()) + } + "show" => { + let workspace_id = take_required_auth_option(&mut args, "--workspace-id")?; + let config = parse_auth_storage_flags(&mut args)?; + let auth_path = runtime_auth_path(&config); + let auth = read_runtime_auth_file(&auth_path)?; + let record = auth + .workspace_issuers + .iter() + .find(|record| record.workspace_id == workspace_id) + .ok_or_else(|| { + ProcessError::auth(format!( + "Workspace issuer trust is not registered for `{workspace_id}`" + )) + })?; + let output = serde_json::to_string_pretty(record) + .map_err(|_| ProcessError::auth("Workspace issuer trust output failed"))?; + println!("{output}"); + Ok(()) + } + "revoke" => { + let workspace_id = take_required_auth_option(&mut args, "--workspace-id")?; + let config = parse_auth_storage_flags(&mut args)?; + let auth_path = runtime_auth_path(&config); + let mut auth = read_runtime_auth_file(&auth_path)?; + let (mutation, record) = revoke_workspace_issuer_trust( + &mut auth.workspace_issuers, + &workspace_id, + unix_now_i64()?, + ) + .map_err(|error| ProcessError::auth(error.to_string()))?; + if mutation != WorkspaceIssuerTrustMutation::Unchanged { + write_runtime_auth_file(&auth_path, &auth)?; + } + print_workspace_trust_mutation(mutation, &record); + Ok(()) + } + _ => Err(ProcessError::usage(format!( + "unknown trust-workspace command `{subcommand}`" + ))), + } +} + +fn take_required_auth_option( + args: &mut VecDeque, + expected: &str, +) -> Result { + let argument = args + .pop_front() + .ok_or_else(|| ProcessError::usage(format!("missing required `{expected}`")))?; + let (flag, inline_value) = split_flag_value(argument)?; + if flag != expected { + return Err(ProcessError::usage(format!( + "expected `{expected}`, found `{flag}`" + ))); + } + take_value(&flag, inline_value, args) +} + +fn unix_now_i64() -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| ProcessError::auth("system clock is before the Unix epoch"))? + .as_secs(); + i64::try_from(now).map_err(|_| ProcessError::auth("system clock is out of range")) +} + +fn print_workspace_trust_mutation( + mutation: WorkspaceIssuerTrustMutation, + record: &WorkspaceIssuerTrustRecord, +) { + let action = match mutation { + WorkspaceIssuerTrustMutation::Added => "added", + WorkspaceIssuerTrustMutation::Replaced => "replaced", + WorkspaceIssuerTrustMutation::Revoked => "revoked", + WorkspaceIssuerTrustMutation::Unchanged => "unchanged", + }; + println!( + "Workspace issuer trust {action}: workspace={} key={} fingerprint={} identity_revision={} trust_generation={} state={:?}", + record.workspace_id, + record.key_id, + record.public_key_fingerprint, + record.identity_revision, + record.trust_generation, + record.state, + ); +} + fn runtime_auth_path(config: &ProcessConfig) -> PathBuf { config.resolved_fs_paths().runtime_dir.join("auth.toml") } @@ -535,34 +675,50 @@ fn read_runtime_auth_file(path: &Path) -> Result if !path.exists() { return Ok(RuntimeAuthFile::default()); } - let contents = std::fs::read_to_string(path)?; - toml::from_str(&contents) - .map_err(|error| ProcessError::Auth(format!("failed to parse {}: {error}", path.display()))) + let contents = std::fs::read_to_string(path) + .map_err(|_| ProcessError::auth("runtime auth store is unavailable"))?; + let auth: RuntimeAuthFile = toml::from_str(&contents) + .map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?; + validate_workspace_issuer_trust_records(&auth.workspace_issuers) + .map_err(|_| ProcessError::auth("runtime Workspace issuer trust store is corrupt"))?; + Ok(auth) } fn write_runtime_auth_file(path: &Path, auth: &RuntimeAuthFile) -> Result<(), ProcessError> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let contents = toml::to_string_pretty(auth).map_err(|error| { - ProcessError::Auth(format!("failed to serialize {}: {error}", path.display())) - })?; + let contents = toml::to_string_pretty(auth) + .map_err(|_| ProcessError::auth("runtime auth store serialization failed"))?; write_secret_file(path, contents.as_bytes()) } fn write_secret_file(path: &Path, contents: &[u8]) -> Result<(), ProcessError> { - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; + use std::io::Write as _; + + let parent = path + .parent() + .ok_or_else(|| ProcessError::auth("runtime auth store path has no parent"))?; + let temporary_path = parent.join(format!(".runtime-auth-{}.tmp", uuid::Uuid::now_v7())); + let write_result = (|| -> Result<(), ProcessError> { let mut options = std::fs::OpenOptions::new(); - options.create(true).write(true).truncate(true).mode(0o600); - std::io::Write::write_all(&mut options.open(path)?, contents)?; + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options.open(&temporary_path)?; + file.write_all(contents)?; + file.sync_all()?; + std::fs::rename(&temporary_path, path)?; + std::fs::File::open(parent)?.sync_all()?; + Ok(()) + })(); + if write_result.is_err() { + let _ = std::fs::remove_file(&temporary_path); } - #[cfg(not(unix))] - { - std::fs::write(path, contents)?; - } - Ok(()) + write_result } fn load_runtime_http_auth( @@ -609,6 +765,7 @@ fn run_auth_command(args: Vec) -> Result<(), ProcessError> { match command.as_str() { "identity" => run_identity_command(args), "trust-server" => run_trust_server_command(args), + "trust-workspace" => run_trust_workspace_command(args), _ => Err(ProcessError::usage(format!( "unknown auth command `{command}`" ))), @@ -668,7 +825,7 @@ fn run_identity_command(mut args: VecDeque) -> Result<(), ProcessError> })?; auth.identity = Some( RuntimeIdentityMaterial::generate(runtime_id) - .map_err(|error| ProcessError::Auth(error.to_string()))?, + .map_err(|error| ProcessError::auth(error.to_string()))?, ); write_runtime_auth_file(&path, &auth)?; let identity = auth.identity.as_ref().unwrap(); @@ -721,7 +878,7 @@ fn run_identity_command(mut args: VecDeque) -> Result<(), ProcessError> println!( "{}", serde_json::to_string_pretty(&view) - .map_err(|error| ProcessError::Auth(error.to_string()))? + .map_err(|error| ProcessError::auth(error.to_string()))? ); } else { println!("runtime_id={}", view.identity_id); @@ -852,7 +1009,7 @@ fn run_trust_server_command(mut args: VecDeque) -> Result<(), ProcessErr println!( "{}", serde_json::to_string_pretty(&auth.trusted_servers) - .map_err(|error| ProcessError::Auth(error.to_string()))? + .map_err(|error| ProcessError::auth(error.to_string()))? ); } else { for server in auth.trusted_servers { @@ -944,6 +1101,11 @@ Auth commands: identity show [--json] [--fs-root PATH] [--fs-runtime-dir PATH] trust-server add --server-id ID --public-key KEY [--display-name NAME] [--replace] [--fs-root PATH] [--fs-runtime-dir PATH] trust-server list [--json] [--fs-root PATH] [--fs-runtime-dir PATH] + trust-workspace add --bundle PATH [--fs-root PATH] [--fs-runtime-dir PATH] + trust-workspace list [--fs-root PATH] [--fs-runtime-dir PATH] + trust-workspace show --workspace-id ID [--fs-root PATH] [--fs-runtime-dir PATH] + trust-workspace replace --bundle PATH [--fs-root PATH] [--fs-runtime-dir PATH] + trust-workspace revoke --workspace-id ID [--fs-root PATH] [--fs-runtime-dir PATH] trust-server revoke --server-id ID [--fs-root PATH] [--fs-runtime-dir PATH]"# } @@ -1121,6 +1283,149 @@ mod tests { assert_eq!(std::fs::read(root.join("runtime.json")).unwrap(), before); } + #[test] + fn workspace_issuer_trust_cli_persists_across_reload_and_writes_private_mode() { + use sha2::{Digest as _, Sha256}; + use workspace_api::WorkspacePublicIdentityBundle; + + let temp = tempfile::tempdir().unwrap(); + let identity = RuntimeIdentityMaterial::generate("WK-1").unwrap(); + let public_key = decode_public_key(&identity.public_key).unwrap(); + let fingerprint = Sha256::digest(public_key); + let fingerprint = format!( + "sha256:{}", + fingerprint + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + let bundle_path = temp.path().join("workspace-public.json"); + std::fs::write( + &bundle_path, + serde_json::to_vec(&WorkspacePublicIdentityBundle { + workspace_id: "workspace-1".to_string(), + backend_url: "https://backend.example.test".to_string(), + key_id: "WK-1".to_string(), + algorithm: "ed25519".to_string(), + public_key: identity.public_key, + public_key_fingerprint: fingerprint, + revision: 1, + }) + .unwrap(), + ) + .unwrap(); + + run_trust_workspace_command(VecDeque::from([ + "add".to_string(), + "--bundle".to_string(), + bundle_path.to_string_lossy().into_owned(), + "--fs-root".to_string(), + temp.path().to_string_lossy().into_owned(), + ])) + .unwrap(); + let config = ProcessConfig { + fs_root: Some(temp.path().to_path_buf()), + ..ProcessConfig::default().unwrap() + }; + let path = runtime_auth_path(&config); + let first = read_runtime_auth_file(&path).unwrap(); + assert_eq!(first.workspace_issuers.len(), 1); + assert_eq!(first.workspace_issuers[0].trust_generation, 1); + + run_trust_workspace_command(VecDeque::from([ + "add".to_string(), + "--bundle".to_string(), + bundle_path.to_string_lossy().into_owned(), + "--fs-root".to_string(), + temp.path().to_string_lossy().into_owned(), + ])) + .unwrap(); + let replay = read_runtime_auth_file(&path).unwrap(); + assert_eq!(replay.workspace_issuers[0].trust_generation, 1); + + let replacement = RuntimeIdentityMaterial::generate("WK-2").unwrap(); + let public_key = decode_public_key(&replacement.public_key).unwrap(); + let fingerprint = Sha256::digest(public_key); + let fingerprint = format!( + "sha256:{}", + fingerprint + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + std::fs::write( + &bundle_path, + serde_json::to_vec(&WorkspacePublicIdentityBundle { + workspace_id: "workspace-1".to_string(), + backend_url: "https://backend.example.test".to_string(), + key_id: "WK-2".to_string(), + algorithm: "ed25519".to_string(), + public_key: replacement.public_key, + public_key_fingerprint: fingerprint, + revision: 2, + }) + .unwrap(), + ) + .unwrap(); + for command in ["replace", "show", "list"] { + let mut args = VecDeque::from([command.to_string()]); + match command { + "replace" => { + args.push_back("--bundle".to_string()); + args.push_back(bundle_path.to_string_lossy().into_owned()); + } + "show" => { + args.push_back("--workspace-id".to_string()); + args.push_back("workspace-1".to_string()); + } + "list" => {} + _ => unreachable!(), + } + args.push_back("--fs-root".to_string()); + args.push_back(temp.path().to_string_lossy().into_owned()); + run_trust_workspace_command(args).unwrap(); + } + run_trust_workspace_command(VecDeque::from([ + "revoke".to_string(), + "--workspace-id".to_string(), + "workspace-1".to_string(), + "--fs-root".to_string(), + temp.path().to_string_lossy().into_owned(), + ])) + .unwrap(); + let revoked = read_runtime_auth_file(&path).unwrap(); + assert_eq!(revoked.workspace_issuers[0].trust_generation, 3); + assert_eq!( + revoked.workspace_issuers[0].state, + worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + + #[test] + fn corrupt_workspace_issuer_trust_fails_closed_without_echoing_store_content() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("runtime-auth.toml"); + let private_marker = "private-key-material-must-not-appear"; + std::fs::write( + &path, + format!("identity = {{ private_key_pkcs8 = \"{private_marker}\" }}\n[[workspace_issuers]]\nworkspace_id = []\n"), + ) + .unwrap(); + let error = read_runtime_auth_file(&path).unwrap_err().to_string(); + assert_eq!(error, "runtime auth store is corrupt"); + assert!(!error.contains(private_marker)); + assert!(!error.contains(&path.to_string_lossy().into_owned())); + } + #[test] fn no_store_disables_runtime_catalog_persistence() { let config = parse_args(["--no-store"]).unwrap().unwrap(); diff --git a/crates/worker-runtime/src/workspace_issuer.rs b/crates/worker-runtime/src/workspace_issuer.rs new file mode 100644 index 00000000..07dbc47c --- /dev/null +++ b/crates/worker-runtime/src/workspace_issuer.rs @@ -0,0 +1,1033 @@ +use std::collections::HashMap; +use std::fmt; +use std::sync::{Arc, Mutex}; + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ring::signature::{ED25519, Ed25519KeyPair, UnparsedPublicKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use workspace_api::WorkspacePublicIdentityBundle; + +use crate::auth::{RuntimeAuthContext, RuntimeAuthError, RuntimeHttpAuthConfig}; + +const WORKSPACE_TOKEN_PREFIX: &str = "yoi-workspace-v1"; +const WORKSPACE_SIGNING_INPUT_PREFIX: &str = "yoi.workspace.capability.v1."; +const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519"; +const MAX_TOKEN_BYTES: usize = 16 * 1024; +const MAX_ID_BYTES: usize = 256; +const MAX_OPERATION_BYTES: usize = 128; +const MAX_TOKEN_LIFETIME_SECONDS: i64 = 300; +const MAX_CLOCK_SKEW_SECONDS: i64 = 30; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceIssuerTrustState { + Active, + Revoked, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceIssuerTrustRecord { + pub workspace_id: String, + pub backend_url: String, + pub key_id: String, + pub algorithm: String, + pub public_key: String, + pub public_key_fingerprint: String, + pub identity_revision: u64, + pub trust_generation: u64, + pub state: WorkspaceIssuerTrustState, + pub registered_at_unix: i64, + pub updated_at_unix: i64, +} + +impl WorkspaceIssuerTrustRecord { + pub fn from_bundle( + bundle: WorkspacePublicIdentityBundle, + trust_generation: u64, + now_unix: i64, + ) -> Result { + validate_bundle(&bundle)?; + if trust_generation == 0 { + return Err(WorkspaceIssuerTrustError::InvalidTrustGeneration); + } + Ok(Self { + workspace_id: bundle.workspace_id, + backend_url: bundle.backend_url, + key_id: bundle.key_id, + algorithm: bundle.algorithm, + public_key: bundle.public_key, + public_key_fingerprint: bundle.public_key_fingerprint, + identity_revision: bundle.revision, + trust_generation, + state: WorkspaceIssuerTrustState::Active, + registered_at_unix: now_unix, + updated_at_unix: now_unix, + }) + } + + pub fn public_bundle(&self) -> WorkspacePublicIdentityBundle { + WorkspacePublicIdentityBundle { + workspace_id: self.workspace_id.clone(), + backend_url: self.backend_url.clone(), + key_id: self.key_id.clone(), + algorithm: self.algorithm.clone(), + public_key: self.public_key.clone(), + public_key_fingerprint: self.public_key_fingerprint.clone(), + revision: self.identity_revision, + } + } +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum WorkspaceIssuerTrustError { + #[error("Workspace issuer trust already exists for `{0}`")] + AlreadyExists(String), + #[error("Workspace issuer trust is not registered for `{0}`")] + NotFound(String), + #[error("Workspace signing algorithm is not supported")] + UnsupportedAlgorithm, + #[error("Workspace signing public identity backend URL is invalid")] + InvalidBackendUrl, + #[error("Workspace signing public identity contains an invalid identifier")] + InvalidIdentifier, + #[error("Workspace signing public key is invalid")] + InvalidPublicKey, + #[error("Workspace signing public key fingerprint does not match the public key")] + PublicKeyFingerprintMismatch, + #[error("Workspace signing identity revision must be greater than zero")] + InvalidIdentityRevision, + #[error("Workspace issuer trust generation must be greater than zero")] + InvalidTrustGeneration, + #[error("Workspace signing identity replacement revision is stale")] + StaleIdentityRevision, + #[error("Workspace issuer trust generation overflow")] + TrustGenerationOverflow, +} + +pub fn validate_workspace_issuer_trust_records( + records: &[WorkspaceIssuerTrustRecord], +) -> Result<(), WorkspaceCapabilityVerificationError> { + let mut seen = std::collections::HashSet::new(); + for record in records { + validate_trust_record(record)?; + if !seen.insert(record.workspace_id.clone()) { + return Err(WorkspaceCapabilityVerificationError::DuplicateWorkspaceTrust); + } + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WorkspaceIssuerTrustMutation { + Added, + Replaced, + Revoked, + Unchanged, +} + +pub fn add_workspace_issuer_trust( + records: &mut Vec, + bundle: WorkspacePublicIdentityBundle, + now_unix: i64, +) -> Result<(WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord), WorkspaceIssuerTrustError> { + validate_bundle(&bundle)?; + if let Some(existing) = records + .iter() + .find(|record| record.workspace_id == bundle.workspace_id) + { + if existing.state == WorkspaceIssuerTrustState::Active && existing.public_bundle() == bundle + { + return Ok((WorkspaceIssuerTrustMutation::Unchanged, existing.clone())); + } + return Err(WorkspaceIssuerTrustError::AlreadyExists( + bundle.workspace_id, + )); + } + let record = WorkspaceIssuerTrustRecord::from_bundle(bundle, 1, now_unix)?; + records.push(record.clone()); + records.sort_by(|left, right| left.workspace_id.cmp(&right.workspace_id)); + Ok((WorkspaceIssuerTrustMutation::Added, record)) +} + +pub fn replace_workspace_issuer_trust( + records: &mut [WorkspaceIssuerTrustRecord], + bundle: WorkspacePublicIdentityBundle, + now_unix: i64, +) -> Result<(WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord), WorkspaceIssuerTrustError> { + validate_bundle(&bundle)?; + let record = records + .iter_mut() + .find(|record| record.workspace_id == bundle.workspace_id) + .ok_or_else(|| WorkspaceIssuerTrustError::NotFound(bundle.workspace_id.clone()))?; + if record.state == WorkspaceIssuerTrustState::Active && record.public_bundle() == bundle { + return Ok((WorkspaceIssuerTrustMutation::Unchanged, record.clone())); + } + if bundle.revision < record.identity_revision { + return Err(WorkspaceIssuerTrustError::StaleIdentityRevision); + } + if bundle.revision == record.identity_revision + && (bundle.key_id != record.key_id + || bundle.public_key != record.public_key + || bundle.public_key_fingerprint != record.public_key_fingerprint) + { + return Err(WorkspaceIssuerTrustError::StaleIdentityRevision); + } + let generation = record + .trust_generation + .checked_add(1) + .ok_or(WorkspaceIssuerTrustError::TrustGenerationOverflow)?; + let registered_at_unix = record.registered_at_unix; + *record = WorkspaceIssuerTrustRecord::from_bundle(bundle, generation, now_unix)?; + record.registered_at_unix = registered_at_unix; + Ok((WorkspaceIssuerTrustMutation::Replaced, record.clone())) +} + +pub fn revoke_workspace_issuer_trust( + records: &mut [WorkspaceIssuerTrustRecord], + workspace_id: &str, + now_unix: i64, +) -> Result<(WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord), WorkspaceIssuerTrustError> { + validate_id(workspace_id)?; + let record = records + .iter_mut() + .find(|record| record.workspace_id == workspace_id) + .ok_or_else(|| WorkspaceIssuerTrustError::NotFound(workspace_id.to_string()))?; + if record.state == WorkspaceIssuerTrustState::Revoked { + return Ok((WorkspaceIssuerTrustMutation::Unchanged, record.clone())); + } + record.trust_generation = record + .trust_generation + .checked_add(1) + .ok_or(WorkspaceIssuerTrustError::TrustGenerationOverflow)?; + record.state = WorkspaceIssuerTrustState::Revoked; + record.updated_at_unix = now_unix; + Ok((WorkspaceIssuerTrustMutation::Revoked, record.clone())) +} + +fn validate_bundle( + bundle: &WorkspacePublicIdentityBundle, +) -> Result<(), WorkspaceIssuerTrustError> { + validate_id(&bundle.workspace_id)?; + validate_id(&bundle.key_id)?; + let backend_url = url::Url::parse(&bundle.backend_url) + .map_err(|_| WorkspaceIssuerTrustError::InvalidBackendUrl)?; + if !matches!(backend_url.scheme(), "http" | "https") + || backend_url.host_str().is_none() + || !backend_url.username().is_empty() + || backend_url.password().is_some() + || backend_url.query().is_some() + || backend_url.fragment().is_some() + { + return Err(WorkspaceIssuerTrustError::InvalidBackendUrl); + } + if bundle.algorithm != WORKSPACE_SIGNING_ALGORITHM { + return Err(WorkspaceIssuerTrustError::UnsupportedAlgorithm); + } + if bundle.revision == 0 { + return Err(WorkspaceIssuerTrustError::InvalidIdentityRevision); + } + let public_key = crate::auth::decode_public_key(&bundle.public_key) + .map_err(|_| WorkspaceIssuerTrustError::InvalidPublicKey)?; + let fingerprint = format!("sha256:{}", hex_lower(&Sha256::digest(&public_key))); + if fingerprint != bundle.public_key_fingerprint { + return Err(WorkspaceIssuerTrustError::PublicKeyFingerprintMismatch); + } + Ok(()) +} + +fn validate_id(value: &str) -> Result<(), WorkspaceIssuerTrustError> { + if value.is_empty() + || value.len() > MAX_ID_BYTES + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(WorkspaceIssuerTrustError::InvalidIdentifier); + } + Ok(()) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceCapabilityClaims { + pub issuer: String, + pub issuer_workspace_id: String, + pub issuer_key_id: String, + pub issuer_identity_revision: u64, + pub trust_generation: u64, + pub binding_revision: u64, + pub runtime_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub worker_id: Option, + pub operation: String, + pub body_digest: String, + pub iat: i64, + pub exp: i64, + pub jti: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WorkspaceCapabilityExpectation<'a> { + pub workspace_id: &'a str, + pub binding_revision: u64, + pub runtime_id: &'a str, + pub worker_id: Option<&'a str>, + pub operation: &'a str, + pub body_digest: &'a str, + pub now_unix: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedWorkspaceCapability { + pub issuer: String, + pub workspace_id: String, + pub issuer_key_id: String, + pub issuer_identity_revision: u64, + pub trust_generation: u64, + pub binding_revision: u64, + pub runtime_id: String, + pub worker_id: Option, + pub operation: String, + pub token_id: String, + pub expires_at: i64, +} + +pub trait WorkspaceClaimReplayProtection: Send + Sync { + fn consume_once( + &self, + workspace_id: &str, + trust_generation: u64, + token_id: &str, + expires_at: i64, + now_unix: i64, + ) -> Result; +} + +#[derive(Default)] +pub struct InMemoryWorkspaceClaimReplayProtection { + consumed: Mutex>, +} + +impl WorkspaceClaimReplayProtection for InMemoryWorkspaceClaimReplayProtection { + fn consume_once( + &self, + workspace_id: &str, + trust_generation: u64, + token_id: &str, + expires_at: i64, + now_unix: i64, + ) -> Result { + let mut consumed = self + .consumed + .lock() + .map_err(|_| WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable)?; + consumed.retain(|_, expiry| *expiry > now_unix); + let key = ( + workspace_id.to_string(), + trust_generation, + token_id.to_string(), + ); + if consumed.contains_key(&key) { + return Ok(false); + } + consumed.insert(key, expires_at); + Ok(true) + } +} + +#[derive(Clone)] +pub struct WorkspaceCapabilityVerifier { + records: Arc<[WorkspaceIssuerTrustRecord]>, + replay: Arc, +} + +impl fmt::Debug for WorkspaceCapabilityVerifier { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WorkspaceCapabilityVerifier") + .field("trusted_workspace_count", &self.records.len()) + .finish_non_exhaustive() + } +} + +impl WorkspaceCapabilityVerifier { + pub fn new( + records: Vec, + replay: Arc, + ) -> Result { + if records.is_empty() { + return Err(WorkspaceCapabilityVerificationError::TrustAuthorityMissing); + } + validate_workspace_issuer_trust_records(&records)?; + Ok(Self { + records: records.into(), + replay, + }) + } + + pub fn verify( + &self, + token: &str, + expected: &WorkspaceCapabilityExpectation<'_>, + ) -> Result { + if token.len() > MAX_TOKEN_BYTES { + return Err(WorkspaceCapabilityVerificationError::MalformedToken); + } + let (payload, signature) = split_workspace_token(token)?; + let claims_json = URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| WorkspaceCapabilityVerificationError::MalformedToken)?; + let claims: WorkspaceCapabilityClaims = serde_json::from_slice(&claims_json) + .map_err(|_| WorkspaceCapabilityVerificationError::MalformedClaims)?; + validate_claim_shape(&claims)?; + + if claims.issuer_workspace_id != expected.workspace_id { + return Err(WorkspaceCapabilityVerificationError::WrongWorkspace); + } + let record = self + .records + .iter() + .find(|record| record.workspace_id == claims.issuer_workspace_id) + .ok_or(WorkspaceCapabilityVerificationError::UnknownWorkspaceIssuer)?; + if record.state != WorkspaceIssuerTrustState::Active { + return Err(WorkspaceCapabilityVerificationError::IssuerRevoked); + } + if record.backend_url != claims.issuer { + return Err(WorkspaceCapabilityVerificationError::WrongIssuer); + } + if record.trust_generation != claims.trust_generation { + return Err(WorkspaceCapabilityVerificationError::StaleTrustGeneration); + } + if record.key_id != claims.issuer_key_id { + return Err(WorkspaceCapabilityVerificationError::WrongIssuerKey); + } + if record.identity_revision != claims.issuer_identity_revision { + return Err(WorkspaceCapabilityVerificationError::StaleIdentityRevision); + } + + let public_key = crate::auth::decode_public_key(&record.public_key) + .map_err(|_| WorkspaceCapabilityVerificationError::TrustRecordCorrupt)?; + let signing_input = format!("{WORKSPACE_SIGNING_INPUT_PREFIX}{payload}"); + UnparsedPublicKey::new(&ED25519, public_key) + .verify(signing_input.as_bytes(), &signature) + .map_err(|_| WorkspaceCapabilityVerificationError::InvalidSignature)?; + + if claims.runtime_id != expected.runtime_id { + return Err(WorkspaceCapabilityVerificationError::WrongRuntime); + } + if claims.binding_revision != expected.binding_revision { + return Err(WorkspaceCapabilityVerificationError::StaleBindingRevision); + } + if claims.worker_id.as_deref() != expected.worker_id { + return Err(WorkspaceCapabilityVerificationError::WrongWorker); + } + if claims.operation != expected.operation { + return Err(WorkspaceCapabilityVerificationError::WrongOperation); + } + if claims.body_digest != expected.body_digest { + return Err(WorkspaceCapabilityVerificationError::WrongBodyDigest); + } + if claims.exp <= expected.now_unix { + return Err(WorkspaceCapabilityVerificationError::Expired); + } + if claims.iat > expected.now_unix.saturating_add(MAX_CLOCK_SKEW_SECONDS) { + return Err(WorkspaceCapabilityVerificationError::IssuedInFuture); + } + if claims.exp <= claims.iat + || claims.exp.saturating_sub(claims.iat) > MAX_TOKEN_LIFETIME_SECONDS + { + return Err(WorkspaceCapabilityVerificationError::InvalidLifetime); + } + if !self.replay.consume_once( + &claims.issuer_workspace_id, + claims.trust_generation, + &claims.jti, + claims.exp, + expected.now_unix, + )? { + return Err(WorkspaceCapabilityVerificationError::Replay); + } + + Ok(VerifiedWorkspaceCapability { + issuer: claims.issuer, + workspace_id: claims.issuer_workspace_id, + issuer_key_id: claims.issuer_key_id, + issuer_identity_revision: claims.issuer_identity_revision, + trust_generation: claims.trust_generation, + binding_revision: claims.binding_revision, + runtime_id: claims.runtime_id, + worker_id: claims.worker_id, + operation: claims.operation, + token_id: claims.jti, + expires_at: claims.exp, + }) + } +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum WorkspaceCapabilityVerificationError { + #[error("Workspace issuer trust authority is not configured")] + TrustAuthorityMissing, + #[error("Workspace issuer trust contains duplicate Workspace identities")] + DuplicateWorkspaceTrust, + #[error("Workspace issuer trust record is corrupt")] + TrustRecordCorrupt, + #[error("Workspace capability token is malformed")] + MalformedToken, + #[error("Workspace capability claims are malformed")] + MalformedClaims, + #[error("Workspace capability claim identifier is invalid")] + InvalidIdentifier, + #[error("Workspace capability claim body digest is invalid")] + InvalidBodyDigest, + #[error("Workspace capability issuer is not trusted")] + UnknownWorkspaceIssuer, + #[error("Workspace capability issuer does not match trust")] + WrongIssuer, + #[error("Workspace capability issuer trust is revoked")] + IssuerRevoked, + #[error("Workspace capability trust generation is stale")] + StaleTrustGeneration, + #[error("Workspace capability issuer key does not match trust")] + WrongIssuerKey, + #[error("Workspace capability identity revision is stale")] + StaleIdentityRevision, + #[error("Workspace capability signature is invalid")] + InvalidSignature, + #[error("Workspace capability targets another Workspace")] + WrongWorkspace, + #[error("Workspace capability targets another Runtime")] + WrongRuntime, + #[error("Workspace capability binding revision is stale")] + StaleBindingRevision, + #[error("Workspace capability targets another Worker")] + WrongWorker, + #[error("Workspace capability does not authorize this operation")] + WrongOperation, + #[error("Workspace capability does not bind this request body")] + WrongBodyDigest, + #[error("Workspace capability has expired")] + Expired, + #[error("Workspace capability was issued in the future")] + IssuedInFuture, + #[error("Workspace capability lifetime is invalid")] + InvalidLifetime, + #[error("Workspace capability was already consumed")] + Replay, + #[error("Workspace capability replay authority is unavailable")] + ReplayAuthorityUnavailable, +} + +pub fn issue_workspace_capability_token( + signing_key: &Ed25519KeyPair, + claims: &WorkspaceCapabilityClaims, +) -> Result { + validate_claim_shape(claims)?; + let payload = serde_json::to_vec(claims) + .map_err(|_| WorkspaceCapabilityVerificationError::MalformedClaims)?; + let payload = URL_SAFE_NO_PAD.encode(payload); + let signing_input = format!("{WORKSPACE_SIGNING_INPUT_PREFIX}{payload}"); + let signature = signing_key.sign(signing_input.as_bytes()); + Ok(format!( + "{WORKSPACE_TOKEN_PREFIX}.{payload}.{}", + URL_SAFE_NO_PAD.encode(signature.as_ref()) + )) +} + +fn split_workspace_token( + token: &str, +) -> Result<(&str, Vec), WorkspaceCapabilityVerificationError> { + let mut parts = token.split('.'); + if parts.next() != Some(WORKSPACE_TOKEN_PREFIX) { + return Err(WorkspaceCapabilityVerificationError::MalformedToken); + } + let payload = parts + .next() + .ok_or(WorkspaceCapabilityVerificationError::MalformedToken)?; + let signature = parts + .next() + .ok_or(WorkspaceCapabilityVerificationError::MalformedToken)?; + if parts.next().is_some() || payload.is_empty() || signature.is_empty() { + return Err(WorkspaceCapabilityVerificationError::MalformedToken); + } + let signature = URL_SAFE_NO_PAD + .decode(signature) + .map_err(|_| WorkspaceCapabilityVerificationError::MalformedToken)?; + Ok((payload, signature)) +} + +fn validate_trust_record( + record: &WorkspaceIssuerTrustRecord, +) -> Result<(), WorkspaceCapabilityVerificationError> { + validate_bundle(&WorkspacePublicIdentityBundle { + workspace_id: record.workspace_id.clone(), + backend_url: record.backend_url.clone(), + key_id: record.key_id.clone(), + algorithm: record.algorithm.clone(), + public_key: record.public_key.clone(), + public_key_fingerprint: record.public_key_fingerprint.clone(), + revision: record.identity_revision, + }) + .map_err(|_| WorkspaceCapabilityVerificationError::TrustRecordCorrupt)?; + if record.trust_generation == 0 { + return Err(WorkspaceCapabilityVerificationError::TrustRecordCorrupt); + } + Ok(()) +} + +fn validate_claim_shape( + claims: &WorkspaceCapabilityClaims, +) -> Result<(), WorkspaceCapabilityVerificationError> { + let issuer = url::Url::parse(&claims.issuer) + .map_err(|_| WorkspaceCapabilityVerificationError::WrongIssuer)?; + if !matches!(issuer.scheme(), "http" | "https") + || issuer.host_str().is_none() + || !issuer.username().is_empty() + || issuer.password().is_some() + || issuer.query().is_some() + || issuer.fragment().is_some() + { + return Err(WorkspaceCapabilityVerificationError::WrongIssuer); + } + for value in [ + claims.issuer_workspace_id.as_str(), + claims.issuer_key_id.as_str(), + claims.runtime_id.as_str(), + claims.jti.as_str(), + ] { + if value.is_empty() + || value.len() > MAX_ID_BYTES + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(WorkspaceCapabilityVerificationError::InvalidIdentifier); + } + } + if let Some(worker_id) = &claims.worker_id { + if worker_id.is_empty() + || worker_id.len() > MAX_ID_BYTES + || worker_id.trim() != worker_id + || worker_id.chars().any(char::is_control) + { + return Err(WorkspaceCapabilityVerificationError::InvalidIdentifier); + } + } + if claims.issuer_identity_revision == 0 + || claims.trust_generation == 0 + || claims.binding_revision == 0 + { + return Err(WorkspaceCapabilityVerificationError::MalformedClaims); + } + if claims.operation.is_empty() + || claims.operation.len() > MAX_OPERATION_BYTES + || claims.operation.trim() != claims.operation + || claims.operation.chars().any(char::is_control) + { + return Err(WorkspaceCapabilityVerificationError::MalformedClaims); + } + if !is_sha256_digest(&claims.body_digest) { + return Err(WorkspaceCapabilityVerificationError::InvalidBodyDigest); + } + Ok(()) +} + +fn is_sha256_digest(value: &str) -> bool { + value.len() == 71 + && value.starts_with("sha256:") + && value[7..] + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +pub fn workspace_request_body_digest(body: &[u8]) -> String { + format!("sha256:{}", hex_lower(&Sha256::digest(body))) +} + +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 +} + +pub enum RuntimeCapabilityVerifier { + LegacyServer(RuntimeHttpAuthConfig), + WorkspaceIssuer(WorkspaceCapabilityVerifier), +} + +pub enum RuntimeCapabilityVerification<'a> { + LegacyServer { + required_permission: Option<&'a str>, + now_seconds: u64, + }, + WorkspaceIssuer(WorkspaceCapabilityExpectation<'a>), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum VerifiedRuntimeCapability { + LegacyServer(RuntimeAuthContext), + WorkspaceIssuer(VerifiedWorkspaceCapability), +} + +#[derive(Debug, thiserror::Error)] +pub enum RuntimeCapabilityVerificationError { + #[error("Runtime capability verifier mode does not match the verification request")] + ModeMismatch, + #[error(transparent)] + LegacyServer(#[from] RuntimeAuthError), + #[error(transparent)] + WorkspaceIssuer(#[from] WorkspaceCapabilityVerificationError), +} + +impl RuntimeCapabilityVerifier { + pub fn verify( + &self, + token: &str, + verification: RuntimeCapabilityVerification<'_>, + ) -> Result { + match (self, verification) { + ( + Self::LegacyServer(config), + RuntimeCapabilityVerification::LegacyServer { + required_permission, + now_seconds, + }, + ) => crate::auth::verify_capability_token( + config, + token, + required_permission, + now_seconds, + ) + .map(VerifiedRuntimeCapability::LegacyServer) + .map_err(Into::into), + ( + Self::WorkspaceIssuer(verifier), + RuntimeCapabilityVerification::WorkspaceIssuer(expectation), + ) => verifier + .verify(token, &expectation) + .map(VerifiedRuntimeCapability::WorkspaceIssuer) + .map_err(Into::into), + _ => Err(RuntimeCapabilityVerificationError::ModeMismatch), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::RuntimeIdentityMaterial; + + fn identity( + workspace_id: &str, + key_id: &str, + revision: u64, + ) -> (Ed25519KeyPair, WorkspacePublicIdentityBundle) { + let material = RuntimeIdentityMaterial::generate(key_id).unwrap(); + let signing_key = material.signing_key().unwrap(); + let public_key_fingerprint = format!( + "sha256:{}", + hex_lower(&Sha256::digest( + crate::auth::decode_public_key(&material.public_key).unwrap() + )) + ); + ( + signing_key, + WorkspacePublicIdentityBundle { + workspace_id: workspace_id.to_string(), + backend_url: "https://backend.example.test".to_string(), + key_id: key_id.to_string(), + algorithm: WORKSPACE_SIGNING_ALGORITHM.to_string(), + public_key: material.public_key, + public_key_fingerprint, + revision, + }, + ) + } + + fn claims(record: &WorkspaceIssuerTrustRecord, jti: &str) -> WorkspaceCapabilityClaims { + WorkspaceCapabilityClaims { + issuer: record.backend_url.clone(), + issuer_workspace_id: record.workspace_id.clone(), + issuer_key_id: record.key_id.clone(), + issuer_identity_revision: record.identity_revision, + trust_generation: record.trust_generation, + binding_revision: 7, + runtime_id: "runtime-1".to_string(), + worker_id: Some("worker-1".to_string()), + operation: "POST /v1/workers/worker-1/submit".to_string(), + body_digest: workspace_request_body_digest(br#"{"content":"hello"}"#), + iat: 1_000, + exp: 1_060, + jti: jti.to_string(), + } + } + + fn expectation<'a>(body_digest: &'a str) -> WorkspaceCapabilityExpectation<'a> { + WorkspaceCapabilityExpectation { + workspace_id: "workspace-1", + binding_revision: 7, + runtime_id: "runtime-1", + worker_id: Some("worker-1"), + operation: "POST /v1/workers/worker-1/submit", + body_digest, + now_unix: 1_001, + } + } + + #[test] + fn trust_mutations_are_idempotent_generation_fenced_and_reject_stale_keys() { + let (_, bundle_v1) = identity("workspace-1", "WK-1", 1); + let mut records = Vec::new(); + let (mutation, first) = + add_workspace_issuer_trust(&mut records, bundle_v1.clone(), 10).unwrap(); + assert_eq!(mutation, WorkspaceIssuerTrustMutation::Added); + assert_eq!(first.trust_generation, 1); + let (mutation, replay) = + add_workspace_issuer_trust(&mut records, bundle_v1.clone(), 11).unwrap(); + assert_eq!(mutation, WorkspaceIssuerTrustMutation::Unchanged); + assert_eq!(replay, first); + + let (_, stale_other_key) = identity("workspace-1", "WK-stale", 1); + assert_eq!( + replace_workspace_issuer_trust(&mut records, stale_other_key, 12).unwrap_err(), + WorkspaceIssuerTrustError::StaleIdentityRevision + ); + let (_, bundle_v2) = identity("workspace-1", "WK-2", 2); + let (mutation, replaced) = + replace_workspace_issuer_trust(&mut records, bundle_v2, 13).unwrap(); + assert_eq!(mutation, WorkspaceIssuerTrustMutation::Replaced); + assert_eq!(replaced.trust_generation, 2); + assert_eq!(replaced.registered_at_unix, 10); + let (_, revoked) = revoke_workspace_issuer_trust(&mut records, "workspace-1", 14).unwrap(); + assert_eq!(revoked.trust_generation, 3); + assert_eq!(revoked.state, WorkspaceIssuerTrustState::Revoked); + let (mutation, same) = + revoke_workspace_issuer_trust(&mut records, "workspace-1", 15).unwrap(); + assert_eq!(mutation, WorkspaceIssuerTrustMutation::Unchanged); + assert_eq!(same, revoked); + } + + #[test] + fn verifier_binds_workspace_key_generation_runtime_worker_operation_and_body() { + let (key_1, bundle_1) = identity("workspace-1", "WK-1", 1); + let (key_2, bundle_2) = identity("workspace-2", "WK-2", 1); + let record_1 = WorkspaceIssuerTrustRecord::from_bundle(bundle_1, 1, 1).unwrap(); + let record_2 = WorkspaceIssuerTrustRecord::from_bundle(bundle_2, 1, 1).unwrap(); + let verifier = WorkspaceCapabilityVerifier::new( + vec![record_1.clone(), record_2.clone()], + Arc::new(InMemoryWorkspaceClaimReplayProtection::default()), + ) + .unwrap(); + let expected_body = workspace_request_body_digest(br#"{"content":"hello"}"#); + let token = + issue_workspace_capability_token(&key_1, &claims(&record_1, "token-ok")).unwrap(); + assert_eq!( + verifier + .verify(&token, &expectation(&expected_body)) + .unwrap() + .workspace_id, + "workspace-1" + ); + + let workspace_2_token = + issue_workspace_capability_token(&key_2, &claims(&record_2, "workspace-2")).unwrap(); + assert_eq!( + verifier + .verify(&workspace_2_token, &expectation(&expected_body)) + .unwrap_err(), + WorkspaceCapabilityVerificationError::WrongWorkspace + ); + let workspace_2_expectation = WorkspaceCapabilityExpectation { + workspace_id: "workspace-2", + binding_revision: 7, + runtime_id: "runtime-1", + worker_id: Some("worker-1"), + operation: "POST /v1/workers/worker-1/submit", + body_digest: &expected_body, + now_unix: 1_001, + }; + assert_eq!( + verifier + .verify(&workspace_2_token, &workspace_2_expectation) + .unwrap() + .workspace_id, + "workspace-2" + ); + + let wrong_key_token = + issue_workspace_capability_token(&key_2, &claims(&record_1, "wrong-key")).unwrap(); + assert_eq!( + verifier + .verify(&wrong_key_token, &expectation(&expected_body)) + .unwrap_err(), + WorkspaceCapabilityVerificationError::InvalidSignature + ); + + type ClaimsMutation = fn(&mut WorkspaceCapabilityClaims); + for (name, mutate, expected) in [ + ( + "runtime", + (|claims: &mut WorkspaceCapabilityClaims| { + claims.runtime_id = "runtime-2".to_string() + }) as ClaimsMutation, + WorkspaceCapabilityVerificationError::WrongRuntime, + ), + ( + "worker", + (|claims: &mut WorkspaceCapabilityClaims| { + claims.worker_id = Some("worker-2".to_string()) + }) as ClaimsMutation, + WorkspaceCapabilityVerificationError::WrongWorker, + ), + ( + "binding", + (|claims: &mut WorkspaceCapabilityClaims| claims.binding_revision = 8) + as ClaimsMutation, + WorkspaceCapabilityVerificationError::StaleBindingRevision, + ), + ( + "issuer", + (|claims: &mut WorkspaceCapabilityClaims| { + claims.issuer = "https://other-backend.example.test".to_string() + }) as ClaimsMutation, + WorkspaceCapabilityVerificationError::WrongIssuer, + ), + ( + "operation", + (|claims: &mut WorkspaceCapabilityClaims| { + claims.operation = "DELETE /v1/workers/worker-1".to_string() + }) as ClaimsMutation, + WorkspaceCapabilityVerificationError::WrongOperation, + ), + ] { + let mut changed = claims(&record_1, name); + mutate(&mut changed); + let token = issue_workspace_capability_token(&key_1, &changed).unwrap(); + assert_eq!( + verifier + .verify(&token, &expectation(&expected_body)) + .unwrap_err(), + expected + ); + } + let token = + issue_workspace_capability_token(&key_1, &claims(&record_1, "wrong-body")).unwrap(); + let wrong_body = workspace_request_body_digest(b"different"); + assert_eq!( + verifier + .verify(&token, &expectation(&wrong_body)) + .unwrap_err(), + WorkspaceCapabilityVerificationError::WrongBodyDigest + ); + } + + #[test] + fn verifier_rejects_replay_revocation_stale_generation_expiry_and_future_claims() { + let (key, bundle) = identity("workspace-1", "WK-1", 1); + let record = WorkspaceIssuerTrustRecord::from_bundle(bundle, 2, 1).unwrap(); + let replay = Arc::new(InMemoryWorkspaceClaimReplayProtection::default()); + let verifier = WorkspaceCapabilityVerifier::new(vec![record.clone()], replay).unwrap(); + let body = workspace_request_body_digest(br#"{"content":"hello"}"#); + let token = issue_workspace_capability_token(&key, &claims(&record, "replay")).unwrap(); + verifier.verify(&token, &expectation(&body)).unwrap(); + assert_eq!( + verifier.verify(&token, &expectation(&body)).unwrap_err(), + WorkspaceCapabilityVerificationError::Replay + ); + + let mut stale = claims(&record, "stale"); + stale.trust_generation = 1; + let token = issue_workspace_capability_token(&key, &stale).unwrap(); + assert_eq!( + verifier.verify(&token, &expectation(&body)).unwrap_err(), + WorkspaceCapabilityVerificationError::StaleTrustGeneration + ); + + let mut expired = claims(&record, "expired"); + expired.iat = 900; + expired.exp = 950; + let token = issue_workspace_capability_token(&key, &expired).unwrap(); + assert_eq!( + verifier.verify(&token, &expectation(&body)).unwrap_err(), + WorkspaceCapabilityVerificationError::Expired + ); + + let mut future = claims(&record, "future"); + future.iat = 1_100; + future.exp = 1_160; + let token = issue_workspace_capability_token(&key, &future).unwrap(); + assert_eq!( + verifier.verify(&token, &expectation(&body)).unwrap_err(), + WorkspaceCapabilityVerificationError::IssuedInFuture + ); + + let mut revoked = record; + revoked.state = WorkspaceIssuerTrustState::Revoked; + revoked.trust_generation += 1; + let revoked_verifier = WorkspaceCapabilityVerifier::new( + vec![revoked.clone()], + Arc::new(InMemoryWorkspaceClaimReplayProtection::default()), + ) + .unwrap(); + let token = issue_workspace_capability_token(&key, &claims(&revoked, "revoked")).unwrap(); + assert_eq!( + revoked_verifier + .verify(&token, &expectation(&body)) + .unwrap_err(), + WorkspaceCapabilityVerificationError::IssuerRevoked + ); + } + + #[test] + fn verifier_selection_never_falls_back_between_legacy_and_workspace_authority() { + assert_eq!( + WorkspaceCapabilityVerifier::new( + Vec::new(), + Arc::new(InMemoryWorkspaceClaimReplayProtection::default()), + ) + .unwrap_err(), + WorkspaceCapabilityVerificationError::TrustAuthorityMissing + ); + let (key, bundle) = identity("workspace-1", "WK-1", 1); + let record = WorkspaceIssuerTrustRecord::from_bundle(bundle, 1, 1).unwrap(); + let workspace = RuntimeCapabilityVerifier::WorkspaceIssuer( + WorkspaceCapabilityVerifier::new( + vec![record.clone()], + Arc::new(InMemoryWorkspaceClaimReplayProtection::default()), + ) + .unwrap(), + ); + let token = issue_workspace_capability_token(&key, &claims(&record, "typed-mode")).unwrap(); + assert!(matches!( + workspace.verify( + &token, + RuntimeCapabilityVerification::LegacyServer { + required_permission: None, + now_seconds: 1_001, + }, + ), + Err(RuntimeCapabilityVerificationError::ModeMismatch) + )); + assert!(matches!( + RuntimeCapabilityVerifier::WorkspaceIssuer( + WorkspaceCapabilityVerifier::new( + vec![record], + Arc::new(InMemoryWorkspaceClaimReplayProtection::default()), + ) + .unwrap(), + ) + .verify( + &token, + RuntimeCapabilityVerification::WorkspaceIssuer(expectation( + &workspace_request_body_digest(br#"{"content":"hello"}"#), + )), + ), + Ok(VerifiedRuntimeCapability::WorkspaceIssuer(_)) + )); + } +} diff --git a/docs/development/server-runtime-auth.md b/docs/development/server-runtime-auth.md index 5439de6d..b92a6175 100644 --- a/docs/development/server-runtime-auth.md +++ b/docs/development/server-runtime-auth.md @@ -65,7 +65,7 @@ By default, Runtime auth state is stored at: /runtime/auth.toml ``` -If the Runtime process is launched with `--fs-root` or `--fs-runtime-dir`, pass the same flags to every `identity` and `trust-server` command. Otherwise the setup command may write an auth file that the server process never reads. +If the Runtime process is launched with `--fs-root` or `--fs-runtime-dir`, pass the same flags to every `identity`, `trust-server`, and `trust-workspace` command. Otherwise the setup command may write an auth file that the server process never reads. Example with explicit Runtime storage: @@ -104,6 +104,53 @@ Verify: yoi-runtime trust-server list --json ``` +## Workspace issuer trust foundation + +Workspace signing identity is Workspace-scoped. Provision the identity through the authenticated owner-only Workspace Settings operation, then export the public bundle from: + +```text +GET /api/w//settings/workspace/signing-identity +``` + +Save the response's `identity` object—not the outer response wrapper—as `workspace-public-identity.json`, and transfer only that document to the Runtime host: + +```bash +yoi-runtime trust-workspace add \ + --bundle workspace-public-identity.json \ + --fs-root /var/lib/yoi-runtime +``` + +The bundle contains `workspace_id`, `backend_url`, `key_id`, `algorithm`, `public_key`, `public_key_fingerprint`, and the Workspace signing identity `revision`. It never contains the Workspace private key. Do not transfer the Server-side private-material file or place private material in Runtime configuration. + +Inspect the Runtime trust records without exposing private material: + +```bash +yoi-runtime trust-workspace list --fs-root /var/lib/yoi-runtime +yoi-runtime trust-workspace show \ + --workspace-id '' \ + --fs-root /var/lib/yoi-runtime +``` + +An exact repeated `add` is idempotent. A different bundle for an existing Workspace is rejected; use the explicit `replace` operation after verifying the new public fingerprint out of band: + +```bash +yoi-runtime trust-workspace replace \ + --bundle workspace-public-identity-v2.json \ + --fs-root /var/lib/yoi-runtime +``` + +Runtime increments a local `trust_generation` on replacement and revocation. Signed claims bind the issuer URL, Workspace signing identity revision, Runtime trust generation, live Workspace–Runtime `binding_revision`, Runtime/Worker target, operation, request-body SHA-256 digest, expiry, and one-time `jti`. Claims are accepted only when the Workspace identity revision, Runtime trust generation, and caller-supplied current binding revision all match exactly. Revoke trust without deleting its generation fence: + +```bash +yoi-runtime trust-workspace revoke \ + --workspace-id '' \ + --fs-root /var/lib/yoi-runtime +``` + +For rotation, create and export the new Workspace identity first, verify its fingerprint, replace Runtime trust, update the Workspace–Runtime binding authority, and only then issue claims under the new key/generation. For emergency revocation, revoke Runtime trust first and stop issuing claims; reactivation requires an explicit `replace` with an active public bundle. Runtime auth state is written atomically with private file permissions and survives restart; malformed trust state fails closed. + +This command establishes the Runtime-side trust and claim-verifier foundation only. The Runtime auth store is read during process startup; once signed verification is connected, a controlled Runtime restart will be required before a changed trust record affects verification. Do not restart a live Runtime until its active Worker lifecycle has been handled. Until the signed-verification cutover Ticket is integrated, existing remote control traffic continues to select the legacy trusted-Server verifier explicitly; Runtime never falls back from one verifier mode to the other. + ## 4. Register the Runtime public key and endpoint on Server On the Workspace Server host, register the Runtime public key copied from `yoi-runtime identity show --json`: