diff --git a/crates/worker-runtime/src/auth.rs b/crates/worker-runtime/src/auth.rs index 05279ea5..f6ef1b7d 100644 --- a/crates/worker-runtime/src/auth.rs +++ b/crates/worker-runtime/src/auth.rs @@ -2,6 +2,7 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use ring::rand::{SecureRandom, SystemRandom}; use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey}; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fmt; @@ -68,6 +69,74 @@ pub enum RuntimeAuthError { WrongMutationTarget, } +pub(crate) struct SignedJsonToken { + pub payload: String, + pub signature: Vec, + pub claims: T, +} + +pub(crate) fn sign_json_token( + token_prefix: &str, + signing_input_prefix: &str, + signing_key: &Ed25519KeyPair, + claims: &T, +) -> Result { + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims)?); + let signing_input = format!("{signing_input_prefix}{payload}"); + let signature = signing_key.sign(signing_input.as_bytes()); + Ok(format!( + "{token_prefix}.{payload}.{}", + URL_SAFE_NO_PAD.encode(signature.as_ref()) + )) +} + +pub(crate) fn decode_signed_json_token( + token: &str, + expected_prefix: &str, +) -> Result, RuntimeAuthError> { + let (prefix, payload, signature) = split_three_part_token(token)?; + if prefix != expected_prefix { + return Err(RuntimeAuthError::InvalidTokenFormat); + } + let signature = URL_SAFE_NO_PAD.decode(signature)?; + let claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?; + Ok(SignedJsonToken { + payload: payload.to_string(), + signature, + claims, + }) +} + +pub(crate) fn verify_signed_json_token( + signing_input_prefix: &str, + payload: &str, + signature: &[u8], + public_key: &str, +) -> Result<(), RuntimeAuthError> { + let public_key = decode_public_key(public_key)?; + let signing_input = format!("{signing_input_prefix}{payload}"); + UnparsedPublicKey::new(&ED25519, public_key) + .verify(signing_input.as_bytes(), signature) + .map_err(|_| RuntimeAuthError::InvalidSignature) +} + +fn split_three_part_token(token: &str) -> Result<(&str, &str, &str), RuntimeAuthError> { + let mut parts = token.split('.'); + let prefix = parts.next().unwrap_or_default(); + let payload = parts.next().unwrap_or_default(); + let signature = parts.next().unwrap_or_default(); + if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() { + return Err(RuntimeAuthError::InvalidTokenFormat); + } + Ok((prefix, payload, signature)) +} + +pub(crate) fn is_request_body_digest(value: &str) -> bool { + URL_SAFE_NO_PAD + .decode(value) + .is_ok_and(|decoded| decoded.len() == 32 && URL_SAFE_NO_PAD.encode(decoded) == value) +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeIdentityMaterial { pub identity_id: String, @@ -323,28 +392,22 @@ impl RuntimeRequestSourceSigner { exp: now_unix.saturating_add(ttl_seconds), jti: new_token_id()?, }; - let payload = serde_json::to_vec(&claims)?; - let payload = URL_SAFE_NO_PAD.encode(payload); - let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}"); let private = decode_private_key(&self.private_key)?; let key_pair = Ed25519KeyPair::from_pkcs8(&private) .map_err(|_| RuntimeAuthError::InvalidPrivateKey)?; - let signature = URL_SAFE_NO_PAD.encode(key_pair.sign(signing_input.as_bytes()).as_ref()); - Ok(format!( - "{RUNTIME_REQUEST_SOURCE_PROOF_PREFIX}.{payload}.{signature}" - )) + sign_json_token( + RUNTIME_REQUEST_SOURCE_PROOF_PREFIX, + RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX, + &key_pair, + &claims, + ) } } pub fn decode_runtime_request_source_claims( proof: &str, ) -> Result { - let (prefix, payload, _signature) = split_runtime_request_source_proof(proof)?; - if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX { - return Err(RuntimeAuthError::InvalidTokenFormat); - } - let payload = URL_SAFE_NO_PAD.decode(payload)?; - serde_json::from_slice(&payload).map_err(RuntimeAuthError::from) + Ok(decode_signed_json_token(proof, RUNTIME_REQUEST_SOURCE_PROOF_PREFIX)?.claims) } pub fn verify_runtime_request_source( @@ -352,17 +415,17 @@ pub fn verify_runtime_request_source( public_key: &str, expected: &RuntimeRequestSourceExpectation<'_>, ) -> Result { - let (prefix, payload, signature) = split_runtime_request_source_proof(proof)?; - if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX { - return Err(RuntimeAuthError::InvalidTokenFormat); - } - let signature = URL_SAFE_NO_PAD.decode(signature)?; - let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}"); - let public_key = decode_public_key(public_key)?; - UnparsedPublicKey::new(&ED25519, public_key) - .verify(signing_input.as_bytes(), &signature) - .map_err(|_| RuntimeAuthError::InvalidSignature)?; - let claims = decode_runtime_request_source_claims(proof)?; + let signed = decode_signed_json_token::( + proof, + RUNTIME_REQUEST_SOURCE_PROOF_PREFIX, + )?; + verify_signed_json_token( + RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX, + &signed.payload, + &signed.signature, + public_key, + )?; + let claims = signed.claims; if claims.iss != expected.identity_id || claims.aud != expected.audience || claims.workspace_id != expected.workspace_id @@ -380,17 +443,6 @@ pub fn verify_runtime_request_source( Ok(claims) } -fn split_runtime_request_source_proof(proof: &str) -> Result<(&str, &str, &str), RuntimeAuthError> { - let mut parts = proof.split('.'); - let prefix = parts.next().unwrap_or_default(); - let payload = parts.next().unwrap_or_default(); - let signature = parts.next().unwrap_or_default(); - if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() { - return Err(RuntimeAuthError::InvalidTokenFormat); - } - Ok((prefix, payload, signature)) -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkerMutationSourceClaims { pub iss: String, diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index 1d2fa107..c6b7e2b1 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -26,9 +26,9 @@ 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, + MAX_WORKSPACE_ISSUER_TRUST_RECORDS, WorkspaceIssuerTrustError, WorkspaceIssuerTrustMutation, + WorkspaceIssuerTrustRecord, add_workspace_issuer_trust, replace_workspace_issuer_trust, + revoke_workspace_issuer_trust, validate_workspace_issuer_trust_records, }; use worker_runtime::{Runtime, RuntimeOptions}; @@ -516,7 +516,18 @@ impl From for ProcessError { } } +const MAX_RUNTIME_AUTH_FILE_BYTES: u64 = 8 * 1024 * 1024; const MAX_WORKSPACE_IDENTITY_BUNDLE_BYTES: u64 = 64 * 1024; +const DEFAULT_TRUST_WORKSPACE_LIST_LIMIT: usize = 100; +const MAX_TRUST_WORKSPACE_LIST_LIMIT: usize = 100; + +#[derive(Serialize)] +struct WorkspaceIssuerTrustListPage { + offset: usize, + limit: usize, + total: usize, + records: Vec, +} #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] struct RuntimeAuthFile { @@ -550,7 +561,7 @@ fn run_trust_workspace_command(mut args: VecDeque) -> Result<(), Process 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 config = parse_trust_workspace_storage_flags(&mut args)?; let auth_path = runtime_auth_path(&config); let mut auth = read_runtime_auth_file(&auth_path)?; let bundle_bytes = read_workspace_identity_bundle(Path::new(&bundle_path))?; @@ -563,7 +574,7 @@ fn run_trust_workspace_command(mut args: VecDeque) -> Result<(), Process } else { replace_workspace_issuer_trust(&mut auth.workspace_issuers, bundle, now_unix) } - .map_err(|error| ProcessError::auth(error.to_string()))?; + .map_err(workspace_trust_process_error)?; if mutation != WorkspaceIssuerTrustMutation::Unchanged { write_runtime_auth_file(&auth_path, &auth)?; } @@ -571,29 +582,32 @@ fn run_trust_workspace_command(mut args: VecDeque) -> Result<(), Process Ok(()) } "list" => { - let config = parse_auth_storage_flags(&mut args)?; + let (config, offset, limit) = parse_trust_workspace_list_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"))?; + let total = records.len(); + let records = records.into_iter().skip(offset).take(limit).collect(); + let output = serde_json::to_string_pretty(&WorkspaceIssuerTrustListPage { + offset, + limit, + total, + 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 config = parse_trust_workspace_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}`" - )) - })?; + .ok_or_else(|| ProcessError::auth("Workspace issuer trust is not registered"))?; let output = serde_json::to_string_pretty(record) .map_err(|_| ProcessError::auth("Workspace issuer trust output failed"))?; println!("{output}"); @@ -601,7 +615,7 @@ fn run_trust_workspace_command(mut args: VecDeque) -> Result<(), Process } "revoke" => { let workspace_id = take_required_auth_option(&mut args, "--workspace-id")?; - let config = parse_auth_storage_flags(&mut args)?; + let config = parse_trust_workspace_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( @@ -609,19 +623,128 @@ fn run_trust_workspace_command(mut args: VecDeque) -> Result<(), Process &workspace_id, unix_now_i64()?, ) - .map_err(|error| ProcessError::auth(error.to_string()))?; + .map_err(workspace_trust_process_error)?; 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}`" - ))), + _ => Err(ProcessError::usage( + "unknown trust-workspace command".to_string(), + )), } } +fn parse_trust_workspace_storage_flags( + args: &mut VecDeque, +) -> Result { + let mut index = 0; + while index < args.len() { + let argument = &args[index]; + if matches!(argument.as_str(), "--fs-root" | "--fs-runtime-dir") { + if index + 1 >= args.len() { + return Err(ProcessError::usage( + "invalid trust-workspace storage arguments".to_string(), + )); + } + index += 2; + continue; + } + if argument + .strip_prefix("--fs-root=") + .or_else(|| argument.strip_prefix("--fs-runtime-dir=")) + .is_some_and(|value| !value.is_empty()) + { + index += 1; + continue; + } + return Err(ProcessError::usage( + "invalid trust-workspace storage arguments".to_string(), + )); + } + parse_auth_storage_flags(args) + .map_err(|_| ProcessError::usage("invalid trust-workspace storage arguments".to_string())) +} + +fn parse_trust_workspace_list_flags( + args: &mut VecDeque, +) -> Result<(ProcessConfig, usize, usize), ProcessError> { + let mut storage = VecDeque::new(); + let mut offset = None; + let mut limit = None; + while let Some(argument) = args.pop_front() { + if matches!(argument.as_str(), "--fs-root" | "--fs-runtime-dir") { + let value = args.pop_front().ok_or_else(|| { + ProcessError::usage("invalid trust-workspace list arguments".to_string()) + })?; + storage.push_back(argument); + storage.push_back(value); + continue; + } + if argument.starts_with("--fs-root=") || argument.starts_with("--fs-runtime-dir=") { + if argument.ends_with('=') { + return Err(ProcessError::usage( + "invalid trust-workspace list arguments".to_string(), + )); + } + storage.push_back(argument); + continue; + } + let (name, inline_value) = argument + .split_once('=') + .map_or((argument.as_str(), None), |(name, value)| { + (name, Some(value)) + }); + if !matches!(name, "--offset" | "--limit") { + return Err(ProcessError::usage( + "invalid trust-workspace list arguments".to_string(), + )); + } + let value = match inline_value { + Some(value) if !value.is_empty() => value.to_string(), + Some(_) => { + return Err(ProcessError::usage( + "invalid trust-workspace list arguments".to_string(), + )); + } + None => args.pop_front().ok_or_else(|| { + ProcessError::usage("invalid trust-workspace list arguments".to_string()) + })?, + }; + let parsed = value.parse::().map_err(|_| { + ProcessError::usage("invalid trust-workspace list arguments".to_string()) + })?; + match name { + "--offset" if offset.replace(parsed).is_none() => {} + "--limit" + if (1..=MAX_TRUST_WORKSPACE_LIST_LIMIT).contains(&parsed) + && limit.replace(parsed).is_none() => {} + _ => { + return Err(ProcessError::usage( + "invalid trust-workspace list arguments".to_string(), + )); + } + } + } + let config = parse_trust_workspace_storage_flags(&mut storage)?; + let offset = offset.unwrap_or(0); + if offset > MAX_WORKSPACE_ISSUER_TRUST_RECORDS { + return Err(ProcessError::usage( + "invalid trust-workspace list arguments".to_string(), + )); + } + Ok(( + config, + offset, + limit.unwrap_or(DEFAULT_TRUST_WORKSPACE_LIST_LIMIT), + )) +} + +fn workspace_trust_process_error(_: WorkspaceIssuerTrustError) -> ProcessError { + ProcessError::auth("Workspace issuer trust mutation was rejected") +} + fn read_workspace_identity_bundle(path: &Path) -> Result, ProcessError> { use std::io::Read as _; @@ -649,13 +772,15 @@ fn take_required_auth_option( let argument = args .pop_front() .ok_or_else(|| ProcessError::usage(format!("missing required `{expected}`")))?; - let (flag, inline_value) = split_flag_value(argument)?; + let (flag, inline_value) = split_flag_value(argument) + .map_err(|_| ProcessError::usage("invalid trust-workspace argument".to_string()))?; if flag != expected { - return Err(ProcessError::usage(format!( - "expected `{expected}`, found `{flag}`" - ))); + return Err(ProcessError::usage( + "invalid trust-workspace argument".to_string(), + )); } take_value(&flag, inline_value, args) + .map_err(|_| ProcessError::usage("invalid trust-workspace argument".to_string())) } fn unix_now_i64() -> Result { @@ -695,8 +820,19 @@ fn read_runtime_auth_file(path: &Path) -> Result if !path.exists() { return Ok(RuntimeAuthFile::default()); } - let contents = std::fs::read_to_string(path) + use std::io::Read as _; + + let file = std::fs::File::open(path) .map_err(|_| ProcessError::auth("runtime auth store is unavailable"))?; + let mut contents = Vec::new(); + file.take(MAX_RUNTIME_AUTH_FILE_BYTES + 1) + .read_to_end(&mut contents) + .map_err(|_| ProcessError::auth("runtime auth store is unavailable"))?; + if contents.len() as u64 > MAX_RUNTIME_AUTH_FILE_BYTES { + return Err(ProcessError::auth("runtime auth store is too large")); + } + let contents = String::from_utf8(contents) + .map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?; let auth: RuntimeAuthFile = toml::from_str(&contents) .map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?; validate_workspace_issuer_trust_records(&auth.workspace_issuers) @@ -710,6 +846,9 @@ fn write_runtime_auth_file(path: &Path, auth: &RuntimeAuthFile) -> Result<(), Pr } let contents = toml::to_string_pretty(auth) .map_err(|_| ProcessError::auth("runtime auth store serialization failed"))?; + if contents.len() as u64 > MAX_RUNTIME_AUTH_FILE_BYTES { + return Err(ProcessError::auth("runtime auth store is too large")); + } write_secret_file(path, contents.as_bytes()) } @@ -1122,7 +1261,7 @@ Auth commands: 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 list [--offset N] [--limit N] [--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] @@ -1461,6 +1600,62 @@ mod tests { assert!(!error.contains(&path.to_string_lossy().into_owned())); } + #[test] + fn trust_workspace_errors_and_pagination_are_bounded_and_secret_free() { + let temp = tempfile::tempdir().unwrap(); + let secret_path = "/private/operator/path/must-not-appear"; + for args in [ + VecDeque::from([secret_path.to_string()]), + VecDeque::from([ + "show".to_string(), + "--workspace-id".to_string(), + secret_path.to_string(), + "--fs-root".to_string(), + temp.path().to_string_lossy().into_owned(), + ]), + VecDeque::from([ + "list".to_string(), + secret_path.to_string(), + "--fs-root".to_string(), + temp.path().to_string_lossy().into_owned(), + ]), + ] { + let error = run_trust_workspace_command(args).unwrap_err().to_string(); + assert!( + !error.contains(secret_path), + "unexpected diagnostic: {error}" + ); + } + + let mut page_args = VecDeque::from([ + "--offset".to_string(), + "2".to_string(), + "--limit=7".to_string(), + "--fs-root".to_string(), + temp.path().to_string_lossy().into_owned(), + ]); + let (_, offset, limit) = parse_trust_workspace_list_flags(&mut page_args).unwrap(); + assert_eq!((offset, limit), (2, 7)); + let error = parse_trust_workspace_list_flags(&mut VecDeque::from([ + "--limit".to_string(), + "101".to_string(), + ])) + .unwrap_err() + .to_string(); + assert_eq!(error, "invalid trust-workspace list arguments"); + } + + #[test] + fn oversized_runtime_auth_store_fails_closed_before_parsing() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("runtime-auth.toml"); + std::fs::write(&path, vec![b'x'; MAX_RUNTIME_AUTH_FILE_BYTES as usize + 1]).unwrap(); + assert_eq!( + read_runtime_auth_file(&path).unwrap_err().to_string(), + "runtime auth store is too large" + ); + } + #[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 index fcd33cb9..ce5880f7 100644 --- a/crates/worker-runtime/src/workspace_issuer.rs +++ b/crates/worker-runtime/src/workspace_issuer.rs @@ -2,9 +2,7 @@ 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 ring::signature::Ed25519KeyPair; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use workspace_api::WorkspacePublicIdentityBundle; @@ -18,6 +16,7 @@ const MAX_TOKEN_BYTES: usize = 16 * 1024; const MAX_ID_BYTES: usize = 256; const MAX_ISSUER_BYTES: usize = 2 * 1024; const MAX_OPERATION_BYTES: usize = 128; +pub const MAX_WORKSPACE_ISSUER_TRUST_RECORDS: usize = 4_096; const MAX_REPLAY_ENTRIES: usize = 65_536; const MAX_TOKEN_LIFETIME_SECONDS: i64 = 300; const MAX_CLOCK_SKEW_SECONDS: i64 = 30; @@ -105,6 +104,8 @@ pub enum WorkspaceIssuerTrustError { InvalidTrustGeneration, #[error("Workspace signing identity replacement revision is stale")] StaleIdentityRevision, + #[error("Workspace issuer trust record limit was reached")] + TrustRecordLimitExceeded, #[error("Workspace issuer trust generation overflow")] TrustGenerationOverflow, } @@ -112,6 +113,9 @@ pub enum WorkspaceIssuerTrustError { pub fn validate_workspace_issuer_trust_records( records: &[WorkspaceIssuerTrustRecord], ) -> Result<(), WorkspaceCapabilityVerificationError> { + if records.len() > MAX_WORKSPACE_ISSUER_TRUST_RECORDS { + return Err(WorkspaceCapabilityVerificationError::TrustRecordLimitExceeded); + } let mut seen = std::collections::HashSet::new(); for record in records { validate_trust_record(record)?; @@ -148,6 +152,9 @@ pub fn add_workspace_issuer_trust( bundle.workspace_id, )); } + if records.len() >= MAX_WORKSPACE_ISSUER_TRUST_RECORDS { + return Err(WorkspaceIssuerTrustError::TrustRecordLimitExceeded); + } 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)); @@ -250,7 +257,9 @@ 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) + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) { return Err(WorkspaceIssuerTrustError::InvalidIdentifier); } @@ -386,12 +395,12 @@ impl WorkspaceCapabilityVerifier { 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)?; + let signed = crate::auth::decode_signed_json_token::( + token, + WORKSPACE_TOKEN_PREFIX, + ) + .map_err(|_| WorkspaceCapabilityVerificationError::MalformedToken)?; + let claims = signed.claims; validate_claim_shape(&claims)?; if claims.issuer_workspace_id != expected.workspace_id { @@ -418,12 +427,18 @@ impl WorkspaceCapabilityVerifier { 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)?; + crate::auth::verify_signed_json_token( + WORKSPACE_SIGNING_INPUT_PREFIX, + &signed.payload, + &signed.signature, + &record.public_key, + ) + .map_err(|error| match error { + RuntimeAuthError::InvalidSignature => { + WorkspaceCapabilityVerificationError::InvalidSignature + } + _ => WorkspaceCapabilityVerificationError::TrustRecordCorrupt, + })?; if claims.runtime_id != expected.runtime_id { return Err(WorkspaceCapabilityVerificationError::WrongRuntime); @@ -483,6 +498,8 @@ pub enum WorkspaceCapabilityVerificationError { TrustAuthorityMissing, #[error("Workspace issuer trust contains duplicate Workspace identities")] DuplicateWorkspaceTrust, + #[error("Workspace issuer trust contains too many records")] + TrustRecordLimitExceeded, #[error("Workspace issuer trust record is corrupt")] TrustRecordCorrupt, #[error("Workspace capability token is malformed")] @@ -536,37 +553,13 @@ pub fn issue_workspace_capability_token( 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)) + crate::auth::sign_json_token( + WORKSPACE_TOKEN_PREFIX, + WORKSPACE_SIGNING_INPUT_PREFIX, + signing_key, + claims, + ) + .map_err(|_| WorkspaceCapabilityVerificationError::MalformedClaims) } fn validate_trust_record( @@ -617,7 +610,9 @@ fn validate_claim_shape( if value.is_empty() || value.len() > MAX_ID_BYTES || value.trim() != value - || value.chars().any(char::is_control) + || !value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':') + }) { return Err(WorkspaceCapabilityVerificationError::InvalidIdentifier); } @@ -626,7 +621,9 @@ fn validate_claim_shape( if worker_id.is_empty() || worker_id.len() > MAX_ID_BYTES || worker_id.trim() != worker_id - || worker_id.chars().any(char::is_control) + || !worker_id.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':') + }) { return Err(WorkspaceCapabilityVerificationError::InvalidIdentifier); } @@ -651,15 +648,11 @@ fn validate_claim_shape( } 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)) + crate::auth::is_request_body_digest(value) } pub fn workspace_request_body_digest(body: &[u8]) -> String { - format!("sha256:{}", hex_lower(&Sha256::digest(body))) + crate::auth::request_body_digest(body) } fn hex_lower(bytes: &[u8]) -> String { @@ -809,6 +802,13 @@ mod tests { assert_eq!(mutation, WorkspaceIssuerTrustMutation::Unchanged); assert_eq!(replay, first); + let oversized_records = + vec![first.clone(); MAX_WORKSPACE_ISSUER_TRUST_RECORDS.saturating_add(1)]; + assert_eq!( + validate_workspace_issuer_trust_records(&oversized_records).unwrap_err(), + WorkspaceCapabilityVerificationError::TrustRecordLimitExceeded + ); + let (_, stale_other_key) = identity("workspace-1", "WK-stale", 1); assert_eq!( replace_workspace_issuer_trust(&mut records, stale_other_key, 12).unwrap_err(), diff --git a/docs/development/server-runtime-auth.md b/docs/development/server-runtime-auth.md index a36d3c61..6ce9f950 100644 --- a/docs/development/server-runtime-auth.md +++ b/docs/development/server-runtime-auth.md @@ -125,12 +125,14 @@ The bundle contains `workspace_id`, `backend_url`, `key_id`, `algorithm`, `publi 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 list --offset 0 --limit 100 --fs-root /var/lib/yoi-runtime yoi-runtime trust-workspace show \ --workspace-id '' \ --fs-root /var/lib/yoi-runtime ``` +`list` returns a bounded page with `offset`, `limit`, `total`, and `records`; advance `--offset` to inspect later pages. The records contain public trust metadata only. + 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