feat: add Runtime Workspace issuer trust

This commit is contained in:
2026-09-08 01:54:10 +09:00
parent 3baf0b6358
commit e035df9e7b
4 changed files with 1410 additions and 24 deletions
+1
View File
@@ -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};
+326 -21
View File
@@ -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<String>) -> 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<Runtime, ProcessError> {
> = 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<String>) -> Self {
Self::Auth(message.into())
}
}
impl fmt::Display for ProcessError {
@@ -512,6 +522,8 @@ struct RuntimeAuthFile {
identity: Option<RuntimeIdentityMaterial>,
#[serde(default)]
trusted_servers: Vec<TrustedServerKey>,
#[serde(default)]
workspace_issuers: Vec<WorkspaceIssuerTrustRecord>,
}
#[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<String>) -> 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<String>,
expected: &str,
) -> Result<String, ProcessError> {
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<i64, ProcessError> {
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<RuntimeAuthFile, ProcessError>
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> {
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_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
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)?;
}
#[cfg(not(unix))]
{
std::fs::write(path, contents)?;
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);
}
write_result
}
fn load_runtime_http_auth(
@@ -609,6 +765,7 @@ fn run_auth_command(args: Vec<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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::<String>()
);
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::<String>()
);
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();
File diff suppressed because it is too large Load Diff
+48 -1
View File
@@ -65,7 +65,7 @@ By default, Runtime auth state is stored at:
<data_dir>/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/<WORKSPACE_ID>/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 '<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 WorkspaceRuntime `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 '<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 WorkspaceRuntime 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`: