//! 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 { 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 { let response = handle( api, caller(), http::Request::builder().uri(path).body(Bytes::new())?, ) .await; assert_eq!(response.status(), 404); assert_eq!(response.headers()["content-type"], "application/json"); let text = std::str::from_utf8(response.body())?; assert!(!text.contains(SECRET)); assert!(text.contains("route_missing")); Ok(text.into()) } fn item(cred: &Cred, name: &str) -> Result { 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 { 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> { let start: Challenge = 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, path: &str, body: Value, ) -> Result { let start: Challenge = 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 ")?; 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}"); page(&api, &format!("/approve/{id}")).await?; let review = ok(&api, "GET", &path, json!({})).await?; assert_eq!(review["payload"]["duration_secs"], 300); 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(); page(&api, &format!("/approve/{edit}")).await?; 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 = 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 { use deno_core::{JsRuntime, RuntimeOptions}; let script = include_str!("../../../userland/web/credentials.js"); 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", script, )?; 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 = 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(()) }