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
+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(())
}