feat: verify Workspace-signed Runtime bindings
This commit is contained in:
@@ -27,6 +27,15 @@ use crate::retention::{
|
||||
};
|
||||
#[cfg(feature = "ws-server")]
|
||||
use crate::runtime::RuntimeSubscriptionRecvError;
|
||||
use crate::workspace_issuer::{
|
||||
RuntimeVerificationSigner, VerifiedWorkspaceCapability, WORKSPACE_VERIFICATION_ACK_PATH,
|
||||
WORKSPACE_VERIFICATION_CHALLENGE_PATH, WORKSPACE_VERIFICATION_OPERATION,
|
||||
WorkspaceCapabilityExpectation, WorkspaceCapabilityVerifier,
|
||||
WorkspaceRuntimeVerificationAcknowledgement, WorkspaceRuntimeVerificationAuthority,
|
||||
WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt,
|
||||
WorkspaceRuntimeVerificationRecord, WorkspaceRuntimeVerificationResponse,
|
||||
inspect_workspace_capability_claims, workspace_request_body_digest,
|
||||
};
|
||||
use crate::{Runtime, RuntimeWorkspaceScope};
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::extract::rejection::{JsonRejection, QueryRejection};
|
||||
@@ -155,7 +164,22 @@ pub async fn serve_runtime_http_with_auth(
|
||||
}
|
||||
axum::serve(
|
||||
listener,
|
||||
runtime_http_router_with_optional_auth(runtime, local_token, auth),
|
||||
runtime_http_router_with_optional_auth(runtime, local_token, auth, None),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn serve_runtime_http_with_workspace_auth(
|
||||
runtime: Runtime,
|
||||
listener: TcpListener,
|
||||
local_token: Option<String>,
|
||||
auth: Option<RuntimeHttpAuthConfig>,
|
||||
workspace_auth: WorkspaceRuntimeHttpAuth,
|
||||
) -> Result<(), RuntimeHttpServerError> {
|
||||
axum::serve(
|
||||
listener,
|
||||
runtime_http_router_with_optional_auth(runtime, local_token, auth, Some(workspace_auth)),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -167,7 +191,7 @@ pub async fn serve_runtime_http_with_auth(
|
||||
/// The path contains only a Runtime-local `worker_id`; backend aliases are not
|
||||
/// accepted or forwarded as Runtime authority.
|
||||
pub fn runtime_http_router(runtime: Runtime, local_token: String) -> Router {
|
||||
runtime_http_router_with_optional_auth(runtime, Some(local_token), None)
|
||||
runtime_http_router_with_optional_auth(runtime, Some(local_token), None, None)
|
||||
}
|
||||
|
||||
/// Build the REST router for an existing Runtime with signed capability-token auth.
|
||||
@@ -176,23 +200,42 @@ pub fn runtime_http_router_with_auth(
|
||||
local_token: Option<String>,
|
||||
auth: RuntimeHttpAuthConfig,
|
||||
) -> Router {
|
||||
runtime_http_router_with_optional_auth(runtime, local_token, Some(auth))
|
||||
runtime_http_router_with_optional_auth(runtime, local_token, Some(auth), None)
|
||||
}
|
||||
|
||||
pub fn runtime_http_router_with_workspace_auth(
|
||||
runtime: Runtime,
|
||||
local_token: Option<String>,
|
||||
auth: RuntimeHttpAuthConfig,
|
||||
workspace_auth: WorkspaceRuntimeHttpAuth,
|
||||
) -> Router {
|
||||
runtime_http_router_with_optional_auth(runtime, local_token, Some(auth), Some(workspace_auth))
|
||||
}
|
||||
|
||||
fn runtime_http_router_with_optional_auth(
|
||||
runtime: Runtime,
|
||||
local_token: Option<String>,
|
||||
auth: Option<RuntimeHttpAuthConfig>,
|
||||
workspace_auth: Option<WorkspaceRuntimeHttpAuth>,
|
||||
) -> Router {
|
||||
let state = RuntimeHttpState {
|
||||
runtime,
|
||||
local_token: local_token.map(Arc::<str>::from),
|
||||
auth: auth.map(Arc::new),
|
||||
workspace_auth: workspace_auth.map(Arc::new),
|
||||
workdir_sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
|
||||
let router = Router::new()
|
||||
.route("/v1/ping", get(get_runtime_ping))
|
||||
.route(
|
||||
WORKSPACE_VERIFICATION_CHALLENGE_PATH,
|
||||
post(post_workspace_verification_challenge),
|
||||
)
|
||||
.route(
|
||||
WORKSPACE_VERIFICATION_ACK_PATH,
|
||||
post(post_workspace_verification_acknowledgement),
|
||||
)
|
||||
.route("/v1/runtime", get(get_runtime))
|
||||
.route(
|
||||
"/v1/config-bundles",
|
||||
@@ -286,9 +329,17 @@ struct RuntimeHttpState {
|
||||
runtime: Runtime,
|
||||
local_token: Option<Arc<str>>,
|
||||
auth: Option<Arc<RuntimeHttpAuthConfig>>,
|
||||
workspace_auth: Option<Arc<WorkspaceRuntimeHttpAuth>>,
|
||||
workdir_sessions: Arc<Mutex<HashMap<String, RuntimeHttpWorkdirSession>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkspaceRuntimeHttpAuth {
|
||||
pub verifier: WorkspaceCapabilityVerifier,
|
||||
pub signer: RuntimeVerificationSigner,
|
||||
pub verifications: Arc<dyn WorkspaceRuntimeVerificationAuthority>,
|
||||
}
|
||||
|
||||
struct RuntimeHttpWorkdirSession {
|
||||
owner: RuntimeWorkspaceScope,
|
||||
session: WorkdirSessionHandle,
|
||||
@@ -475,6 +526,164 @@ struct RuntimeWorkerEventsWsQuery {
|
||||
|
||||
type RestResult<T> = Result<Json<T>, RuntimeHttpRestError>;
|
||||
|
||||
fn unix_now_i64() -> i64 {
|
||||
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
async fn post_workspace_verification_challenge(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
Extension(verified): Extension<VerifiedWorkspaceCapability>,
|
||||
Json(challenge): Json<WorkspaceRuntimeVerificationChallenge>,
|
||||
) -> RestResult<WorkspaceRuntimeVerificationResponse> {
|
||||
let auth = state.workspace_auth.as_deref().ok_or_else(|| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"workspace_runtime_verification_unavailable",
|
||||
"Workspace Runtime verification is not configured",
|
||||
)
|
||||
})?;
|
||||
if challenge.workspace_id != verified.workspace_id
|
||||
|| challenge.runtime_id != verified.runtime_id
|
||||
|| challenge.binding_revision != verified.binding_revision
|
||||
|| challenge.workspace_key_id != verified.issuer_key_id
|
||||
|| challenge.workspace_identity_revision != verified.issuer_identity_revision
|
||||
|| challenge.workspace_trust_generation != verified.trust_generation
|
||||
|| challenge.runtime_id != auth.signer.runtime_id()
|
||||
|| challenge.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
|
||||
{
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"workspace_runtime_verification_rejected",
|
||||
"Workspace Runtime verification challenge does not match authenticated authority",
|
||||
));
|
||||
}
|
||||
let response = auth
|
||||
.signer
|
||||
.sign_response(&challenge, uuid::Uuid::now_v7().to_string(), unix_now_i64())
|
||||
.map_err(|error| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"workspace_runtime_verification_rejected",
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn post_workspace_verification_acknowledgement(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
Extension(verified): Extension<VerifiedWorkspaceCapability>,
|
||||
Json(acknowledgement): Json<WorkspaceRuntimeVerificationAcknowledgement>,
|
||||
) -> RestResult<WorkspaceRuntimeVerificationReceipt> {
|
||||
let auth = state.workspace_auth.as_deref().ok_or_else(|| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"workspace_runtime_verification_unavailable",
|
||||
"Workspace Runtime verification is not configured",
|
||||
)
|
||||
})?;
|
||||
if acknowledgement.workspace_id != verified.workspace_id
|
||||
|| acknowledgement.runtime_id != verified.runtime_id
|
||||
|| acknowledgement.binding_revision != verified.binding_revision
|
||||
|| acknowledgement.workspace_key_id != verified.issuer_key_id
|
||||
|| acknowledgement.workspace_identity_revision != verified.issuer_identity_revision
|
||||
|| acknowledgement.workspace_trust_generation != verified.trust_generation
|
||||
|| acknowledgement.runtime_id != auth.signer.runtime_id()
|
||||
|| acknowledgement.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
|
||||
|| acknowledgement.expires_at <= unix_now_i64()
|
||||
|| acknowledgement.binding_revision == 0
|
||||
|| acknowledgement.runtime_identity_revision == 0
|
||||
|| acknowledgement.workspace_identity_revision == 0
|
||||
|| acknowledgement.workspace_trust_generation == 0
|
||||
|| acknowledgement.workspace_nonce.is_empty()
|
||||
|| acknowledgement.runtime_nonce.is_empty()
|
||||
|| acknowledgement.response_digest.len() != workspace_request_body_digest(&[]).len()
|
||||
{
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"workspace_runtime_verification_rejected",
|
||||
"Workspace Runtime verification acknowledgement is invalid",
|
||||
));
|
||||
}
|
||||
let challenge = WorkspaceRuntimeVerificationChallenge {
|
||||
challenge_id: acknowledgement.challenge_id.clone(),
|
||||
workspace_id: acknowledgement.workspace_id.clone(),
|
||||
runtime_id: acknowledgement.runtime_id.clone(),
|
||||
binding_revision: acknowledgement.binding_revision,
|
||||
workspace_key_id: acknowledgement.workspace_key_id.clone(),
|
||||
workspace_identity_revision: acknowledgement.workspace_identity_revision,
|
||||
workspace_trust_generation: acknowledgement.workspace_trust_generation,
|
||||
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(),
|
||||
runtime_identity_revision: acknowledgement.runtime_identity_revision,
|
||||
workspace_nonce: acknowledgement.workspace_nonce.clone(),
|
||||
expires_at: acknowledgement.expires_at,
|
||||
};
|
||||
let response = acknowledgement.response.clone();
|
||||
if response.runtime_nonce != acknowledgement.runtime_nonce
|
||||
|| response.workspace_nonce != acknowledgement.workspace_nonce
|
||||
|| response.response_proof.is_empty()
|
||||
{
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"workspace_runtime_verification_rejected",
|
||||
"Workspace Runtime verification acknowledgement does not match its response",
|
||||
));
|
||||
}
|
||||
let response_bytes = serde_json::to_vec(&response).map_err(|_| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"workspace_runtime_verification_rejected",
|
||||
"Workspace Runtime verification response could not be canonicalized",
|
||||
)
|
||||
})?;
|
||||
if workspace_request_body_digest(&response_bytes) != acknowledgement.response_digest {
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"workspace_runtime_verification_rejected",
|
||||
"Workspace Runtime verification acknowledgement has the wrong response digest",
|
||||
));
|
||||
}
|
||||
crate::workspace_issuer::verify_runtime_verification_response(
|
||||
&response,
|
||||
&challenge,
|
||||
auth.signer.public_key(),
|
||||
unix_now_i64(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"workspace_runtime_verification_rejected",
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
auth.verifications
|
||||
.record(WorkspaceRuntimeVerificationRecord {
|
||||
workspace_id: acknowledgement.workspace_id.clone(),
|
||||
runtime_id: acknowledgement.runtime_id.clone(),
|
||||
binding_revision: acknowledgement.binding_revision,
|
||||
workspace_key_id: acknowledgement.workspace_key_id.clone(),
|
||||
workspace_identity_revision: acknowledgement.workspace_identity_revision,
|
||||
workspace_trust_generation: acknowledgement.workspace_trust_generation,
|
||||
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(),
|
||||
runtime_identity_revision: acknowledgement.runtime_identity_revision,
|
||||
verified_at: unix_now_i64(),
|
||||
})
|
||||
.map_err(|error| {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"workspace_runtime_verification_unavailable",
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(WorkspaceRuntimeVerificationReceipt {
|
||||
challenge_id: acknowledgement.challenge_id,
|
||||
workspace_id: acknowledgement.workspace_id,
|
||||
runtime_id: acknowledgement.runtime_id,
|
||||
binding_revision: acknowledgement.binding_revision,
|
||||
accepted_at: unix_now_i64(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_runtime_ping(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
Extension(auth): Extension<RuntimeAuthContext>,
|
||||
@@ -1797,10 +2006,112 @@ async fn require_runtime_auth(
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "));
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.map(str::to_owned);
|
||||
|
||||
if let Some(workspace_auth) = state.workspace_auth.as_deref()
|
||||
&& let Some(token) = supplied.as_deref()
|
||||
&& let Ok(claims) = inspect_workspace_capability_claims(token)
|
||||
{
|
||||
let method = request.method().as_str().to_string();
|
||||
let path_and_query = request
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.map_or_else(|| request.uri().path().to_string(), ToString::to_string);
|
||||
let required_permission =
|
||||
workspace_runtime_operation(request.method(), request.uri().path());
|
||||
let expected_worker_id = worker_id_from_runtime_path(request.uri().path());
|
||||
let (parts, body) = request.into_parts();
|
||||
let body = match axum::body::to_bytes(body, 8 * 1024 * 1024).await {
|
||||
Ok(body) => body,
|
||||
Err(_) => {
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"request_body_too_large",
|
||||
"Runtime request body exceeds the verification limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let body_digest = workspace_request_body_digest(&body);
|
||||
let expected = WorkspaceCapabilityExpectation {
|
||||
workspace_id: &claims.issuer_workspace_id,
|
||||
binding_revision: claims.binding_revision,
|
||||
runtime_id: workspace_auth.signer.runtime_id(),
|
||||
worker_id: expected_worker_id.as_deref(),
|
||||
operation: required_permission,
|
||||
method: &method,
|
||||
path_and_query: &path_and_query,
|
||||
body_digest: &body_digest,
|
||||
now_unix: unix_now_i64(),
|
||||
};
|
||||
match workspace_auth.verifier.verify(token, &expected) {
|
||||
Ok(verified) => {
|
||||
let is_verification = path_and_query == WORKSPACE_VERIFICATION_CHALLENGE_PATH
|
||||
|| path_and_query == WORKSPACE_VERIFICATION_ACK_PATH;
|
||||
if !is_verification {
|
||||
let record = match workspace_auth
|
||||
.verifications
|
||||
.get(&verified.workspace_id, workspace_auth.signer.runtime_id())
|
||||
{
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) => {
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"workspace_runtime_verification_required",
|
||||
"Workspace Runtime binding has not completed signed verification",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(error) => {
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"workspace_runtime_verification_unavailable",
|
||||
error.to_string(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if record.binding_revision != verified.binding_revision
|
||||
|| record.workspace_key_id != verified.issuer_key_id
|
||||
|| record.workspace_identity_revision != verified.issuer_identity_revision
|
||||
|| record.workspace_trust_generation != verified.trust_generation
|
||||
|| record.runtime_public_key_fingerprint
|
||||
!= workspace_auth.signer.public_key_fingerprint()
|
||||
|| record.runtime_identity_revision == 0
|
||||
{
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"workspace_runtime_verification_stale",
|
||||
"Workspace Runtime verification does not match current request authority",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
request = Request::from_parts(parts, Body::from(body));
|
||||
request.extensions_mut().insert(verified.clone());
|
||||
request.extensions_mut().insert(RuntimeAuthContext {
|
||||
server_id: verified.issuer,
|
||||
workspace_id: verified.workspace_id,
|
||||
permissions: vec![required_permission.to_string()],
|
||||
token_id: verified.token_id,
|
||||
expires_at: u64::try_from(verified.expires_at).unwrap_or(0),
|
||||
});
|
||||
return next.run(request).await;
|
||||
}
|
||||
Err(error) => {
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unauthorized",
|
||||
format!("invalid Workspace capability token: {error}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(auth) = state.auth.as_deref() {
|
||||
let Some(token) = supplied else {
|
||||
let Some(token) = supplied.as_deref() else {
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unauthorized",
|
||||
@@ -1815,6 +2126,18 @@ async fn require_runtime_auth(
|
||||
unix_now_seconds(),
|
||||
) {
|
||||
Ok(context) => {
|
||||
if state.workspace_auth.as_ref().is_some_and(|workspace_auth| {
|
||||
workspace_auth
|
||||
.verifier
|
||||
.has_active_workspace_issuer(&context.workspace_id)
|
||||
}) {
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"workspace_identity_required",
|
||||
"Legacy Server-issued capability is disabled for this Workspace",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
request.extensions_mut().insert(context);
|
||||
return next.run(request).await;
|
||||
}
|
||||
@@ -1825,7 +2148,7 @@ async fn require_runtime_auth(
|
||||
}
|
||||
|
||||
if let Some(expected) = state.local_token.as_deref() {
|
||||
if supplied != Some(expected) {
|
||||
if supplied.as_deref() != Some(expected) {
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unauthorized",
|
||||
@@ -1897,6 +2220,21 @@ fn auth_workspace_scope(
|
||||
Ok(Some(RuntimeWorkspaceScope::new(workspace_id, server_id)))
|
||||
}
|
||||
|
||||
fn workspace_runtime_operation(method: &Method, path: &str) -> &'static str {
|
||||
if (path == WORKSPACE_VERIFICATION_CHALLENGE_PATH || path == WORKSPACE_VERIFICATION_ACK_PATH)
|
||||
&& *method == Method::POST
|
||||
{
|
||||
return WORKSPACE_VERIFICATION_OPERATION;
|
||||
}
|
||||
required_runtime_permission(method, path).unwrap_or("runtime:read")
|
||||
}
|
||||
|
||||
fn worker_id_from_runtime_path(path: &str) -> Option<String> {
|
||||
let rest = path.strip_prefix("/v1/workers/")?;
|
||||
let worker_id = rest.split('/').next()?;
|
||||
(!worker_id.is_empty()).then(|| worker_id.to_string())
|
||||
}
|
||||
|
||||
fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static str> {
|
||||
if path == "/v1/ping" && *method == Method::GET {
|
||||
return Some(RUNTIME_PING_PERMISSION);
|
||||
@@ -2209,15 +2547,210 @@ mod tests {
|
||||
WorkerExecutionSpawnResult,
|
||||
};
|
||||
use crate::management::RuntimeOptions;
|
||||
use crate::workspace_issuer::{
|
||||
InMemoryWorkspaceClaimReplayProtection, InMemoryWorkspaceRuntimeVerificationAuthority,
|
||||
WorkspaceCapabilityClaims, WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord,
|
||||
WorkspaceIssuerTrustState, issue_workspace_capability_token,
|
||||
verify_runtime_verification_response,
|
||||
};
|
||||
use axum::body::to_bytes;
|
||||
use axum::http::Method;
|
||||
use manifest::{Scope, SharedScope};
|
||||
use sha2::Digest as _;
|
||||
use tower::ServiceExt;
|
||||
use workdir::{
|
||||
GrepOutputMode, GrepRequest, LocalWorkdirSession, StatRequest, Workdir, WorkdirPath,
|
||||
WorkdirSessionCapabilities,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_signed_verification_requires_exact_request_and_acknowledges_response() {
|
||||
let runtime = Runtime::new_memory();
|
||||
let (legacy_auth, _) = auth_config_and_signer();
|
||||
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
||||
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
let workspace_public_key =
|
||||
crate::auth::decode_public_key(&workspace_identity.public_key).unwrap();
|
||||
let workspace_fingerprint = format!(
|
||||
"sha256:{}",
|
||||
crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(workspace_public_key))
|
||||
);
|
||||
let runtime_public_key =
|
||||
crate::auth::decode_public_key(&runtime_identity.public_key).unwrap();
|
||||
let runtime_fingerprint = format!(
|
||||
"sha256:{}",
|
||||
crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(runtime_public_key))
|
||||
);
|
||||
let verifier = WorkspaceCapabilityVerifier::new(
|
||||
vec![WorkspaceIssuerTrustRecord {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
backend_url: "https://backend.test".to_string(),
|
||||
key_id: "workspace-key".to_string(),
|
||||
algorithm: "ed25519".to_string(),
|
||||
public_key: workspace_identity.public_key.clone(),
|
||||
public_key_fingerprint: workspace_fingerprint,
|
||||
identity_revision: 1,
|
||||
trust_generation: 1,
|
||||
state: WorkspaceIssuerTrustState::Active,
|
||||
registered_at_unix: 1,
|
||||
updated_at_unix: 1,
|
||||
}],
|
||||
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
|
||||
)
|
||||
.unwrap();
|
||||
let app = runtime_http_router_with_workspace_auth(
|
||||
runtime,
|
||||
None,
|
||||
legacy_auth,
|
||||
WorkspaceRuntimeHttpAuth {
|
||||
verifier,
|
||||
signer: RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap(),
|
||||
verifications: Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default()),
|
||||
},
|
||||
);
|
||||
let challenge = WorkspaceRuntimeVerificationChallenge {
|
||||
challenge_id: "challenge-1".to_string(),
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
runtime_id: "runtime-test".to_string(),
|
||||
binding_revision: 4,
|
||||
workspace_key_id: "workspace-key".to_string(),
|
||||
workspace_identity_revision: 1,
|
||||
workspace_trust_generation: 1,
|
||||
runtime_public_key_fingerprint: runtime_fingerprint,
|
||||
runtime_identity_revision: 1,
|
||||
workspace_nonce: "workspace-nonce".to_string(),
|
||||
expires_at: unix_now_i64() + 60,
|
||||
};
|
||||
let body = serde_json::to_vec(&challenge).unwrap();
|
||||
let claims = WorkspaceCapabilityClaims {
|
||||
issuer: "https://backend.test".to_string(),
|
||||
issuer_workspace_id: "workspace-a".to_string(),
|
||||
issuer_key_id: "workspace-key".to_string(),
|
||||
issuer_identity_revision: 1,
|
||||
trust_generation: 1,
|
||||
binding_revision: 4,
|
||||
runtime_id: "runtime-test".to_string(),
|
||||
worker_id: None,
|
||||
operation: WORKSPACE_VERIFICATION_OPERATION.to_string(),
|
||||
method: "POST".to_string(),
|
||||
path_and_query: WORKSPACE_VERIFICATION_CHALLENGE_PATH.to_string(),
|
||||
body_digest: workspace_request_body_digest(&body),
|
||||
iat: unix_now_i64(),
|
||||
exp: challenge.expires_at,
|
||||
jti: "challenge-token".to_string(),
|
||||
};
|
||||
let token =
|
||||
issue_workspace_capability_token(&workspace_identity.signing_key().unwrap(), &claims)
|
||||
.unwrap();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(WORKSPACE_VERIFICATION_CHALLENGE_PATH)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&response_body)
|
||||
);
|
||||
let verification_response =
|
||||
serde_json::from_slice::<WorkspaceRuntimeVerificationResponse>(&response_body).unwrap();
|
||||
verify_runtime_verification_response(
|
||||
&verification_response,
|
||||
&challenge,
|
||||
&runtime_identity.public_key,
|
||||
unix_now_i64(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let acknowledgement = WorkspaceRuntimeVerificationAcknowledgement {
|
||||
challenge_id: verification_response.challenge_id.clone(),
|
||||
workspace_id: verification_response.workspace_id.clone(),
|
||||
runtime_id: verification_response.runtime_id.clone(),
|
||||
binding_revision: verification_response.binding_revision,
|
||||
workspace_key_id: verification_response.workspace_key_id.clone(),
|
||||
workspace_identity_revision: verification_response.workspace_identity_revision,
|
||||
workspace_trust_generation: verification_response.workspace_trust_generation,
|
||||
runtime_public_key_fingerprint: verification_response
|
||||
.runtime_public_key_fingerprint
|
||||
.clone(),
|
||||
runtime_identity_revision: verification_response.runtime_identity_revision,
|
||||
workspace_nonce: verification_response.workspace_nonce.clone(),
|
||||
runtime_nonce: verification_response.runtime_nonce.clone(),
|
||||
response_digest: workspace_request_body_digest(&response_body),
|
||||
response: verification_response.clone(),
|
||||
expires_at: verification_response.expires_at,
|
||||
};
|
||||
let acknowledgement_body = serde_json::to_vec(&acknowledgement).unwrap();
|
||||
let acknowledgement_claims = WorkspaceCapabilityClaims {
|
||||
path_and_query: WORKSPACE_VERIFICATION_ACK_PATH.to_string(),
|
||||
body_digest: workspace_request_body_digest(&acknowledgement_body),
|
||||
jti: "ack-token".to_string(),
|
||||
..claims
|
||||
};
|
||||
let acknowledgement_token = issue_workspace_capability_token(
|
||||
&workspace_identity.signing_key().unwrap(),
|
||||
&acknowledgement_claims,
|
||||
)
|
||||
.unwrap();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(WORKSPACE_VERIFICATION_ACK_PATH)
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {acknowledgement_token}"),
|
||||
)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(acknowledgement_body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
|
||||
|
||||
let ping_claims = WorkspaceCapabilityClaims {
|
||||
operation: RUNTIME_PING_PERMISSION.to_string(),
|
||||
method: "GET".to_string(),
|
||||
path_and_query: "/v1/ping".to_string(),
|
||||
body_digest: workspace_request_body_digest(&[]),
|
||||
jti: "ping-token".to_string(),
|
||||
..acknowledgement_claims
|
||||
};
|
||||
let ping_token = issue_workspace_capability_token(
|
||||
&workspace_identity.signing_key().unwrap(),
|
||||
&ping_claims,
|
||||
)
|
||||
.unwrap();
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/v1/ping")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {ping_token}"))
|
||||
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_requires_scoped_permission_and_returns_versioned_identity() {
|
||||
let runtime = Runtime::new_memory();
|
||||
@@ -2786,6 +3319,7 @@ mod tests {
|
||||
.expect("runtime"),
|
||||
local_token: Some(Arc::from("token")),
|
||||
auth: None,
|
||||
workspace_auth: None,
|
||||
workdir_sessions: Arc::new(Mutex::new(HashMap::from([(
|
||||
"session-1".to_string(),
|
||||
RuntimeHttpWorkdirSession {
|
||||
|
||||
@@ -22,13 +22,16 @@ use worker_runtime::error::RuntimeError;
|
||||
use worker_runtime::fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
|
||||
use worker_runtime::http_server::{
|
||||
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
|
||||
WorkspaceRuntimeHttpAuth,
|
||||
};
|
||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||
use worker_runtime::working_directory::RuntimeGitCacheMaterializer;
|
||||
use worker_runtime::workspace_issuer::{
|
||||
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,
|
||||
FileWorkspaceClaimReplayProtection, FileWorkspaceRuntimeVerificationAuthority,
|
||||
MAX_WORKSPACE_ISSUER_TRUST_RECORDS, RuntimeVerificationSigner, WorkspaceCapabilityVerifier,
|
||||
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};
|
||||
|
||||
@@ -88,6 +91,7 @@ fn run() -> Result<(), ProcessError> {
|
||||
};
|
||||
init_serve_tracing();
|
||||
config.http.auth = load_runtime_http_auth(&config)?;
|
||||
let workspace_http_auth = load_workspace_runtime_http_auth(&config)?;
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
@@ -99,14 +103,25 @@ fn run() -> Result<(), ProcessError> {
|
||||
eprintln!(
|
||||
"yoi-runtime listening on {local_addr}; intended client is a trusted backend/proxy, not a browser"
|
||||
);
|
||||
worker_runtime::http_server::serve_runtime_http_with_auth(
|
||||
worker_runtime,
|
||||
listener,
|
||||
config.http.local_token,
|
||||
config.http.auth,
|
||||
)
|
||||
.await
|
||||
.map_err(ProcessError::from)
|
||||
let server = if let Some(workspace_auth) = workspace_http_auth {
|
||||
worker_runtime::http_server::serve_runtime_http_with_workspace_auth(
|
||||
worker_runtime,
|
||||
listener,
|
||||
config.http.local_token,
|
||||
config.http.auth,
|
||||
workspace_auth,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
worker_runtime::http_server::serve_runtime_http_with_auth(
|
||||
worker_runtime,
|
||||
listener,
|
||||
config.http.local_token,
|
||||
config.http.auth,
|
||||
)
|
||||
.await
|
||||
};
|
||||
server.map_err(ProcessError::from)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -880,6 +895,35 @@ fn write_secret_file(path: &Path, contents: &[u8]) -> Result<(), ProcessError> {
|
||||
write_result
|
||||
}
|
||||
|
||||
fn load_workspace_runtime_http_auth(
|
||||
config: &ProcessConfig,
|
||||
) -> Result<Option<WorkspaceRuntimeHttpAuth>, ProcessError> {
|
||||
let auth = read_runtime_auth_file(&runtime_auth_path(config))?;
|
||||
let Some(identity) = auth.identity else {
|
||||
return Ok(None);
|
||||
};
|
||||
if auth.workspace_issuers.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let replay_path = runtime_auth_path(config).with_extension("workspace-replay.json");
|
||||
let verifier = WorkspaceCapabilityVerifier::new(
|
||||
auth.workspace_issuers,
|
||||
Arc::new(FileWorkspaceClaimReplayProtection::new(replay_path)),
|
||||
)
|
||||
.map_err(|error| ProcessError::auth(format!("invalid Workspace issuer trust: {error}")))?;
|
||||
let signer = RuntimeVerificationSigner::from_identity(&identity)
|
||||
.map_err(|error| ProcessError::auth(format!("invalid Runtime identity: {error}")))?;
|
||||
let verifications_path =
|
||||
runtime_auth_path(config).with_extension("workspace-verifications.json");
|
||||
Ok(Some(WorkspaceRuntimeHttpAuth {
|
||||
verifier,
|
||||
signer,
|
||||
verifications: Arc::new(FileWorkspaceRuntimeVerificationAuthority::new(
|
||||
verifications_path,
|
||||
)),
|
||||
}))
|
||||
}
|
||||
|
||||
fn load_runtime_http_auth(
|
||||
config: &ProcessConfig,
|
||||
) -> Result<Option<RuntimeHttpAuthConfig>, ProcessError> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user