Share owned host API state between HTTP hooks and native pages
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Host API and host pages
|
||||
|
||||
`handle(&Api, Caller, Request<Bytes>)` answers requests without a transport.
|
||||
The later `wasi:http` hook supplies the caller from the process table.
|
||||
The `wasi:http` hook supplies the caller from the process table.
|
||||
It must not accept caller identity from headers, environment variables, or JSON.
|
||||
Reads are open to every caller.
|
||||
JSON routes return 200 on success.
|
||||
@@ -11,11 +11,13 @@ Conflicts return 409. The filing budget returns 429.
|
||||
|
||||
## Host listener
|
||||
|
||||
`sausage run WORLD` starts the console and the host listener with one shared `Arc<Cred>`.
|
||||
The listener owns its passkey connection and handles requests on a dedicated thread.
|
||||
The console receives the same credentials store, including its decision notifications.
|
||||
`sausage run WORLD` creates one `Cred`, one `Arc<Api>`, and one process runtime.
|
||||
The API owns its passkey connection and retains ceremony and rate-limit state.
|
||||
The hook and the host listener share that API.
|
||||
The listener handles requests on a dedicated thread.
|
||||
The console shares the credential store, including its decision notifications.
|
||||
After the listener binds, the runtime denies its port on every local address.
|
||||
The listener stops when the console returns.
|
||||
No protected harness or process module needs a change.
|
||||
|
||||
Options on `sausage run`:
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ fn choice(body: &Value) -> Result<(Decision, Overrides)> {
|
||||
}
|
||||
Ok((decision, overrides))
|
||||
}
|
||||
fn verify(ctx: &Api<'_>, body: &Value) -> Result<Verified> {
|
||||
fn verify(ctx: &Api, body: &Value) -> Result<Verified> {
|
||||
ctx.passkeys
|
||||
.lock()
|
||||
.map_err(|_| Error(500, "storage"))?
|
||||
@@ -25,7 +25,7 @@ fn verify(ctx: &Api<'_>, body: &Value) -> Result<Verified> {
|
||||
)
|
||||
.map_err(|_| Error(403, "assertion_failed"))
|
||||
}
|
||||
pub(super) fn field(ctx: &Api<'_>, item: &str, field: &str) -> Result<cred::items::Field> {
|
||||
pub(super) fn field(ctx: &Api, item: &str, field: &str) -> Result<cred::items::Field> {
|
||||
let field = ctx
|
||||
.cred
|
||||
.item_get(item)?
|
||||
@@ -39,7 +39,7 @@ pub(super) fn field(ctx: &Api<'_>, item: &str, field: &str) -> Result<cred::item
|
||||
Ok(field)
|
||||
}
|
||||
pub(super) fn route(
|
||||
ctx: &Api<'_>,
|
||||
ctx: &Api,
|
||||
caller: &Caller,
|
||||
method: &str,
|
||||
path: &[&str],
|
||||
|
||||
+26
-8
@@ -17,16 +17,34 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub struct Api<'a> {
|
||||
pub struct Api {
|
||||
pub cred: Arc<Cred>,
|
||||
script: cred::quickjs::QuickJs,
|
||||
pub passkeys: Mutex<Passkeys<'a>>,
|
||||
pub passkeys: Mutex<Passkeys<rusqlite::Connection>>,
|
||||
pub entry: EntryKey,
|
||||
requests: Mutex<HashMap<u32, (Instant, u32)>>,
|
||||
}
|
||||
impl<'a> Api<'a> {
|
||||
impl Api {
|
||||
/// Own the connection and ceremony state shared by both transports.
|
||||
pub fn open(
|
||||
cred: Arc<Cred>,
|
||||
host_dir: &std::path::Path,
|
||||
rp_id: &str,
|
||||
origin: &str,
|
||||
) -> anyhow::Result<Self> {
|
||||
let db = rusqlite::Connection::open(host_dir.join("cred.sqlite"))?;
|
||||
Ok(Self::new(
|
||||
cred,
|
||||
Passkeys::new(db, rp_id, origin)?,
|
||||
EntryKey::open(&host_dir.join("entry.key"))?,
|
||||
))
|
||||
}
|
||||
/// Passkeys owns the RP configuration. Its database must remain host-only.
|
||||
pub fn new(cred: impl Into<Arc<Cred>>, passkeys: Passkeys<'a>, entry: EntryKey) -> Self {
|
||||
pub fn new(
|
||||
cred: impl Into<Arc<Cred>>,
|
||||
passkeys: Passkeys<rusqlite::Connection>,
|
||||
entry: EntryKey,
|
||||
) -> Self {
|
||||
Self {
|
||||
cred: cred.into(),
|
||||
script: cred::quickjs::QuickJs::default(),
|
||||
@@ -113,10 +131,10 @@ fn request_view(request: cred::requests::Request) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Handle a request without a network transport. Errors contain only static error kinds.
|
||||
pub async fn handle(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
|
||||
pub async fn handle(ctx: &Api, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
|
||||
respond(ctx, caller, request)
|
||||
}
|
||||
fn respond(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
|
||||
fn respond(ctx: &Api, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
|
||||
let result = if request.method() == http::Method::GET {
|
||||
match pages::get(ctx, request.uri().path()) {
|
||||
Ok(Some(response)) => return response,
|
||||
@@ -143,7 +161,7 @@ fn respond(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<B
|
||||
.body(Bytes::from(serde_json::to_vec(&body).expect("JSON value")))
|
||||
.expect("valid response")
|
||||
}
|
||||
fn route(ctx: &Api<'_>, caller: &Caller, request: Request<Bytes>) -> Result<Value> {
|
||||
fn route(ctx: &Api, caller: &Caller, request: Request<Bytes>) -> Result<Value> {
|
||||
let path: Vec<_> = request.uri().path().trim_matches('/').split('/').collect();
|
||||
let method = request.method().as_str();
|
||||
let body: Value = if request.body().is_empty() {
|
||||
@@ -206,7 +224,7 @@ mod tests {
|
||||
let db = rusqlite::Connection::open_in_memory()?;
|
||||
let api = Api::new(
|
||||
Cred::open(dir.path())?,
|
||||
Passkeys::new(&db, "localhost", "http://localhost:8080")?,
|
||||
Passkeys::new(db, "localhost", "http://localhost:8080")?,
|
||||
EntryKey::open(&dir.path().join("entry.key"))?,
|
||||
);
|
||||
let caller = Caller::default();
|
||||
|
||||
@@ -10,7 +10,7 @@ fn escape(text: &str) -> String {
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
pub(super) fn get(ctx: &Api<'_>, path: &str) -> Result<Option<Response<Bytes>>> {
|
||||
pub(super) fn get(ctx: &Api, path: &str) -> Result<Option<Response<Bytes>>> {
|
||||
let parts: Vec<_> = path.trim_matches('/').split('/').collect();
|
||||
let (mime, text) = match parts.as_slice() {
|
||||
["app.js"] => (
|
||||
|
||||
+67
-65
@@ -1,6 +1,6 @@
|
||||
//! The host HTTP listener and console startup with one shared credentials store.
|
||||
//! The host HTTP listener and console startup share one owned API and credentials store.
|
||||
use super::{Api, respond};
|
||||
use crate::cred::{Caller, Cred, entry::EntryKey, webauthn::Passkeys};
|
||||
use crate::cred::{Caller, Cred};
|
||||
use std::{net::SocketAddr, path::Path, sync::Arc};
|
||||
|
||||
#[derive(Clone, Debug, clap::Args)]
|
||||
@@ -22,71 +22,58 @@ pub struct Listener {
|
||||
}
|
||||
impl Listener {
|
||||
pub fn start(cred: Arc<Cred>, host_dir: &Path, options: Options) -> anyhow::Result<Self> {
|
||||
let api = Arc::new(Api::open(
|
||||
cred,
|
||||
host_dir,
|
||||
&options.api_rp_id,
|
||||
&options.api_origin,
|
||||
)?);
|
||||
Self::start_with_api(api, options)
|
||||
}
|
||||
pub fn start_with_api(api: Arc<Api>, options: Options) -> anyhow::Result<Self> {
|
||||
let server = Arc::new(
|
||||
tiny_http::Server::http(options.api_listen)
|
||||
.map_err(|_| anyhow::anyhow!("host API address unavailable"))?,
|
||||
);
|
||||
let serving = server.clone();
|
||||
let host_dir = host_dir.to_owned();
|
||||
let (ready, started) = std::sync::mpsc::sync_channel(1);
|
||||
let thread = std::thread::spawn(move || {
|
||||
let setup = || -> anyhow::Result<_> {
|
||||
Ok((
|
||||
rusqlite::Connection::open(host_dir.join("cred.sqlite"))?,
|
||||
EntryKey::open(&host_dir.join("entry.key"))?,
|
||||
))
|
||||
};
|
||||
let serve = || -> anyhow::Result<()> {
|
||||
let (db, entry) = setup()?;
|
||||
let passkeys = Passkeys::new(&db, &options.api_rp_id, &options.api_origin)?;
|
||||
let api = Api::new(cred, passkeys, entry);
|
||||
let _ = ready.send(true);
|
||||
for mut request in serving.incoming_requests() {
|
||||
let response = (|| -> anyhow::Result<_> {
|
||||
let mut body = Vec::new();
|
||||
request.as_reader().read_to_end(&mut body)?;
|
||||
let request = http::Request::builder()
|
||||
.method(request.method().as_str())
|
||||
.uri(request.url())
|
||||
.body(bytes::Bytes::from(body))?;
|
||||
Ok(respond(
|
||||
&api,
|
||||
Caller {
|
||||
program: "/host/browser".into(),
|
||||
pid: std::process::id(),
|
||||
conversations: Vec::new(),
|
||||
},
|
||||
request,
|
||||
for mut request in serving.incoming_requests() {
|
||||
let response = (|| -> anyhow::Result<_> {
|
||||
let mut body = Vec::new();
|
||||
request.as_reader().read_to_end(&mut body)?;
|
||||
let request = http::Request::builder()
|
||||
.method(request.method().as_str())
|
||||
.uri(request.url())
|
||||
.body(bytes::Bytes::from(body))?;
|
||||
Ok(respond(
|
||||
&api,
|
||||
Caller {
|
||||
program: "/host/browser".into(),
|
||||
pid: std::process::id(),
|
||||
conversations: Vec::new(),
|
||||
},
|
||||
request,
|
||||
))
|
||||
})();
|
||||
let response = response.unwrap_or_else(|_| {
|
||||
http::Response::builder()
|
||||
.status(400)
|
||||
.header("content-type", "application/json")
|
||||
.body(bytes::Bytes::from_static(
|
||||
b"{\"error\":\"invalid_request\",\"message\":\"invalid_request\"}",
|
||||
))
|
||||
})();
|
||||
let response = response.unwrap_or_else(|_| {
|
||||
http::Response::builder()
|
||||
.status(400)
|
||||
.header("content-type", "application/json")
|
||||
.body(bytes::Bytes::from_static(
|
||||
b"{\"error\":\"invalid_request\",\"message\":\"invalid_request\"}",
|
||||
))
|
||||
.expect("static response")
|
||||
});
|
||||
let content_type = response.headers()["content-type"].as_bytes();
|
||||
let reply = tiny_http::Response::from_data(response.body().to_vec())
|
||||
.with_status_code(response.status().as_u16())
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes(b"Content-Type", content_type)
|
||||
.expect("static header"),
|
||||
);
|
||||
let _ = request.respond(reply);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if serve().is_err() {
|
||||
let _ = ready.send(false);
|
||||
.expect("static response")
|
||||
});
|
||||
let content_type = response.headers()["content-type"].as_bytes();
|
||||
let reply = tiny_http::Response::from_data(response.body().to_vec())
|
||||
.with_status_code(response.status().as_u16())
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes(b"Content-Type", content_type)
|
||||
.expect("static header"),
|
||||
);
|
||||
let _ = request.respond(reply);
|
||||
}
|
||||
});
|
||||
if !started.recv().unwrap_or(false) {
|
||||
let _ = thread.join();
|
||||
anyhow::bail!("host API initialization failed");
|
||||
}
|
||||
Ok(Self {
|
||||
server,
|
||||
thread: Some(thread),
|
||||
@@ -105,7 +92,7 @@ impl Drop for Listener {
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve the console lifecycle while sharing Cred with the host page listener.
|
||||
/// Preserve the console lifecycle with one API for guests and host pages.
|
||||
pub async fn run_with_userland(
|
||||
path: &Path,
|
||||
userland: Option<&Path>,
|
||||
@@ -113,12 +100,26 @@ pub async fn run_with_userland(
|
||||
) -> anyhow::Result<()> {
|
||||
let world = crate::world::World::open_with_userland(path, userland)?;
|
||||
let cred = Arc::new(Cred::open(&world.host_dir)?);
|
||||
let _listener = Listener::start(cred.clone(), &world.host_dir, options)?;
|
||||
let runtime = crate::process::Runtime::new(crate::world::World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
})?;
|
||||
let api = Arc::new(Api::open(
|
||||
cred.clone(),
|
||||
&world.host_dir,
|
||||
&options.api_rp_id,
|
||||
&options.api_origin,
|
||||
)?);
|
||||
let runtime = crate::process::Runtime::new_with_hooks(
|
||||
crate::world::World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
},
|
||||
cred.clone(),
|
||||
crate::process::default_script(),
|
||||
Some(api.clone()),
|
||||
)?;
|
||||
let listener = Listener::start_with_api(api, options)?;
|
||||
runtime
|
||||
.network_policy()
|
||||
.deny_host_port(listener.address().port());
|
||||
let services = crate::process::supervise(runtime.clone());
|
||||
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
@@ -146,5 +147,6 @@ pub async fn run_with_userland(
|
||||
anyhow::anyhow!(text)
|
||||
});
|
||||
services.shutdown().await;
|
||||
drop(listener);
|
||||
result
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ use crate::cred::{self, Caller, script::Script, types::FieldType};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub(super) fn handle(ctx: &Api<'_>, caller: &Caller, action: &str, body: &Value) -> Result<Value> {
|
||||
pub(super) fn handle(ctx: &Api, caller: &Caller, action: &str, body: &Value) -> Result<Value> {
|
||||
let cred = &ctx.cred;
|
||||
let host = string(body, "host")?;
|
||||
let mut items = cred.item_list()?.into_iter().filter(|item| {
|
||||
|
||||
@@ -155,17 +155,18 @@ struct Pending<T> {
|
||||
deadline: Instant,
|
||||
value: T,
|
||||
}
|
||||
pub struct Passkeys<'a> {
|
||||
db: &'a Connection,
|
||||
/// Own the connection for a shared API, or borrow it for a local caller.
|
||||
pub struct Passkeys<D: std::borrow::Borrow<Connection>> {
|
||||
db: D,
|
||||
webauthn: Webauthn,
|
||||
registrations: HashMap<String, Pending<(String, bool, PasskeyRegistration)>>,
|
||||
assertions: HashMap<String, Pending<(Binding, PasskeyAuthentication)>>,
|
||||
}
|
||||
impl<'a> Passkeys<'a> {
|
||||
pub fn new(db: &'a Connection, rp_id: &str, origin: &str) -> Result<Self> {
|
||||
impl<D: std::borrow::Borrow<Connection>> Passkeys<D> {
|
||||
pub fn new(db: D, rp_id: &str, origin: &str) -> Result<Self> {
|
||||
let origin = Url::parse(origin)?;
|
||||
let webauthn = WebauthnBuilder::new(rp_id, &origin)?.build()?;
|
||||
db.execute_batch("CREATE TABLE IF NOT EXISTS passkey_credentials(id TEXT PRIMARY KEY,label TEXT NOT NULL,credential TEXT NOT NULL)")?;
|
||||
db.borrow().execute_batch("CREATE TABLE IF NOT EXISTS passkey_credentials(id TEXT PRIMARY KEY,label TEXT NOT NULL,credential TEXT NOT NULL)")?;
|
||||
Ok(Self {
|
||||
db,
|
||||
webauthn,
|
||||
@@ -176,6 +177,7 @@ impl<'a> Passkeys<'a> {
|
||||
/// Database errors leave the manager locked.
|
||||
pub fn is_unlocked(&self) -> bool {
|
||||
self.db
|
||||
.borrow()
|
||||
.query_row(
|
||||
"SELECT NOT EXISTS(SELECT 1 FROM passkey_credentials)",
|
||||
[],
|
||||
@@ -187,6 +189,7 @@ impl<'a> Passkeys<'a> {
|
||||
pub fn list(&self) -> Result<Vec<serde_json::Value>> {
|
||||
let mut statement = self
|
||||
.db
|
||||
.borrow()
|
||||
.prepare("SELECT id,label FROM passkey_credentials ORDER BY rowid")?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
@@ -201,6 +204,7 @@ impl<'a> Passkeys<'a> {
|
||||
fn credentials(&self) -> Result<Vec<Passkey>> {
|
||||
let mut q = self
|
||||
.db
|
||||
.borrow()
|
||||
.prepare("SELECT credential FROM passkey_credentials")?;
|
||||
q.query_map([], |r| r.get::<_, String>(0))?
|
||||
.map(|r| Ok(serde_json::from_str(&r?)?))
|
||||
@@ -261,7 +265,7 @@ impl<'a> Passkeys<'a> {
|
||||
let credential = self
|
||||
.webauthn
|
||||
.finish_passkey_registration(&response, state)?;
|
||||
self.db.execute(
|
||||
self.db.borrow().execute(
|
||||
"INSERT INTO passkey_credentials(id,label,credential) VALUES(?1,?2,?3)",
|
||||
params![
|
||||
serde_json::to_string(credential.cred_id())?,
|
||||
@@ -313,7 +317,7 @@ impl<'a> Passkeys<'a> {
|
||||
bail!("passkey counter did not increase");
|
||||
}
|
||||
credential.update_credential(&result);
|
||||
self.db.execute(
|
||||
self.db.borrow().execute(
|
||||
"UPDATE passkey_credentials SET credential=?1 WHERE id=?2",
|
||||
params![
|
||||
serde_json::to_string(credential)?,
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
use wasmtime_wasi_http::{Error, RequestOptions};
|
||||
use wasmtime_wasi_http::{WasiBody, WasiHttpHooks};
|
||||
type Io = Box<dyn Future<Output = Result<(), Error>> + Send>;
|
||||
pub type Router = fn(&Caller, Request<Bytes>) -> Response<Bytes>;
|
||||
pub type Router = Arc<crate::api::Api>;
|
||||
pub(super) struct Hooks {
|
||||
pub caller: Caller,
|
||||
pub cred: Arc<Cred>,
|
||||
@@ -14,21 +14,6 @@ pub(super) struct Hooks {
|
||||
pub policy: Arc<super::net::NetPolicy>,
|
||||
pub router: Router,
|
||||
}
|
||||
pub fn whoami(caller: &Caller, request: Request<Bytes>) -> Response<Bytes> {
|
||||
let (status, body) = if request.uri().path() == "/whoami" {
|
||||
(200, serde_json::to_vec(caller).unwrap())
|
||||
} else {
|
||||
(
|
||||
404,
|
||||
serde_json::to_vec(&serde_json::json!({"error":"unknown host API route"})).unwrap(),
|
||||
)
|
||||
};
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "application/json")
|
||||
.body(Bytes::from(body))
|
||||
.unwrap()
|
||||
}
|
||||
fn response(response: Response<Bytes>) -> (Response<WasiBody>, Io) {
|
||||
(
|
||||
response.map(|b| Full::new(b).map_err(|n| match n {}).boxed_unsync()),
|
||||
@@ -51,7 +36,7 @@ impl WasiHttpHooks for Hooks {
|
||||
let cred = self.cred.clone();
|
||||
let script = self.script.clone();
|
||||
let policy = self.policy.clone();
|
||||
let router = self.router;
|
||||
let router = self.router.clone();
|
||||
Box::new(async move {
|
||||
let (parts, mut body) = request.into_parts();
|
||||
if parts
|
||||
@@ -60,7 +45,9 @@ impl WasiHttpHooks for Hooks {
|
||||
.is_some_and(|h| h.eq_ignore_ascii_case("host.sausage"))
|
||||
{
|
||||
let bytes = body.collect().await?.to_bytes();
|
||||
return Ok(response(router(&caller, Request::from_parts(parts, bytes))));
|
||||
return Ok(response(
|
||||
crate::api::handle(&router, caller, Request::from_parts(parts, bytes)).await,
|
||||
));
|
||||
}
|
||||
if !matches!(parts.uri.scheme_str(), Some("http" | "https")) {
|
||||
while let Some(frame) = body.frame().await {
|
||||
|
||||
+17
-10
@@ -164,7 +164,7 @@ impl Runtime {
|
||||
}
|
||||
fn with_compiler(world: World, compiler: Arc<cache::Compiler>) -> anyhow::Result<Arc<Self>> {
|
||||
let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?);
|
||||
Self::with_hooks(world, compiler, cred, default_script(), http::whoami)
|
||||
Self::with_hooks(world, compiler, cred, default_script(), None)
|
||||
}
|
||||
/// Share credentials and the host API router with the host's other subsystems.
|
||||
pub fn new_with_hooks(
|
||||
@@ -173,21 +173,24 @@ impl Runtime {
|
||||
script: Arc<dyn crate::cred::script::Script>,
|
||||
router: Option<Router>,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
Self::with_hooks(
|
||||
world,
|
||||
cache::shared()?,
|
||||
cred,
|
||||
script,
|
||||
router.unwrap_or(http::whoami),
|
||||
)
|
||||
Self::with_hooks(world, cache::shared()?, cred, script, router)
|
||||
}
|
||||
fn with_hooks(
|
||||
world: World,
|
||||
compiler: Arc<cache::Compiler>,
|
||||
cred: Arc<crate::cred::Cred>,
|
||||
script: Arc<dyn crate::cred::script::Script>,
|
||||
router: http::Router,
|
||||
router: Option<http::Router>,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
let router = match router {
|
||||
Some(api) => api,
|
||||
None => Arc::new(crate::api::Api::open(
|
||||
cred.clone(),
|
||||
&world.host_dir,
|
||||
"localhost",
|
||||
"http://localhost:8081",
|
||||
)?),
|
||||
};
|
||||
let network = Arc::new(net::NetPolicy::open(&world.host_dir)?);
|
||||
let engine = compiler.engine.clone();
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
@@ -215,6 +218,10 @@ impl Runtime {
|
||||
cache::precompile(runtime.compiler.clone(), runtime.world.root.clone());
|
||||
Ok(runtime)
|
||||
}
|
||||
/// Register native listener ports through the shared network policy.
|
||||
pub fn network_policy(&self) -> &net::NetPolicy {
|
||||
&self.network
|
||||
}
|
||||
/// Number of distinct program contents compiled by this runtime.
|
||||
pub fn compiled_programs(&self) -> usize {
|
||||
self.cache.lock().unwrap().len()
|
||||
@@ -428,7 +435,7 @@ impl Runtime {
|
||||
cred: self.cred.clone(),
|
||||
script: self.script.clone(),
|
||||
policy: self.network.clone(),
|
||||
router: self.router,
|
||||
router: self.router.clone(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+4
-4
@@ -15,7 +15,7 @@ fn caller() -> Caller {
|
||||
conversations: vec!["conversation".into()],
|
||||
}
|
||||
}
|
||||
async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
|
||||
async fn send(api: &Api, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
|
||||
let request = http::Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
@@ -31,7 +31,7 @@ async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u
|
||||
serde_json::from_slice(response.body())?,
|
||||
))
|
||||
}
|
||||
async fn ok(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<Value> {
|
||||
async fn ok(api: &Api, method: &str, path: &str, body: Value) -> Result<Value> {
|
||||
let (status, value) = send(api, method, path, body).await?;
|
||||
assert_eq!(status, 200, "unexpected API status");
|
||||
Ok(value)
|
||||
@@ -55,7 +55,7 @@ async fn public_routes_and_ssh() -> Result<()> {
|
||||
)?;
|
||||
let api = Api::new(
|
||||
cred,
|
||||
Passkeys::new(&db, "localhost", "http://localhost:8080")?,
|
||||
Passkeys::new(db, "localhost", "http://localhost:8080")?,
|
||||
EntryKey::open(&dir.path().join("entry.key"))?,
|
||||
);
|
||||
let who = ok(
|
||||
@@ -221,7 +221,7 @@ async fn ssh_rejects_ambiguous_items() -> Result<()> {
|
||||
item(&cred, "two")?;
|
||||
let api = Api::new(
|
||||
cred,
|
||||
Passkeys::new(&db, "localhost", "http://localhost:8080")?,
|
||||
Passkeys::new(db, "localhost", "http://localhost:8080")?,
|
||||
EntryKey::open(&dir.path().join("entry.key"))?,
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
+10
-9
@@ -21,7 +21,7 @@ fn caller() -> Caller {
|
||||
conversations: vec!["conversation".into()],
|
||||
}
|
||||
}
|
||||
async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
|
||||
async fn send(api: &Api, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
|
||||
let response = handle(
|
||||
api,
|
||||
caller(),
|
||||
@@ -37,12 +37,12 @@ async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u
|
||||
serde_json::from_slice(response.body())?,
|
||||
))
|
||||
}
|
||||
async fn ok(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<Value> {
|
||||
async fn ok(api: &Api, method: &str, path: &str, body: Value) -> Result<Value> {
|
||||
let (status, value) = send(api, method, path, body).await?;
|
||||
assert_eq!(status, 200, "unexpected API status");
|
||||
Ok(value)
|
||||
}
|
||||
async fn page(api: &Api<'_>, path: &str) -> Result<String> {
|
||||
async fn page(api: &Api, path: &str) -> Result<String> {
|
||||
let response = handle(
|
||||
api,
|
||||
caller(),
|
||||
@@ -62,7 +62,7 @@ async fn page(api: &Api<'_>, path: &str) -> Result<String> {
|
||||
fn item(cred: &Cred, name: &str) -> Result<cred::items::Item> {
|
||||
Ok(cred.create_item(serde_json::from_value(json!({"name":name,"rules":[],"transform_js":"","fields":[{"name":"first","field_type":"text"},{"name":"second","field_type":"text"}]}))?,&caller(),cred.unlocked()?)?)
|
||||
}
|
||||
async fn file(api: &Api<'_>, item: &str, key: &str) -> Result<String> {
|
||||
async fn file(api: &Api, item: &str, key: &str) -> Result<String> {
|
||||
let result = ok(
|
||||
api,
|
||||
"POST",
|
||||
@@ -72,7 +72,7 @@ async fn file(api: &Api<'_>, item: &str, key: &str) -> Result<String> {
|
||||
.await?;
|
||||
Ok(result["request"].as_str().unwrap().into())
|
||||
}
|
||||
async fn register(api: &Api<'_>) -> Result<WebauthnAuthenticator<SoftPasskey>> {
|
||||
async fn register(api: &Api) -> Result<WebauthnAuthenticator<SoftPasskey>> {
|
||||
let start: Challenge<CreationChallengeResponse> = serde_json::from_value(
|
||||
ok(
|
||||
api,
|
||||
@@ -94,7 +94,7 @@ async fn register(api: &Api<'_>) -> Result<WebauthnAuthenticator<SoftPasskey>> {
|
||||
Ok(auth)
|
||||
}
|
||||
async fn assertion(
|
||||
api: &Api<'_>,
|
||||
api: &Api,
|
||||
auth: &mut WebauthnAuthenticator<SoftPasskey>,
|
||||
path: &str,
|
||||
body: Value,
|
||||
@@ -114,7 +114,7 @@ async fn approvals_registration_and_revocation() -> Result<()> {
|
||||
let second = item(&cred, "review <script>not markup</script>")?;
|
||||
let api = Api::new(
|
||||
cred,
|
||||
Passkeys::new(&db, "localhost", ORIGIN)?,
|
||||
Passkeys::new(db, "localhost", ORIGIN)?,
|
||||
EntryKey::open(&dir.path().join("entry.key"))?,
|
||||
);
|
||||
page(&api, "/register").await?;
|
||||
@@ -314,7 +314,7 @@ async fn approvals_registration_and_revocation() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn encrypt(api: &Api<'_>, public_key: Value) -> Result<Envelope> {
|
||||
async fn encrypt(api: &Api, public_key: Value) -> Result<Envelope> {
|
||||
use deno_core::{JsRuntime, RuntimeOptions};
|
||||
let script = handle(
|
||||
api,
|
||||
@@ -358,7 +358,7 @@ async fn encrypted_entry_uses_the_host_page_code() -> Result<()> {
|
||||
let item = item(&cred, "secret")?;
|
||||
let api = Api::new(
|
||||
cred,
|
||||
Passkeys::new(&db, "localhost", ORIGIN)?,
|
||||
Passkeys::new(db, "localhost", ORIGIN)?,
|
||||
EntryKey::open(&dir.path().join("entry.key"))?,
|
||||
);
|
||||
let mut auth = register(&api).await?;
|
||||
@@ -408,6 +408,7 @@ async fn encrypted_entry_uses_the_host_page_code() -> Result<()> {
|
||||
assert!(!api.cred.item_get(&item.id)?.fields[0].needs_value);
|
||||
ok(&api, "GET", &format!("/cred/items/{}", item.id), json!({})).await?;
|
||||
let master = std::fs::read(dir.path().join("cred.key"))?;
|
||||
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite"))?;
|
||||
let cipher: Vec<u8> = db.query_row(
|
||||
"SELECT ciphertext FROM fields WHERE item=?",
|
||||
[&item.id],
|
||||
|
||||
@@ -42,7 +42,10 @@ fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) {
|
||||
.unwrap();
|
||||
(dir, c, caller, item)
|
||||
}
|
||||
fn register(host: &mut Passkeys<'_>, auth: &mut WebauthnAuthenticator<SoftPasskey>) {
|
||||
fn register(
|
||||
host: &mut Passkeys<&rusqlite::Connection>,
|
||||
auth: &mut WebauthnAuthenticator<SoftPasskey>,
|
||||
) {
|
||||
let start = host.start_registration("test".into(), None).unwrap();
|
||||
let response = auth
|
||||
.do_registration(Url::parse("http://localhost:8000").unwrap(), start.options)
|
||||
@@ -50,7 +53,7 @@ fn register(host: &mut Passkeys<'_>, auth: &mut WebauthnAuthenticator<SoftPasske
|
||||
host.finish_registration(&start.id, response).unwrap();
|
||||
}
|
||||
fn proof(
|
||||
host: &mut Passkeys<'_>,
|
||||
host: &mut Passkeys<&rusqlite::Connection>,
|
||||
auth: &mut WebauthnAuthenticator<SoftPasskey>,
|
||||
binding: Binding,
|
||||
) -> webauthn::Verified {
|
||||
|
||||
@@ -126,6 +126,8 @@ async fn identity_injection_denial_and_ambiguity() {
|
||||
assert_eq!(code, 0);
|
||||
let alias_out = String::from_utf8(alias_out).unwrap();
|
||||
assert!(alias_out.contains("/bin/guest.wasm") && !alias_out.contains("/bin/alias"));
|
||||
let items = run(&runtime, "http://host.sausage/cred/items".into(), vec![]).await;
|
||||
assert!(items.contains("test item") && !items.contains(&token));
|
||||
let server_token = token.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! The hook and native listener share ceremony state without exposing the native listener to guests.
|
||||
use sausage::{
|
||||
api::{Api, Listener, Options as ApiOptions},
|
||||
cred::{Caller, Cred},
|
||||
exec::{Command, Exec, Options},
|
||||
process::Runtime,
|
||||
world::World,
|
||||
};
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
sync::Arc,
|
||||
};
|
||||
#[path = "hook_guest.rs"]
|
||||
mod hook_guest;
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_api_for_pages_and_guest_requests() -> anyhow::Result<()> {
|
||||
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
|
||||
let dir = tempfile::tempdir()?;
|
||||
let world = World::open(dir.path())?;
|
||||
std::fs::create_dir_all(dir.path().join("world/bin"))?;
|
||||
std::fs::copy(
|
||||
hook_guest::component(),
|
||||
dir.path().join("world/bin/guest.wasm"),
|
||||
)?;
|
||||
let cred = Arc::new(Cred::open(&world.host_dir)?);
|
||||
let origin = "http://localhost:8081";
|
||||
let api = Arc::new(Api::open(
|
||||
cred.clone(),
|
||||
&world.host_dir,
|
||||
"localhost",
|
||||
origin,
|
||||
)?);
|
||||
let runtime = Runtime::new_with_hooks(
|
||||
world,
|
||||
cred,
|
||||
sausage::process::default_script(),
|
||||
Some(api.clone()),
|
||||
)?;
|
||||
let listener = Listener::start_with_api(
|
||||
api.clone(),
|
||||
ApiOptions {
|
||||
api_listen: "127.0.0.1:0".parse()?,
|
||||
api_rp_id: "localhost".into(),
|
||||
api_origin: origin.into(),
|
||||
},
|
||||
)?;
|
||||
runtime
|
||||
.network_policy()
|
||||
.deny_host_port(listener.address().port());
|
||||
let response = sausage::api::handle(
|
||||
&api,
|
||||
Caller {
|
||||
program: "/bin/guest.wasm".into(),
|
||||
pid: 1,
|
||||
conversations: vec![],
|
||||
},
|
||||
http::Request::post("http://host.sausage/cred/passkeys/register/start")
|
||||
.body(bytes::Bytes::from_static(b"{\"label\":\"shared\"}"))?,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 200);
|
||||
let challenge: serde_json::Value = serde_json::from_slice(response.body())?;
|
||||
let mut authenticator = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
||||
let registration = authenticator.do_registration(
|
||||
url::Url::parse(origin)?,
|
||||
serde_json::from_value(challenge["options"].clone())?,
|
||||
)?;
|
||||
let body = serde_json::to_vec(
|
||||
&serde_json::json!({"challenge_id":challenge["id"], "response": registration}),
|
||||
)?;
|
||||
let mut connection = std::net::TcpStream::connect(listener.address())?;
|
||||
write!(
|
||||
connection,
|
||||
"POST /cred/passkeys/register/finish HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: {}\r\n\r\n",
|
||||
body.len()
|
||||
)?;
|
||||
connection.write_all(&body)?;
|
||||
let mut reply = String::new();
|
||||
connection.read_to_string(&mut reply)?;
|
||||
assert!(reply.starts_with("HTTP/1.1 200"));
|
||||
for (uri, expected) in [
|
||||
("http://host.sausage/cred/passkeys".to_owned(), "shared"),
|
||||
(format!("http://{}/whoami", listener.address()), "error"),
|
||||
] {
|
||||
let mut out = Vec::new();
|
||||
let code = runtime
|
||||
.exec(
|
||||
Command::Argv(vec!["/bin/guest.wasm".into(), uri, "GET".into()]),
|
||||
Options {
|
||||
cwd: "/".into(),
|
||||
timeout: Some(std::time::Duration::from_secs(30)),
|
||||
..Default::default()
|
||||
},
|
||||
&mut |_, b| out.extend_from_slice(b),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(code, 0);
|
||||
assert!(String::from_utf8(out)?.contains(expected));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user