fix: harden Workspace issuer bootstrap
This commit is contained in:
@@ -516,6 +516,8 @@ impl From<std::io::Error> for ProcessError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_WORKSPACE_IDENTITY_BUNDLE_BYTES: u64 = 64 * 1024;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
struct RuntimeAuthFile {
|
struct RuntimeAuthFile {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -551,9 +553,7 @@ fn run_trust_workspace_command(mut args: VecDeque<String>) -> Result<(), Process
|
|||||||
let config = parse_auth_storage_flags(&mut args)?;
|
let config = parse_auth_storage_flags(&mut args)?;
|
||||||
let auth_path = runtime_auth_path(&config);
|
let auth_path = runtime_auth_path(&config);
|
||||||
let mut auth = read_runtime_auth_file(&auth_path)?;
|
let mut auth = read_runtime_auth_file(&auth_path)?;
|
||||||
let bundle_bytes = std::fs::read(&bundle_path).map_err(|_| {
|
let bundle_bytes = read_workspace_identity_bundle(Path::new(&bundle_path))?;
|
||||||
ProcessError::auth("Workspace signing public identity bundle is unavailable")
|
|
||||||
})?;
|
|
||||||
let bundle = serde_json::from_slice(&bundle_bytes).map_err(|_| {
|
let bundle = serde_json::from_slice(&bundle_bytes).map_err(|_| {
|
||||||
ProcessError::auth("Workspace signing public identity bundle is invalid")
|
ProcessError::auth("Workspace signing public identity bundle is invalid")
|
||||||
})?;
|
})?;
|
||||||
@@ -622,6 +622,26 @@ fn run_trust_workspace_command(mut args: VecDeque<String>) -> Result<(), Process
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_workspace_identity_bundle(path: &Path) -> Result<Vec<u8>, ProcessError> {
|
||||||
|
use std::io::Read as _;
|
||||||
|
|
||||||
|
let file = std::fs::File::open(path).map_err(|_| {
|
||||||
|
ProcessError::auth("Workspace signing public identity bundle is unavailable")
|
||||||
|
})?;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
file.take(MAX_WORKSPACE_IDENTITY_BUNDLE_BYTES + 1)
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(|_| {
|
||||||
|
ProcessError::auth("Workspace signing public identity bundle is unavailable")
|
||||||
|
})?;
|
||||||
|
if bytes.len() as u64 > MAX_WORKSPACE_IDENTITY_BUNDLE_BYTES {
|
||||||
|
return Err(ProcessError::auth(
|
||||||
|
"Workspace signing public identity bundle is too large",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
fn take_required_auth_option(
|
fn take_required_auth_option(
|
||||||
args: &mut VecDeque<String>,
|
args: &mut VecDeque<String>,
|
||||||
expected: &str,
|
expected: &str,
|
||||||
@@ -1400,6 +1420,21 @@ mod tests {
|
|||||||
worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked
|
worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let oversized_bundle = temp.path().join("oversized-public-bundle.json");
|
||||||
|
std::fs::write(
|
||||||
|
&oversized_bundle,
|
||||||
|
vec![b'x'; MAX_WORKSPACE_IDENTITY_BUNDLE_BYTES as usize + 1],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let error = read_workspace_identity_bundle(&oversized_bundle)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
"Workspace signing public identity bundle is too large"
|
||||||
|
);
|
||||||
|
assert!(!error.contains(&oversized_bundle.to_string_lossy().into_owned()));
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt as _;
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ const WORKSPACE_SIGNING_INPUT_PREFIX: &str = "yoi.workspace.capability.v1.";
|
|||||||
const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519";
|
const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519";
|
||||||
const MAX_TOKEN_BYTES: usize = 16 * 1024;
|
const MAX_TOKEN_BYTES: usize = 16 * 1024;
|
||||||
const MAX_ID_BYTES: usize = 256;
|
const MAX_ID_BYTES: usize = 256;
|
||||||
|
const MAX_ISSUER_BYTES: usize = 2 * 1024;
|
||||||
const MAX_OPERATION_BYTES: usize = 128;
|
const MAX_OPERATION_BYTES: usize = 128;
|
||||||
|
const MAX_REPLAY_ENTRIES: usize = 65_536;
|
||||||
const MAX_TOKEN_LIFETIME_SECONDS: i64 = 300;
|
const MAX_TOKEN_LIFETIME_SECONDS: i64 = 300;
|
||||||
const MAX_CLOCK_SKEW_SECONDS: i64 = 30;
|
const MAX_CLOCK_SKEW_SECONDS: i64 = 30;
|
||||||
|
|
||||||
@@ -212,6 +214,12 @@ fn validate_bundle(
|
|||||||
) -> Result<(), WorkspaceIssuerTrustError> {
|
) -> Result<(), WorkspaceIssuerTrustError> {
|
||||||
validate_id(&bundle.workspace_id)?;
|
validate_id(&bundle.workspace_id)?;
|
||||||
validate_id(&bundle.key_id)?;
|
validate_id(&bundle.key_id)?;
|
||||||
|
if bundle.backend_url.len() > MAX_ISSUER_BYTES
|
||||||
|
|| bundle.backend_url.trim() != bundle.backend_url
|
||||||
|
|| bundle.backend_url.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err(WorkspaceIssuerTrustError::InvalidBackendUrl);
|
||||||
|
}
|
||||||
let backend_url = url::Url::parse(&bundle.backend_url)
|
let backend_url = url::Url::parse(&bundle.backend_url)
|
||||||
.map_err(|_| WorkspaceIssuerTrustError::InvalidBackendUrl)?;
|
.map_err(|_| WorkspaceIssuerTrustError::InvalidBackendUrl)?;
|
||||||
if !matches!(backend_url.scheme(), "http" | "https")
|
if !matches!(backend_url.scheme(), "http" | "https")
|
||||||
@@ -332,6 +340,9 @@ impl WorkspaceClaimReplayProtection for InMemoryWorkspaceClaimReplayProtection {
|
|||||||
if consumed.contains_key(&key) {
|
if consumed.contains_key(&key) {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
if consumed.len() >= MAX_REPLAY_ENTRIES {
|
||||||
|
return Err(WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable);
|
||||||
|
}
|
||||||
consumed.insert(key, expires_at);
|
consumed.insert(key, expires_at);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -580,6 +591,12 @@ fn validate_trust_record(
|
|||||||
fn validate_claim_shape(
|
fn validate_claim_shape(
|
||||||
claims: &WorkspaceCapabilityClaims,
|
claims: &WorkspaceCapabilityClaims,
|
||||||
) -> Result<(), WorkspaceCapabilityVerificationError> {
|
) -> Result<(), WorkspaceCapabilityVerificationError> {
|
||||||
|
if claims.issuer.len() > MAX_ISSUER_BYTES
|
||||||
|
|| claims.issuer.trim() != claims.issuer
|
||||||
|
|| claims.issuer.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err(WorkspaceCapabilityVerificationError::WrongIssuer);
|
||||||
|
}
|
||||||
let issuer = url::Url::parse(&claims.issuer)
|
let issuer = url::Url::parse(&claims.issuer)
|
||||||
.map_err(|_| WorkspaceCapabilityVerificationError::WrongIssuer)?;
|
.map_err(|_| WorkspaceCapabilityVerificationError::WrongIssuer)?;
|
||||||
if !matches!(issuer.scheme(), "http" | "https")
|
if !matches!(issuer.scheme(), "http" | "https")
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ Workspace signing identity is Workspace-scoped. Provision the identity through t
|
|||||||
GET /api/w/<WORKSPACE_ID>/settings/workspace/signing-identity
|
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:
|
Save the response's non-null `public_bundle` object—not the outer response wrapper—as `workspace-public-identity.json`, and transfer only that document to the Runtime host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yoi-runtime trust-workspace add \
|
yoi-runtime trust-workspace add \
|
||||||
|
|||||||
Reference in New Issue
Block a user