fix: bound Runtime issuer trust surfaces

This commit is contained in:
2026-09-08 02:31:58 +09:00
parent fba5ecf54c
commit 04924cf796
4 changed files with 365 additions and 116 deletions
+87 -35
View File
@@ -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<T> {
pub payload: String,
pub signature: Vec<u8>,
pub claims: T,
}
pub(crate) fn sign_json_token<T: Serialize>(
token_prefix: &str,
signing_input_prefix: &str,
signing_key: &Ed25519KeyPair,
claims: &T,
) -> Result<String, RuntimeAuthError> {
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<T: DeserializeOwned>(
token: &str,
expected_prefix: &str,
) -> Result<SignedJsonToken<T>, 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<RuntimeRequestSourceClaims, RuntimeAuthError> {
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<RuntimeRequestSourceClaims, RuntimeAuthError> {
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::<RuntimeRequestSourceClaims>(
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,
+220 -25
View File
@@ -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<std::io::Error> 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<WorkspaceIssuerTrustRecord>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
struct RuntimeAuthFile {
@@ -550,7 +561,7 @@ fn run_trust_workspace_command(mut args: VecDeque<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<String>,
) -> Result<ProcessConfig, ProcessError> {
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<String>,
) -> 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::<usize>().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<Vec<u8>, 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<i64, ProcessError> {
@@ -695,8 +820,19 @@ fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError>
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();
+55 -55
View File
@@ -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::<WorkspaceCapabilityClaims>(
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<String, WorkspaceCapabilityVerificationError> {
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<u8>), 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(),