From c549cdcafcd3f12fbfaa6d4b5e17a3c22db13cc7 Mon Sep 17 00:00:00 2001 From: Divya Vavili Date: Mon, 1 Jun 2026 17:19:00 -0700 Subject: [PATCH] feat(policy): add openshell-policy-engine-null test fixture for Engine wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minimal binary implementing the openshell.policy.v1alpha1.Engine service over UDS, signing a fixed projection body with a dev Ed25519 key. The conformance fixture that lets AttestedPolicyProvider talk to a real engine process — the first time the wire is exercised end-to-end. CLI: --socket-path, --projection-body, --signing-key-id, optional --signing-key-pem (generated if absent; public key fingerprint logged at startup so test setups can copy it into a trust store). Engine semantics: Health → SERVING; AcquireHandle → 16 random bytes, tracked in-memory; GetProjection → builds the wire envelope, signs canonical bytes with the dev key (shares canonical_projection_bytes with the gateway-side verify path so signer and verifier never disagree); ReleaseHandle → idempotent. Promotes policy_provider from pub(crate) to pub in openshell-server so the new crate's integration test can construct an AttestedPolicyProvider and exercise bind → fetch → verify → decode → release against the live null engine over a real UDS. Tests: 3 integration tests (happy path; untrusted-signing-key-id rejection; health-on-construct). openshell-server lib suite green at 770. Test fixture only — no bundle pipeline, no policy lowering, not for production deployment. Production engine implementations conforming to this wire are tested for behavioral parity against this fixture. --- Cargo.lock | 26 ++ .../openshell-policy-engine-null/Cargo.toml | 65 +++ .../openshell-policy-engine-null/src/lib.rs | 375 ++++++++++++++++++ .../openshell-policy-engine-null/src/main.rs | 154 +++++++ .../tests/integration.rs | 226 +++++++++++ crates/openshell-server/src/lib.rs | 8 +- .../src/policy_provider/mod.rs | 4 +- 7 files changed, 856 insertions(+), 2 deletions(-) create mode 100644 crates/openshell-policy-engine-null/Cargo.toml create mode 100644 crates/openshell-policy-engine-null/src/lib.rs create mode 100644 crates/openshell-policy-engine-null/src/main.rs create mode 100644 crates/openshell-policy-engine-null/tests/integration.rs diff --git a/Cargo.lock b/Cargo.lock index da55af0523..ad2cb9670c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3596,6 +3596,32 @@ dependencies = [ "serde_yml", ] +[[package]] +name = "openshell-policy-engine-null" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "clap", + "ed25519-dalek", + "hyper-util", + "openshell-core", + "openshell-policy", + "openshell-server", + "prost", + "rand 0.9.4", + "rand_core 0.6.4", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tonic", + "tower 0.5.3", + "tracing", + "tracing-subscriber", +] + [[package]] name = "openshell-prover" version = "0.0.0" diff --git a/crates/openshell-policy-engine-null/Cargo.toml b/crates/openshell-policy-engine-null/Cargo.toml new file mode 100644 index 0000000000..891abd0b37 --- /dev/null +++ b/crates/openshell-policy-engine-null/Cargo.toml @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-policy-engine-null" +description = "Null reference Engine for the openshell.policy.v1alpha1 wire — Gateway-side test fixture only" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "openshell-policy-engine-null" +path = "src/main.rs" + +[dependencies] +# openshell-server re-exports `canonical_projection_bytes` and +# `ProjectionEnvelope` (gateway-internal mirror of the wire envelope) so +# the null engine and the AttestedPolicyProvider sign + verify against +# the same canonical byte order. See the policy_provider/source.rs +# canonical-bytes helper. +openshell-core = { path = "../openshell-core" } +openshell-policy = { path = "../openshell-policy" } +openshell-server = { path = "../openshell-server" } + +# Async runtime +tokio = { workspace = true } +tokio-stream = { workspace = true } + +# gRPC +tonic = { workspace = true } +prost = { workspace = true } + +# Service plumbing +tower = { workspace = true } +hyper-util = { workspace = true, features = ["tokio"] } + +# CLI +clap = { workspace = true } + +# Logging +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +# Error +anyhow = { workspace = true } +thiserror = { workspace = true } + +# Crypto +ed25519-dalek = { workspace = true } +# rand_core 0.6 because ed25519-dalek v2 still consumes that trait surface +# for SigningKey::generate. Matches openshell-server's pin. +rand_core_06 = { package = "rand_core", version = "0.6", features = ["getrandom"] } +rand = { workspace = true } +sha2 = { workspace = true } + +# Async trait +async-trait = "0.1" + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/openshell-policy-engine-null/src/lib.rs b/crates/openshell-policy-engine-null/src/lib.rs new file mode 100644 index 0000000000..2e664be97f --- /dev/null +++ b/crates/openshell-policy-engine-null/src/lib.rs @@ -0,0 +1,375 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Null reference engine for the `openshell.policy.v1alpha1.Engine` wire. +//! +//! This crate is **a Gateway-side test fixture**. Its job is to be the +//! simplest possible thing on the other side of a UDS that satisfies the +//! OpenShell-owned `Engine` 4-RPC contract: `Health`, `AcquireHandle`, +//! `GetProjection`, `ReleaseHandle`. It returns a fixed policy projection +//! body loaded at startup and signs the envelope with a dev Ed25519 key. +//! +//! What this is **not**: +//! +//! - It is not the Verifier. There is no bundle pipeline. +//! - It is not RPV. No `Authorize`, no `GetPolicyDigest`, no native +//! `apf.rpv.v1alpha2` surface. +//! - It is not an attested-policy daemon. There is no trust-root +//! provisioning, no policy lowering, no canonical-policy decoder. +//! +//! The fixture exists to bridge the OpenShell-side `AttestedPolicyProvider` +//! (W-B) with a real engine process before the RPV-side `Engine`-surface +//! adapter (W-A) is in place. See the APP implementation plan W-A bullet +//! ("In-tree null Verifier") and W-C Phase-B-consequences for the framing. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; +use ed25519_dalek::{Signer, SigningKey}; +use openshell_core::proto::policy::v1alpha1 as wire; +use openshell_server::policy_provider::{ + canonical_projection_bytes, ProjectionEnvelope as GatewayProjectionEnvelope, +}; +use prost::Message; +use sha2::{Digest, Sha256}; +use tokio::sync::Mutex; +use tonic::{Request, Response, Status}; +use tracing::{debug, info, warn}; +use wire::engine_server::Engine; + +/// Surface id this engine serves. The only one it serves. +pub const SANDBOX_POLICY_SURFACE_ID: &str = "openshell.sandbox.v1"; + +/// Schema version stamped on every projection envelope this engine emits. +/// +/// Lockstep with the gateway-side `AttestedPolicyProvider`'s expectations. +/// The gateway uses `surface_id + schema_version` to decide how to decode +/// `body`; today it routes everything under +/// `openshell.sandbox.v1` through the canonical `SandboxPolicy` decoder. +pub const SANDBOX_POLICY_SCHEMA_VERSION: &str = "v1"; + +/// Errors raised while loading the projection body at engine startup. +#[derive(Debug, thiserror::Error)] +pub enum ProjectionLoadError { + #[error("failed to read projection body at '{path}': {source}")] + Io { + path: String, + #[source] + source: std::io::Error, + }, + + #[error("projection body at '{path}' has unsupported extension '{ext}' (expected yaml/yml/bin/pb)")] + UnsupportedExtension { path: String, ext: String }, + + #[error("projection body at '{path}' has no extension (expected yaml/yml/bin/pb)")] + NoExtension { path: String }, + + #[error("failed to parse YAML projection body at '{path}': {message}")] + Yaml { path: String, message: String }, + + #[error("failed to decode protobuf projection body at '{path}': {source}")] + Decode { + path: String, + #[source] + source: prost::DecodeError, + }, +} + +/// Load a `SandboxPolicy` projection body from disk and return the +/// protobuf-encoded bytes. +/// +/// Format is picked by file extension: +/// +/// - `.yaml` / `.yml` → parse via `openshell_policy::parse_sandbox_policy`, +/// then re-encode as protobuf. +/// - `.bin` / `.pb` → bytes verbatim, after a sanity decode to confirm +/// they are a valid `SandboxPolicy`. +/// +/// Other extensions are rejected at startup so a typo never reaches the +/// wire as opaque bytes. +pub fn load_projection_body(path: &Path) -> Result, ProjectionLoadError> { + let bytes = std::fs::read(path).map_err(|source| ProjectionLoadError::Io { + path: path.display().to_string(), + source, + })?; + + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .ok_or_else(|| ProjectionLoadError::NoExtension { + path: path.display().to_string(), + })?; + + match ext.as_str() { + "yaml" | "yml" => { + let yaml = String::from_utf8(bytes).map_err(|e| ProjectionLoadError::Io { + path: path.display().to_string(), + source: std::io::Error::new(std::io::ErrorKind::InvalidData, e), + })?; + let policy = openshell_policy::parse_sandbox_policy(&yaml).map_err(|report| { + ProjectionLoadError::Yaml { + path: path.display().to_string(), + message: format!("{report:?}"), + } + })?; + Ok(policy.encode_to_vec()) + } + "bin" | "pb" => { + // Sanity decode so a malformed .pb fails at startup rather than + // on the first gateway call. + openshell_core::proto::SandboxPolicy::decode(bytes.as_slice()).map_err(|source| { + ProjectionLoadError::Decode { + path: path.display().to_string(), + source, + } + })?; + Ok(bytes) + } + other => Err(ProjectionLoadError::UnsupportedExtension { + path: path.display().to_string(), + ext: other.to_string(), + }), + } +} + +/// SHA-256 fingerprint of an Ed25519 verifying key in lowercase hex. Used +/// in the startup log so a test setup can confirm the trust store carries +/// the matching key. +#[must_use] +pub fn verifying_key_fingerprint(vk: &ed25519_dalek::VerifyingKey) -> String { + let digest = Sha256::digest(vk.as_bytes()); + let mut out = String::with_capacity(64); + for b in digest { + use std::fmt::Write as _; + let _ = write!(&mut out, "{b:02x}"); + } + out +} + +/// Configuration captured at engine startup. +#[derive(Debug, Clone)] +pub struct NullEngineConfig { + /// `signing_key_id` placed in every `ProjectionEnvelope`. Trust store + /// on the gateway side keys lookups by this string. + pub signing_key_id: String, + /// Pre-loaded protobuf-encoded `SandboxPolicy` bytes returned as + /// `body` on every `GetProjection`. + pub projection_body: Vec, + /// SHA-256 of `projection_body`. Stamped on every envelope so the + /// gateway can correlate identical projections across calls. + pub policy_digest: Vec, +} + +impl NullEngineConfig { + /// Build a config from a projection body byte slice. + #[must_use] + pub fn new(signing_key_id: String, projection_body: Vec) -> Self { + let policy_digest = Sha256::digest(&projection_body).to_vec(); + Self { + signing_key_id, + projection_body, + policy_digest, + } + } +} + +/// In-memory record of a handle the engine has minted. +/// +/// The runtime context is captured but otherwise unused — the null engine +/// does not authorize against it, it just remembers enough to honor +/// `ReleaseHandle` and detect "unknown handle" on `GetProjection`. +#[derive(Debug, Clone)] +struct BoundContext { + sandbox_id: String, +} + +/// Null engine state. +/// +/// Cloneable so each `tonic::Service` clone shares the handle map and the +/// signing key. The handle map lives behind a Tokio mutex; the signing +/// key is read-only. +#[derive(Debug, Clone)] +pub struct NullEngine { + config: Arc, + signing_key: Arc, + handles: Arc, BoundContext>>>, +} + +impl NullEngine { + /// Construct an engine from a pre-loaded config and a signing key. + #[must_use] + pub fn new(config: NullEngineConfig, signing_key: SigningKey) -> Self { + Self { + config: Arc::new(config), + signing_key: Arc::new(signing_key), + handles: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Number of handles currently tracked. Test-only helper; the public + /// surface is the gRPC RPCs. + pub async fn handle_count(&self) -> usize { + self.handles.lock().await.len() + } +} + +#[async_trait] +impl Engine for NullEngine { + async fn health( + &self, + _req: Request, + ) -> Result, Status> { + // Startup loaded the projection and signing key; if we are alive + // we are serving. NOT_SERVING is kept as a defensive enum value + // but is unreachable here. + Ok(Response::new(wire::HealthResponse { + status: wire::health_response::Status::Serving as i32, + })) + } + + async fn acquire_handle( + &self, + req: Request, + ) -> Result, Status> { + // The null engine does NOT verify the runtime-context signature. + // The gateway sets it (see `GrpcPolicySource::acquire_handle`), + // but this fixture has no trust store for gateway-side keys — + // adding one would push it past "minimal test fixture" and start + // duplicating Verifier behavior. Explicit non-goal. + let envelope = req + .into_inner() + .envelope + .ok_or_else(|| Status::invalid_argument("envelope missing"))?; + + if envelope.sandbox_id.is_empty() { + return Err(Status::invalid_argument("envelope.sandbox_id is empty")); + } + + // 16 random bytes. The handle is opaque to the gateway by the + // wire contract; bytes from `rand` are good enough — this is a + // test fixture, not a security boundary. + let handle_bytes: [u8; 16] = rand::random(); + let handle = handle_bytes.to_vec(); + + let ctx = BoundContext { + sandbox_id: envelope.sandbox_id.clone(), + }; + self.handles.lock().await.insert(handle.clone(), ctx); + + debug!( + sandbox_id = %envelope.sandbox_id, + handle_len = handle.len(), + "null-engine: handle bound" + ); + + Ok(Response::new(wire::AcquireHandleResponse { handle })) + } + + async fn get_projection( + &self, + req: Request, + ) -> Result, Status> { + let inner = req.into_inner(); + + if inner.surface_id != SANDBOX_POLICY_SURFACE_ID { + return Err(Status::failed_precondition(format!( + "null engine only serves surface '{SANDBOX_POLICY_SURFACE_ID}', got '{}'", + inner.surface_id + ))); + } + + // Handle must have been bound. Idempotent release semantics are on + // ReleaseHandle, not here — an unknown handle on GetProjection is + // an error. + { + let handles = self.handles.lock().await; + if !handles.contains_key(&inner.handle) { + return Err(Status::not_found("unknown handle")); + } + } + + // Build the envelope in the gateway-internal mirror shape, sign + // its canonical bytes (the EXACT bytes the AttestedPolicyProvider + // re-canonicalizes on the verify side via the shared helper), + // then translate to the wire type for return. Sharing the + // canonical helper is the load-bearing piece — see the + // `openshell_server::policy_provider::canonical_projection_bytes` + // doc comment. + let mirror = GatewayProjectionEnvelope { + surface_id: SANDBOX_POLICY_SURFACE_ID.to_string(), + schema_version: SANDBOX_POLICY_SCHEMA_VERSION.to_string(), + policy_digest: self.config.policy_digest.clone(), + // Empty in v0 — the null engine has no bundle pipeline. + bundle_digest: Vec::new(), + body: self.config.projection_body.clone(), + signature: Vec::new(), + signing_key_id: None, + }; + + let payload = canonical_projection_bytes(&mirror); + let signature = self.signing_key.sign(&payload).to_bytes().to_vec(); + + let envelope = wire::ProjectionEnvelope { + surface_id: mirror.surface_id, + schema_version: mirror.schema_version, + policy_digest: mirror.policy_digest, + bundle_digest: mirror.bundle_digest, + body: mirror.body, + signature, + signing_key_id: self.config.signing_key_id.clone(), + }; + + Ok(Response::new(wire::GetProjectionResponse { + envelope: Some(envelope), + })) + } + + async fn release_handle( + &self, + req: Request, + ) -> Result, Status> { + let inner = req.into_inner(); + // Contractually idempotent; unknown handle is still OK. + let removed = self.handles.lock().await.remove(&inner.handle); + if let Some(ctx) = removed { + debug!( + sandbox_id = %ctx.sandbox_id, + "null-engine: handle released" + ); + } else { + debug!(handle_len = inner.handle.len(), "null-engine: release_handle on unknown handle (idempotent)"); + } + Ok(Response::new(wire::ReleaseHandleResponse {})) + } +} + +/// Helper that logs the startup-summary line every operator wants to see. +pub fn log_startup_summary( + socket_path: &Path, + signing_key_id: &str, + vk: &ed25519_dalek::VerifyingKey, +) { + info!( + socket_path = %socket_path.display(), + signing_key_id, + fingerprint = %verifying_key_fingerprint(vk), + surface_id = %SANDBOX_POLICY_SURFACE_ID, + "null policy engine ready" + ); +} + +/// Warn when startup generated a fresh signing key but no +/// `--signing-key-pem` was provided. +/// +/// The operator must paste the public key (printed on stdout) into the +/// gateway's trust store for the integration to verify. +pub fn warn_if_ephemeral_key(used_pem: bool) { + if !used_pem { + warn!( + "null engine generated a fresh signing key; copy the PEM printed to stdout into the gateway trust store" + ); + } +} diff --git a/crates/openshell-policy-engine-null/src/main.rs b/crates/openshell-policy-engine-null/src/main.rs new file mode 100644 index 0000000000..173e8de90f --- /dev/null +++ b/crates/openshell-policy-engine-null/src/main.rs @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Null policy engine binary — the Gateway-side test fixture that +//! satisfies the `openshell.policy.v1alpha1.Engine` 4-RPC wire. +//! +//! See `openshell_policy_engine_null` library docs for what this is and +//! is not. + +use std::path::PathBuf; + +use anyhow::{anyhow, Context, Result}; +use clap::Parser; +use ed25519_dalek::pkcs8::spki::der::pem::LineEnding; +use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePublicKey}; +use ed25519_dalek::SigningKey; +use openshell_core::proto::policy::v1alpha1::engine_server::EngineServer; +use openshell_policy_engine_null::{ + load_projection_body, log_startup_summary, warn_if_ephemeral_key, NullEngine, + NullEngineConfig, +}; +use rand_core_06::OsRng; +use tokio::net::UnixListener; +use tokio_stream::wrappers::UnixListenerStream; +use tonic::transport::Server; +use tracing::info; + +#[derive(Parser, Debug)] +#[command( + name = "openshell-policy-engine-null", + about = "Null reference Engine for the openshell.policy.v1alpha1 wire (test fixture only)" +)] +struct Cli { + /// UDS path to bind. Parent directory must exist; path must not be in + /// use. + #[arg(long)] + socket_path: PathBuf, + + /// Path to a `SandboxPolicy` projection body. Extension `.yaml`/`.yml` + /// parses as the canonical `SandboxPolicy` YAML; `.bin`/`.pb` is taken + /// verbatim as protobuf-encoded bytes. + #[arg(long)] + projection_body: PathBuf, + + /// Value stamped into every `ProjectionEnvelope.signing_key_id`. The + /// gateway-side trust store keys lookups by this string. + #[arg(long)] + signing_key_id: String, + + /// Optional path to an Ed25519 private key in PKCS#8 PEM form. When + /// absent, a fresh signing key is generated and its public-key PEM is + /// printed to stdout for the operator to copy into the gateway trust + /// store. + #[arg(long)] + signing_key_pem: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .init(); + + let cli = Cli::parse(); + + // -- Pre-flight checks for the UDS path. + // + // The connector on the gateway side will retry, so a clean startup + // failure here is preferable to silently shadowing an existing + // socket. + if cli.socket_path.exists() { + return Err(anyhow!( + "socket path '{}' already exists; remove it before starting", + cli.socket_path.display() + )); + } + if let Some(parent) = cli.socket_path.parent() { + // Empty parent (relative bare filename) is OK — bind in cwd. + if !parent.as_os_str().is_empty() && !parent.exists() { + return Err(anyhow!( + "socket path parent directory '{}' does not exist", + parent.display() + )); + } + } + + // -- Load the projection body once at startup. A bad body fails fast. + let projection_body = load_projection_body(&cli.projection_body) + .with_context(|| format!("loading {}", cli.projection_body.display()))?; + info!( + path = %cli.projection_body.display(), + bytes = projection_body.len(), + "loaded projection body" + ); + + // -- Resolve the signing key. + let (signing_key, used_pem) = if let Some(p) = cli.signing_key_pem.as_ref() { + let pem = std::fs::read_to_string(p) + .with_context(|| format!("reading signing key PEM at {}", p.display()))?; + let sk = SigningKey::from_pkcs8_pem(&pem) + .map_err(|e| anyhow!("invalid PKCS#8 PEM at {}: {e}", p.display()))?; + (sk, true) + } else { + let sk = SigningKey::generate(&mut OsRng); + let pem = sk + .verifying_key() + .to_public_key_pem(LineEnding::LF) + .map_err(|e| anyhow!("encoding generated public key as PEM: {e}"))?; + // Print to stdout so a test harness can pipe / copy. Logs go to + // stderr, so the two streams stay separable. + println!("# null policy engine — generated public key (PEM)"); + println!("# key_id: {}", cli.signing_key_id); + print!("{pem}"); + (sk, false) + }; + + warn_if_ephemeral_key(used_pem); + + let vk = signing_key.verifying_key(); + log_startup_summary(&cli.socket_path, &cli.signing_key_id, &vk); + + let config = NullEngineConfig::new(cli.signing_key_id.clone(), projection_body); + let engine = NullEngine::new(config, signing_key); + + // -- Bind the UDS and serve. + // + // Mirrors the tonic UDS server pattern used elsewhere in the + // workspace (see openshell-server compute drivers). + let listener = UnixListener::bind(&cli.socket_path).with_context(|| { + format!("binding UDS at {}", cli.socket_path.display()) + })?; + let incoming = UnixListenerStream::new(listener); + + // Ensure we clean up the socket on shutdown so a restart does not + // require manual cleanup. + let socket_path_for_cleanup = cli.socket_path.clone(); + let shutdown = async move { + let _ = tokio::signal::ctrl_c().await; + info!("ctrl-c received; shutting down null engine"); + }; + + let result = Server::builder() + .add_service(EngineServer::new(engine)) + .serve_with_incoming_shutdown(incoming, shutdown) + .await; + + // Best-effort socket cleanup. + let _ = std::fs::remove_file(&socket_path_for_cleanup); + + result.context("null engine server exited with error") +} diff --git a/crates/openshell-policy-engine-null/tests/integration.rs b/crates/openshell-policy-engine-null/tests/integration.rs new file mode 100644 index 0000000000..61b33d926c --- /dev/null +++ b/crates/openshell-policy-engine-null/tests/integration.rs @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end integration test between the OpenShell-side +//! `AttestedPolicyProvider` and the null engine over a real UDS. +//! +//! This is the load-bearing deliverable for the null-engine session: if +//! the canonical-byte ordering on the engine side does not match the +//! gateway-side verify path, this test fails with a signature-verify +//! error rather than compiling-but-passing. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use ed25519_dalek::pkcs8::EncodePublicKey; +use ed25519_dalek::SigningKey; +use openshell_core::proto::policy::v1alpha1::engine_server::EngineServer; +use openshell_policy_engine_null::{NullEngine, NullEngineConfig}; +use openshell_server::policy_provider::{ + AttestedPolicyProvider, GrpcPolicySource, PolicyProvider, TrustStore, +}; +use prost::Message; +use rand_core_06::OsRng; +use tokio::net::UnixListener; +use tokio::task::JoinHandle; +use tokio_stream::wrappers::UnixListenerStream; +use tonic::transport::Server; + +/// Spawn the null engine on a freshly-bound UDS and return the socket path, +/// a join handle for the server, and a shutdown sender. +struct EngineFixture { + socket_path: PathBuf, + _server: JoinHandle<()>, + shutdown: tokio::sync::oneshot::Sender<()>, +} + +impl EngineFixture { + fn spawn(engine: NullEngine, socket_dir: &tempfile::TempDir) -> Self { + let socket_path = socket_dir.path().join("engine.sock"); + // Bind before returning so the client never races with a not-yet- + // listening socket. + let listener = UnixListener::bind(&socket_path).expect("bind UDS"); + let incoming = UnixListenerStream::new(listener); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + Server::builder() + .add_service(EngineServer::new(engine)) + .serve_with_incoming_shutdown(incoming, async move { + let _ = shutdown_rx.await; + }) + .await + .expect("null engine serve"); + }); + + Self { + socket_path, + _server: server, + shutdown: shutdown_tx, + } + } + + fn shutdown(self) { + let _ = self.shutdown.send(()); + } +} + +/// Helper: load a `SandboxPolicy` from a YAML fragment and produce the +/// protobuf bytes the null engine would otherwise load from disk. +fn projection_bytes_from_yaml(yaml: &str) -> Vec { + let policy = openshell_policy::parse_sandbox_policy(yaml).expect("parse YAML"); + policy.encode_to_vec() +} + +/// Helper: build a JSON trust store carrying a single key id → PEM mapping. +fn write_trust_store( + dir: &tempfile::TempDir, + key_id: &str, + vk: &ed25519_dalek::VerifyingKey, +) -> PathBuf { + let pem = vk + .to_public_key_pem(ed25519_dalek::pkcs8::spki::der::pem::LineEnding::LF) + .expect("encode public-key PEM"); + let json = format!( + r#"{{"keys":[{{"key_id":{key_id:?},"public_key_pem":{pem:?}}}]}}"#, + ); + let path = dir.path().join("trust.json"); + std::fs::write(&path, json).expect("write trust store"); + path +} + +/// Wait for the UDS to be reachable. The server is spawned on a Tokio task +/// so there is a brief window where the file exists but `serve_with_incoming` +/// has not yet started its accept loop. A tight retry loop is sufficient. +async fn wait_for_socket(path: &std::path::Path) { + for _ in 0..50 { + if tokio::net::UnixStream::connect(path).await.is_ok() { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("null engine socket {} never became reachable", path.display()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attested_provider_resolves_policy_from_null_engine() { + let signing_key = SigningKey::generate(&mut OsRng); + let vk = signing_key.verifying_key(); + let key_id = "k-null-it-1"; + + let projection = projection_bytes_from_yaml("version: 1\n"); + let config = NullEngineConfig::new(key_id.to_string(), projection.clone()); + let engine = NullEngine::new(config, signing_key); + + let socket_dir = tempfile::tempdir().expect("tmp dir"); + let trust_dir = tempfile::tempdir().expect("tmp dir"); + + let fixture = EngineFixture::spawn(engine, &socket_dir); + wait_for_socket(&fixture.socket_path).await; + + let trust_path = write_trust_store(&trust_dir, key_id, &vk); + let trust_store = TrustStore::load(&trust_path).expect("load trust store"); + + let source = GrpcPolicySource::connect(&fixture.socket_path) + .await + .expect("connect to null engine over UDS"); + let provider = AttestedPolicyProvider::new(Arc::new(source), trust_store) + .await + .expect("attested provider constructs (health passes)"); + + let policy = provider + .get_effective_policy("test-sandbox") + .await + .expect("policy fetch ok") + .expect("policy present"); + + // Round-trip check: the policy the gateway sees must equal the one the + // engine was configured with. Any mismatch in canonical-byte ordering + // would have surfaced as a signature-verify error before we got here, + // so reaching this assertion already proves the two wires meet. + let expected = openshell_core::proto::SandboxPolicy::decode(projection.as_slice()) + .expect("decode expected policy"); + assert_eq!(policy.version, expected.version); + + fixture.shutdown(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attested_provider_rejects_when_signing_key_id_is_not_trusted() { + // The null engine signs with `k-engine`; the trust store knows only + // `k-other` (a different key). The gateway must refuse the envelope. + let signing_key = SigningKey::generate(&mut OsRng); + let other_key = SigningKey::generate(&mut OsRng); + + let projection = projection_bytes_from_yaml("version: 2\n"); + let engine_config = NullEngineConfig::new("k-engine".to_string(), projection); + let engine = NullEngine::new(engine_config, signing_key); + + let socket_dir = tempfile::tempdir().expect("tmp dir"); + let trust_dir = tempfile::tempdir().expect("tmp dir"); + + let fixture = EngineFixture::spawn(engine, &socket_dir); + wait_for_socket(&fixture.socket_path).await; + + // Trust store carries a different key id than the one the engine + // stamps onto the envelope. + let trust_path = write_trust_store(&trust_dir, "k-other", &other_key.verifying_key()); + let trust_store = TrustStore::load(&trust_path).expect("load trust store"); + + let source = GrpcPolicySource::connect(&fixture.socket_path) + .await + .expect("connect"); + let provider = AttestedPolicyProvider::new(Arc::new(source), trust_store) + .await + .expect("attested provider constructs"); + + let err = provider + .get_effective_policy("test-sandbox") + .await + .expect_err("untrusted signing_key_id must reject"); + + // Surface the rejection as a SourceError(Rejected) so callers can + // distinguish "engine unreachable" from "engine reachable but + // verify failed". The exact reason string mentions the unknown key id. + let msg = format!("{err}"); + assert!( + msg.contains("k-engine") || msg.to_lowercase().contains("unknown"), + "expected rejection to mention unknown key id, got: {msg}" + ); + + fixture.shutdown(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn null_engine_health_serves_immediately() { + // Defensive: Health returns SERVING the moment the engine is running. + // Catches regressions where startup ordering shifts and Health blocks. + let signing_key = SigningKey::generate(&mut OsRng); + let projection = projection_bytes_from_yaml("version: 1\n"); + let engine = NullEngine::new( + NullEngineConfig::new("k-1".into(), projection), + signing_key, + ); + + let socket_dir = tempfile::tempdir().expect("tmp dir"); + let fixture = EngineFixture::spawn(engine, &socket_dir); + wait_for_socket(&fixture.socket_path).await; + + let trust_dir = tempfile::tempdir().expect("tmp dir"); + let any_key = SigningKey::generate(&mut OsRng); + let trust_path = write_trust_store(&trust_dir, "k-1", &any_key.verifying_key()); + let trust_store = TrustStore::load(&trust_path).expect("load"); + + let source = GrpcPolicySource::connect(&fixture.socket_path) + .await + .expect("connect"); + + // `AttestedPolicyProvider::new` runs `source.health()` internally; a + // successful construction is the health assertion. + let _provider = AttestedPolicyProvider::new(Arc::new(source), trust_store) + .await + .expect("attested provider constructs (health reported SERVING)"); + + fixture.shutdown(); +} diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index bdfdfbd53d..f5546c7b29 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -30,7 +30,13 @@ mod http; mod inference; mod multiplex; mod persistence; -pub(crate) mod policy_provider; +// Public so the OpenShell-owned `openshell.policy.v1alpha1.Engine` test +// fixture (`openshell-policy-engine-null`) can share the canonical +// `canonical_projection_bytes` helper and `ProjectionEnvelope` mirror +// shape with the `AttestedPolicyProvider` verify path, and so cross-crate +// integration tests can construct an `AttestedPolicyProvider` +// programmatically. +pub mod policy_provider; pub(crate) mod policy_store; mod provider_refresh; mod readiness; diff --git a/crates/openshell-server/src/policy_provider/mod.rs b/crates/openshell-server/src/policy_provider/mod.rs index e15b87a7b0..a21cbe8a33 100644 --- a/crates/openshell-server/src/policy_provider/mod.rs +++ b/crates/openshell-server/src/policy_provider/mod.rs @@ -28,7 +28,9 @@ use crate::persistence::PersistenceError; pub use attested::AttestedPolicyProvider; pub use local::LocalPolicyProvider; -pub use source::{GrpcPolicySource, PolicySourceError}; +pub use source::{ + canonical_projection_bytes, GrpcPolicySource, PolicySourceError, ProjectionEnvelope, +}; pub use trust_store::TrustStore; /// Policy-type id for the in-process, store-backed policy provider.