fix: bound Runtime issuer trust surfaces
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user