Files
sausage/host/tests/api_auth.rs
T

434 lines
14 KiB
Rust

//! Host page routes with a software authenticator and the page's WebCrypto implementation.
use anyhow::Result;
use bytes::Bytes;
use sausage::{
api::{Api, handle},
cred::{
self, Caller, Cred,
entry::{EntryKey, Envelope},
webauthn::{Challenge, Passkeys},
},
};
use serde_json::{Value, json};
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse, Url};
const ORIGIN: &str = "http://localhost:8081";
const SECRET: &str = "browser-only-value";
fn caller() -> Caller {
Caller {
pid: 17,
program: "/bin/agent".into(),
conversations: vec!["conversation".into()],
}
}
async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
let response = handle(
api,
caller(),
http::Request::builder()
.method(method)
.uri(path)
.body(Bytes::from(serde_json::to_vec(&body)?))?,
)
.await;
assert!(!std::str::from_utf8(response.body())?.contains(SECRET));
Ok((
response.status().as_u16(),
serde_json::from_slice(response.body())?,
))
}
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> {
let response = handle(
api,
caller(),
http::Request::builder().uri(path).body(Bytes::new())?,
)
.await;
assert_eq!(response.status(), 200);
assert_eq!(
response.headers()["content-type"],
"text/html; charset=utf-8"
);
let text = std::str::from_utf8(response.body())?;
assert!(!text.contains(SECRET));
assert!(text.contains("/app.js"));
Ok(text.into())
}
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> {
let result = ok(
api,
"POST",
"/cred/requests",
json!({"kind":"grant","key":key,"request":{"items":[item],"duration_secs":300}}),
)
.await?;
Ok(result["request"].as_str().unwrap().into())
}
async fn register(api: &Api<'_>) -> Result<WebauthnAuthenticator<SoftPasskey>> {
let start: Challenge<CreationChallengeResponse> = serde_json::from_value(
ok(
api,
"POST",
"/cred/passkeys/register/start",
json!({"label":"browser"}),
)
.await?,
)?;
let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true));
let response = auth.do_registration(Url::parse(ORIGIN)?, start.options)?;
ok(
api,
"POST",
"/cred/passkeys/register/finish",
json!({"challenge_id":start.id,"response":response}),
)
.await?;
Ok(auth)
}
async fn assertion(
api: &Api<'_>,
auth: &mut WebauthnAuthenticator<SoftPasskey>,
path: &str,
body: Value,
) -> Result<Value> {
let start: Challenge<RequestChallengeResponse> =
serde_json::from_value(ok(api, "POST", path, body).await?)?;
let assertion = auth.do_authentication(Url::parse(ORIGIN)?, start.options)?;
Ok(json!({"challenge_id":start.id,"assertion":assertion}))
}
#[tokio::test(flavor = "current_thread")]
async fn approvals_registration_and_revocation() -> Result<()> {
let dir = tempfile::tempdir()?;
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite"))?;
let cred = Cred::open(dir.path())?;
let first = item(&cred, "bootstrap")?;
let second = item(&cred, "review <script>not markup</script>")?;
let api = Api::new(
cred,
Passkeys::new(&db, "localhost", ORIGIN)?,
EntryKey::open(&dir.path().join("entry.key"))?,
);
page(&api, "/register").await?;
let bootstrap = file(&api, &first.id, "bootstrap").await?;
let bootstrap_path = format!("/cred/requests/{bootstrap}");
assert_eq!(
ok(
&api,
"POST",
&format!("{bootstrap_path}/challenge"),
json!({"decision":"approve"})
)
.await?["unlocked"],
true
);
ok(
&api,
"POST",
&format!("{bootstrap_path}/decide"),
json!({"decision":"approve"}),
)
.await?;
assert!(
api.cred
.grant_list()?
.iter()
.any(|g| g.items.contains(&first.id))
);
let mut auth = register(&api).await?;
let list = ok(&api, "GET", "/cred/passkeys", json!({})).await?;
assert_eq!(list[0]["label"], "browser");
assert!(!list[0]["id"].as_str().unwrap().contains('"'));
assert_eq!(
send(
&api,
"POST",
"/cred/passkeys/register/start",
json!({"label":"ungated"})
)
.await?
.0,
403
);
let id = file(&api, &second.id, "approval").await?;
let other = file(&api, &second.id, "other").await?;
let path = format!("/cred/requests/{id}");
let review = page(&api, &format!("/approve/{id}")).await?;
assert!(review.contains("data-grant=\"true\""));
assert!(review.contains("value=\"300\""));
assert!(review.contains("&lt;script&gt;not markup&lt;/script&gt;"));
assert!(!review.contains("<script>not markup</script>"));
assert_ne!(
send(
&api,
"POST",
&format!("{path}/decide"),
json!({"decision":"approve"})
)
.await?
.0,
200
);
assert_eq!(
send(
&api,
"POST",
&format!("{path}/challenge"),
json!({"decision":"approve","overrides":{"max_use":1}})
)
.await?
.0,
400
);
let gate = assertion(
&api,
&mut auth,
&format!("{path}/challenge"),
json!({"decision":"approve"}),
)
.await?;
assert_eq!(
send(
&api,
"POST",
&format!("/cred/requests/{other}/decide"),
gate.clone()
)
.await?
.0,
403
);
assert_eq!(
send(&api, "POST", &format!("{path}/decide"), gate).await?.0,
403
);
let mut gate = assertion(
&api,
&mut auth,
&format!("{path}/challenge"),
json!({"decision":"deny"}),
)
.await?;
gate["decision"] = json!("approve");
assert_eq!(
send(&api, "POST", &format!("{path}/decide"), gate).await?.0,
403
);
let gate = assertion(
&api,
&mut auth,
&format!("{path}/challenge"),
json!({"decision":"approve","overrides":{"max_uses":2}}),
)
.await?;
ok(&api, "POST", &format!("{path}/decide"), gate.clone()).await?;
assert_eq!(
send(&api, "POST", &format!("{path}/decide"), gate).await?.0,
403
);
let grant = api
.cred
.grant_list()?
.into_iter()
.find(|g| g.items.contains(&second.id))
.unwrap();
assert!(grant.subject.covers(&caller()));
assert_eq!(grant.max_uses, Some(2));
let revoke = ok(
&api,
"POST",
&format!("/cred/grants/{}/revoke", grant.id),
json!({"key":"revoke"}),
)
.await?;
assert!(!api.cred.grant_status(&grant.id)?.revoked);
let revoke = revoke["request"].as_str().unwrap();
let gate = assertion(
&api,
&mut auth,
&format!("/cred/requests/{revoke}/challenge"),
json!({"decision":"approve"}),
)
.await?;
ok(
&api,
"POST",
&format!("/cred/requests/{revoke}/decide"),
gate,
)
.await?;
assert!(api.cred.grant_status(&grant.id)?.revoked);
let edit = ok(&api,"POST","/cred/requests",json!({"kind":"edit","key":"rename","request":{"item":second.id,"base_version":api.cred.item_get(&second.id)?.version,"change":[{"op":"set_name","name":"renamed"}]}})).await?;
let edit = edit["request"].as_str().unwrap();
let edit_page = page(&api, &format!("/approve/{edit}")).await?;
assert!(edit_page.contains("data-grant=\"false\""));
assert!(edit_page.contains("<fieldset hidden disabled>"));
let edit_gate = assertion(
&api,
&mut auth,
&format!("/cred/requests/{edit}/challenge"),
json!({"decision":"approve"}),
)
.await?;
ok(
&api,
"POST",
&format!("/cred/requests/{edit}/decide"),
edit_gate,
)
.await?;
assert_eq!(api.cred.item_get(&second.id)?.name, "renamed");
let gate = assertion(
&api,
&mut auth,
"/cred/passkeys/register/challenge",
json!({"label":"second"}),
)
.await?;
let start: Challenge<CreationChallengeResponse> = serde_json::from_value(ok(&api,"POST","/cred/passkeys/register/start",json!({"label":"second","gate":{"id":gate["challenge_id"],"response":gate["assertion"]}})).await?)?;
let mut another = WebauthnAuthenticator::new(SoftPasskey::new(true));
let response = another.do_registration(Url::parse(ORIGIN)?, start.options)?;
ok(
&api,
"POST",
"/cred/passkeys/register/finish",
json!({"challenge_id":start.id,"response":response}),
)
.await?;
assert_eq!(
ok(&api, "GET", "/cred/passkeys", json!({}))
.await?
.as_array()
.unwrap()
.len(),
2
);
Ok(())
}
async fn encrypt(api: &Api<'_>, public_key: Value) -> Result<Envelope> {
use deno_core::{JsRuntime, RuntimeOptions};
let script = handle(
api,
caller(),
http::Request::builder().uri("/app.js").body(Bytes::new())?,
)
.await;
assert_eq!(script.status(), 200);
assert!(!std::str::from_utf8(script.body())?.contains(SECRET));
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
deno_webidl::deno_webidl::init(),
deno_web::deno_web::init(
std::sync::Arc::new(deno_web::BlobStore::default()),
None,
false,
Default::default(),
),
deno_crypto::deno_crypto::init(None),
],
..Default::default()
});
runtime.execute_script("browser.js","globalThis.crypto = Deno.core.loadExtScript('ext:deno_crypto/00_crypto.js').crypto; globalThis.TextEncoder = Deno.core.loadExtScript('ext:deno_web/08_text_encoding.js').TextEncoder; globalThis.document = {querySelector: () => ({addEventListener() {}})};")?;
runtime.execute_script("app.js", std::str::from_utf8(script.body())?.to_string())?;
runtime.execute_script("encrypt.js",format!("globalThis.done = encrypt({public_key},'{SECRET}').then(result => globalThis.envelope = JSON.stringify(result.envelope));"))?;
runtime.run_event_loop(Default::default()).await?;
let value = runtime.execute_script("result.js", "envelope")?;
let text = {
deno_core::scope!(scope, &mut runtime);
let value = deno_core::v8::Local::new(scope, value);
value.to_rust_string_lossy(scope)
};
Ok(serde_json::from_str(&text)?)
}
#[tokio::test(flavor = "current_thread")]
async fn encrypted_entry_uses_the_host_page_code() -> Result<()> {
let dir = tempfile::tempdir()?;
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite"))?;
let cred = Cred::open(dir.path())?;
let item = item(&cred, "secret")?;
let api = Api::new(
cred,
Passkeys::new(&db, "localhost", ORIGIN)?,
EntryKey::open(&dir.path().join("entry.key"))?,
);
let mut auth = register(&api).await?;
let public = ok(&api, "GET", "/cred/entry/key", json!({})).await?;
let entry_page = page(
&api,
&format!("/entry/{}/{}", item.id, item.fields[0].field_id),
)
.await?;
assert!(entry_page.contains(&public["public_key"].to_string()));
let envelope = encrypt(&api, public["public_key"].clone()).await?;
let path = format!("/cred/items/{}/fields/{}", item.id, item.fields[0].field_id);
for target in [
format!("/cred/items/{}/fields/{}", item.id, item.fields[1].field_id),
format!("/cred/items/other/fields/{}", item.fields[0].field_id),
] {
let mut gate = assertion(
&api,
&mut auth,
&format!("{path}/challenge"),
json!({"ciphertext_sha256":envelope.sha256()}),
)
.await?;
gate["envelope"] = serde_json::to_value(&envelope)?;
assert_eq!(send(&api, "POST", &target, gate).await?.0, 403);
}
let mut gate = assertion(
&api,
&mut auth,
&format!("{path}/challenge"),
json!({"ciphertext_sha256":"0".repeat(64)}),
)
.await?;
gate["envelope"] = serde_json::to_value(&envelope)?;
assert_eq!(send(&api, "POST", &path, gate).await?.0, 403);
assert!(api.cred.item_get(&item.id)?.fields[0].needs_value);
let mut gate = assertion(
&api,
&mut auth,
&format!("{path}/challenge"),
json!({"ciphertext_sha256":envelope.sha256()}),
)
.await?;
gate["envelope"] = serde_json::to_value(&envelope)?;
ok(&api, "POST", &path, gate.clone()).await?;
assert_eq!(send(&api, "POST", &path, gate).await?.0, 403);
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 cipher: Vec<u8> = db.query_row(
"SELECT ciphertext FROM fields WHERE item=?",
[&item.id],
|r| r.get(0),
)?;
use chacha20poly1305::{
XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, Payload},
};
let aad = format!("{}|{}", item.id, item.fields[0].field_id);
let plaintext = XChaCha20Poly1305::new_from_slice(&master)
.unwrap()
.decrypt(
XNonce::from_slice(&cipher[..24]),
Payload {
msg: &cipher[24..],
aad: aad.as_bytes(),
},
)
.map_err(|_| anyhow::anyhow!("fixture decrypt failed"))?;
assert!(plaintext == SECRET.as_bytes());
Ok(())
}