//! P2 exit rehearsal: native host pages, software passkey, durable bash tools, and real SSH Git push. use anyhow::{Result, ensure}; use base64::{Engine, engine::general_purpose::STANDARD}; use sausage::{ api::{Api, Listener, Options}, cred::{Cred, entry::Envelope, webauthn::Challenge}, world::World, }; use serde_json::{Value, json}; use std::{ io::{Read, Write}, net::{SocketAddr, TcpListener, TcpStream}, path::{Path, PathBuf}, process::{Child, Command, Stdio}, sync::Arc, time::{Duration, Instant}, }; use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse}; fn command(cwd: &Path, args: &[&str]) -> Result<()> { ensure!( Command::new(args[0]) .args(&args[1..]) .current_dir(cwd) .stdout(Stdio::null()) .status()? .success(), "fixture command failed" ); Ok(()) } struct Sshd(Child); impl Drop for Sshd { fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } } struct Browser { address: SocketAddr, origin: String, auth: WebauthnAuthenticator, } impl Browser { fn raw(&self, method: &str, path: &str, body: Value) -> Result { let bytes = serde_json::to_vec(&body)?; let mut connection = TcpStream::connect(self.address)?; connection.set_read_timeout(Some(Duration::from_secs(10)))?; write!( connection, "{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", bytes.len() )?; connection.write_all(&bytes)?; let mut response = String::new(); connection.read_to_string(&mut response)?; let error = serde_json::from_str::( response .split_once("\r\n\r\n") .map(|(_, body)| body) .unwrap_or(""), ) .ok(); ensure!( response.starts_with("HTTP/1.1 200"), "host page request failed: {path}, kind {}", error .as_ref() .and_then(|value| value["error"].as_str()) .unwrap_or("invalid-response") ); Ok(response .split_once("\r\n\r\n") .ok_or_else(|| anyhow::anyhow!("invalid HTTP response"))? .1 .to_owned()) } fn json(&self, method: &str, path: &str, body: Value) -> Result { Ok(serde_json::from_str(&self.raw(method, path, body)?)?) } fn assertion(&mut self, path: &str, body: Value) -> Result { let start: Challenge = serde_json::from_value(self.json("POST", path, body)?)?; let assertion = self .auth .do_authentication(url::Url::parse(&self.origin)?, start.options)?; Ok(json!({"challenge_id":start.id,"assertion":assertion})) } fn approve(&mut self, id: &str) -> Result<()> { ensure!( self.raw("GET", &format!("/approve/{id}"), Value::Null)? .contains("/app.js"), "approval page missing" ); let path = format!("/cred/requests/{id}"); let gate = self.assertion(&format!("{path}/challenge"), json!({"decision":"approve"}))?; self.json("POST", &format!("{path}/decide"), gate)?; Ok(()) } fn create(&mut self, config: Value) -> Result { let name = config["name"].as_str().unwrap().to_owned(); let request = self.json("POST", "/cred/requests", json!({"kind":"edit","key":format!("setup-{name}"),"request":{"item":null,"base_version":null,"change":[{"op":"create_item","config":config}]}}))?; self.approve(request["request"].as_str().unwrap())?; let items = self.json("GET", "/cred/items", Value::Null)?; Ok(items .as_array() .unwrap() .iter() .find(|item| item["name"] == name) .unwrap() .clone()) } async fn enter(&mut self, item: &Value, secret: &str) -> Result<()> { let path = format!( "/cred/items/{}/fields/{}", item["id"].as_str().unwrap(), item["fields"][0]["field_id"].as_str().unwrap() ); ensure!( self.raw( "GET", &format!( "/entry/{}/{}", item["id"].as_str().unwrap(), item["fields"][0]["field_id"].as_str().unwrap() ), Value::Null )? .contains("/app.js"), "secret-entry page missing" ); let public = self.json("GET", "/cred/entry/key", Value::Null)?; let script = self.raw("GET", "/app.js", Value::Null)?; let mut runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions { extensions: vec![ deno_webidl::deno_webidl::init(), deno_web::deno_web::init( 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("entry.js", format!("encrypt({},{}).then(value => globalThis.envelope = JSON.stringify(value.envelope))", public["public_key"], serde_json::to_string(secret)?))?; runtime.run_event_loop(Default::default()).await?; let result = runtime.execute_script("result.js", "envelope")?; let text = { deno_core::scope!(scope, &mut runtime); deno_core::v8::Local::new(scope, result).to_rust_string_lossy(scope) }; let envelope: Envelope = serde_json::from_str(&text)?; let mut gate = self.assertion( &format!("{path}/challenge"), json!({"ciphertext_sha256":envelope.sha256()}), )?; gate["envelope"] = serde_json::to_value(envelope)?; self.json("POST", &path, gate)?; Ok(()) } } fn components() -> Result { let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("p2-userland"); if !userland.join("cwd-init/init.o").is_file() { command(&userland, &["nu", "--no-config-file", "cwd-init/build.nu"])?; } let status = Command::new("cargo") .current_dir(&userland) .args([ "build", "--release", "--locked", "--target", "wasm32-wasip2", "-j", "2", "-p", "git", "-p", "curl", ]) .arg("--target-dir") .arg(&target) .status()?; ensure!(status.success(), "guest build failed"); let release = target.join("wasm32-wasip2/release"); if !release.join("sh.wasm").is_file() { ensure!( Command::new("nu") .arg("brush/build.nu") .env("CARGO_TARGET_DIR", &target) .current_dir(&userland) .status()? .success(), "shell build failed" ); std::fs::copy(release.join("brush.wasm"), release.join("sh.wasm"))?; } Ok(release) } fn no_secrets(path: &Path, secrets: &[&[u8]]) -> Result<()> { for entry in std::fs::read_dir(path)? { let entry = entry?; if entry.file_type()?.is_dir() { no_secrets(&entry.path(), secrets)?; } else { let bytes = std::fs::read(entry.path())?; for secret in secrets { ensure!( !bytes.windows(secret.len()).any(|value| value == *secret), "secret escaped into a world file" ); } } } Ok(()) } #[tokio::test(flavor = "current_thread")] async fn request_approve_fetch_and_ssh_push() -> Result<()> { let components = components()?; let dir = tempfile::tempdir()?; let world = World::open(dir.path())?; let root = dir.path().join("world"); std::fs::create_dir_all(root.join("bin"))?; for (source, destination) in [ ("git.wasm", "git.wasm"), ("curl.wasm", "curl.wasm"), ("sh.wasm", "sh.wasm"), ] { std::fs::copy(components.join(source), root.join("bin").join(destination))?; } let token = format!("p2-private-{}", uuid::Uuid::new_v4()); let mut seed = [0; 32]; openssl::rand::rand_bytes(&mut seed)?; let key = STANDARD.encode(seed); let public = ed25519_dalek::SigningKey::from_bytes(&seed) .verifying_key() .to_bytes(); let mut blob = Vec::from(11u32.to_be_bytes()); blob.extend_from_slice(b"ssh-ed25519"); blob.extend_from_slice(&32u32.to_be_bytes()); blob.extend_from_slice(&public); let fixture = dir.path().join("sshd"); std::fs::create_dir_all(&fixture)?; std::fs::write( fixture.join("authorized_keys"), format!("ssh-ed25519 {}\n", STANDARD.encode(blob)), )?; command( &fixture, &[ "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", "host_key", ], )?; let reserve = TcpListener::bind("127.0.0.1:0")?; let ssh_port = reserve.local_addr()?.port(); drop(reserve); let user = std::env::var("USER")?; std::fs::write( fixture.join("config"), format!( "Port {ssh_port}\nListenAddress 127.0.0.1\nHostKey {}\nPidFile {}\nAuthorizedKeysFile {}\nStrictModes no\nUsePAM no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin no\nAllowUsers {user}\n", fixture.join("host_key").display(), fixture.join("pid").display(), fixture.join("authorized_keys").display() ), )?; let ssh_binary = std::env::split_paths(&std::env::var_os("PATH").unwrap()) .map(|path| path.join("sshd")) .find(|path| path.is_file()) .unwrap() .canonicalize()?; let _sshd = Sshd( Command::new(ssh_binary) .args(["-D", "-e", "-f"]) .arg(fixture.join("config")) .stdout(Stdio::null()) .stderr(std::fs::File::create(fixture.join("log"))?) .spawn()?, ); let deadline = Instant::now() + Duration::from_secs(10); while TcpStream::connect(("127.0.0.1", ssh_port)).is_err() { ensure!(Instant::now() < deadline, "sshd did not start"); tokio::task::yield_now().await; } std::fs::create_dir_all(root.join(".ssh"))?; let host_public = std::fs::read_to_string(fixture.join("host_key.pub"))?; std::fs::write( root.join(".ssh/known_hosts"), format!("[127.0.0.1]:{ssh_port} {host_public}"), )?; let repo = root.join("repo"); std::fs::create_dir_all(&repo)?; command(&repo, &["git", "init", "-b", "main"])?; std::fs::write(repo.join("demo"), "P2 push\n")?; command(&repo, &["git", "add", "demo"])?; command( &repo, &[ "git", "-c", "user.name=P2", "-c", "user.email=p2@example.com", "commit", "-m", "rehearsal", ], )?; command(&fixture, &["git", "init", "--bare", "destination.git"])?; let url = format!( "ssh://{user}@127.0.0.1:{ssh_port}{}/destination.git", fixture.display() ); command(&repo, &["git", "remote", "add", "origin", &url])?; let server = Arc::new( tiny_http::Server::http("127.0.0.1:0") .map_err(|_| anyhow::anyhow!("HTTP fixture bind failed"))?, ); let http_port = server.server_addr().to_ip().unwrap().port(); let serving = server.clone(); let expected = format!("Bearer {token}"); let thread = std::thread::spawn(move || { if let Ok(request) = serving.recv() { let valid = request.headers().iter().any(|header| { header.field.equiv("Authorization") && header.value.as_str() == expected }); request .respond(tiny_http::Response::from_string(if valid { "Bearer accepted" } else { "missing bearer" })) .unwrap(); valid } else { false } }); let reserve = TcpListener::bind("127.0.0.1:0")?; let address = reserve.local_addr()?; drop(reserve); let origin = format!("http://localhost:{}", address.port()); let cred = Arc::new(Cred::open(&world.host_dir)?); let api = Arc::new(Api::open( cred.clone(), &world.host_dir, "localhost", &origin, )?); let runtime = sausage::process::Runtime::new_with_hooks( World { root: world.root.clone(), host_dir: world.host_dir.clone(), processes: world.processes.clone(), }, cred.clone(), sausage::process::default_script(), Some(api.clone()), )?; let listener = Listener::start_with_api( api, Options { api_listen: address, api_rp_id: "localhost".into(), api_origin: origin.clone(), }, )?; runtime .network_policy() .deny_host_port(listener.address().port()); let mut browser = Browser { address: listener.address(), origin, auth: WebauthnAuthenticator::new(SoftPasskey::new(true)), }; ensure!( browser .raw("GET", "/register", Value::Null)? .contains("/app.js"), "registration page missing" ); let start: Challenge = serde_json::from_value(browser.json( "POST", "/cred/passkeys/register/start", json!({"label":"Surma rehearsal"}), )?)?; let registration = browser .auth .do_registration(url::Url::parse(&browser.origin)?, start.options)?; browser.json( "POST", "/cred/passkeys/register/finish", json!({"challenge_id":start.id,"response":registration}), )?; let http_item = browser.create(json!({"name":"demo-api","rules":[{"scheme":"http","hosts":["127.0.0.1"],"port":http_port,"path_prefixes":["/"]}],"transform_js":sausage::cred::summary::script("bearer")?,"fields":[{"name":"token","field_type":"text","public":false}]}))?; browser.enter(&http_item, &token).await?; let ssh_item = browser.create(json!({"name":"demo-ssh","rules":[{"scheme":"ssh","port":22,"hosts":["127.0.0.1"],"path_prefixes":["/"]}],"transform_js":"","fields":[{"name":"key","field_type":"ssh_private_key","key_type":"ed25519-raw","public":false}]}))?; browser.enter(&ssh_item, &key).await?; let source = format!( "globalThis.p2Http = {http_port};\n{}", include_str!("../js/p2-test-bundle.js") ); let approve = async { for item in [&http_item, &ssh_item] { let deadline = Instant::now() + Duration::from_secs(90); let request = loop { let requests = browser.json("GET", "/cred/requests?state=pending", Value::Null)?; if let Some(request) = requests.as_array().unwrap().iter().find(|request| { request["payload"]["items"] .as_array() .is_some_and(|items| items.contains(&item["id"])) }) { break request["id"].as_str().unwrap().to_owned(); } ensure!( Instant::now() < deadline, "conversation did not file a request" ); tokio::task::yield_now().await; }; let marker = if item["name"] == "demo-api" { "p2-http-idle" } else { "p2-ssh-idle" }; while !root.join(marker).exists() { ensure!( Instant::now() < deadline, "conversation did not finish its turn" ); tokio::task::yield_now().await; } browser.approve(&request)?; } Ok::<_, anyhow::Error>(()) }; let (harness, approvals) = tokio::time::timeout(Duration::from_secs(180), async { tokio::try_join!( sausage::harness::run_module_with_credentials( world, Arc::new(runtime), cred.clone(), &source ), approve ) }) .await??; let _ = (harness, approvals); server.unblock(); ensure!( thread.join().unwrap(), "server did not receive the bearer token" ); let local = Command::new("git") .args(["rev-parse", "main"]) .current_dir(&repo) .output()?; let remote = Command::new("git") .args(["rev-parse", "main"]) .current_dir(fixture.join("destination.git")) .output()?; ensure!( local.status.success() && remote.status.success() && local.stdout == remote.stdout, "SSH push did not update remote main" ); let requests = browser.json("GET", "/cred/requests", Value::Null)?; for item in [&http_item, &ssh_item] { ensure!( requests .as_array() .unwrap() .iter() .filter(|request| request["payload"]["items"] .as_array() .is_some_and(|items| items.contains(&item["id"]))) .count() == 1, "duplicate automatic item request" ); } no_secrets(&root, &[token.as_bytes(), key.as_bytes(), &seed])?; for file in ["harness.sqlite", "harness.sqlite-wal"] { if let Ok(bytes) = std::fs::read(dir.path().join("host").join(file)) { for secret in [token.as_bytes(), key.as_bytes(), &seed] { ensure!( !bytes.windows(secret.len()).any(|value| value == secret), "secret escaped into harness storage" ); } } } let audit = cred.audit_list()?; ensure!( audit .iter() .any(|entry| entry.event == "fetch.use" && entry.item.as_deref() == http_item["id"].as_str()), "HTTP use missing from audit" ); ensure!( audit .iter() .any(|entry| entry.event == "sign" && entry.item.as_deref() == ssh_item["id"].as_str()), "SSH use missing from audit" ); Ok(()) }