439 lines
14 KiB
Rust
439 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:8090";
|
|
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("<script>not markup</script>"));
|
|
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())?
|
|
.replace("import.meta.url", "'http://localhost:8080/api/app.js'"),
|
|
)?;
|
|
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 db = rusqlite::Connection::open(dir.path().join("cred.sqlite"))?;
|
|
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(())
|
|
}
|