Share owned host API state between HTTP hooks and native pages

This commit is contained in:
2026-11-07 00:23:57 +00:00
parent 45d099c3e4
commit c91ecbf7a4
14 changed files with 261 additions and 133 deletions
+7 -5
View File
@@ -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`:
+3 -3
View File
@@ -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
View File
@@ -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();
+1 -1
View File
@@ -10,7 +10,7 @@ fn escape(text: &str) -> String {
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
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
View File
@@ -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
View File
@@ -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| {
+11 -7
View File
@@ -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)?,
+5 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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],
+5 -2
View File
@@ -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 {
+2
View File
@@ -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();
+102
View File
@@ -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(())
}