//! Direct host API tests for reads, request filing, and the SSH signing oracle. use anyhow::Result; use base64::{Engine, engine::general_purpose::STANDARD}; use bytes::Bytes; use sausage::{ api::{Api, handle}, cred::{self, Caller, Cred, entry::EntryKey, webauthn::Passkeys}, }; use serde_json::{Value, json}; 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 request = http::Request::builder() .method(method) .uri(path) .header("x-sausage-pid", "999") .body(Bytes::from(serde_json::to_vec(&body)?))?; let response = handle(api, caller(), request).await; let text = std::str::from_utf8(response.body())?; assert!(!text.contains("browser-only-value")); assert!(!text.contains(&STANDARD.encode([7u8; 32]))); assert_eq!(response.headers()["content-type"], "application/json"); 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) } fn item(cred: &Cred, name: &str) -> Result { Ok(cred.create_item(serde_json::from_value(json!({"name":name,"rules":[{"scheme":"ssh","hosts":["example.com"],"path_prefixes":["/"]}],"transform_js":"","fields":[{"name":"secret","public":false,"field_type":"ssh_private_key","key_type":"ed25519-raw"}]}))?,&caller(),cred.unlocked()?)?) } #[tokio::test(flavor = "current_thread")] async fn public_routes_and_ssh() -> 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, "ssh")?; cred.set_field( &item.id, &item.fields[0].field_id, &STANDARD.encode([7u8; 32]), &caller(), cred.unlocked()?, )?; let api = Api::new( cred, Passkeys::new(&db, "localhost", "http://localhost:8080")?, EntryKey::open(&dir.path().join("entry.key"))?, ); let who = ok( &api, "GET", "/whoami", json!({"pid":999,"conversations":["forged"]}), ) .await?; assert_eq!(who["pid"], 17); assert_eq!(who["conversations"], json!(["conversation"])); let mut pending = String::new(); for action in ["identity", "sign"] { let (status, denied) = send( &api, "POST", &format!("/ssh/{action}"), json!({"host":"example.com","item":item.id,"payload":"YWJj"}), ) .await?; assert_eq!(status, 403); assert_eq!(denied["item"], item.id); let request = denied["request"].as_str().unwrap(); if pending.is_empty() { pending = request.into(); } else { assert_eq!(pending, request); } } for prefix in ["inject:", "ssh:"] { assert_eq!( send( &api, "POST", "/cred/requests", json!({"kind":"grant","key":format!("{prefix}spoof"),"request":{"items":[item.id]}}) ) .await? .1["error"], "reserved_key" ); } let filed = ok( &api, "POST", "/cred/requests", json!({"kind":"grant","key":"explicit","request":{"items":[item.id]},"caller":{"pid":999}}), ) .await?; let id = filed["request"].as_str().unwrap(); let request = ok(&api, "GET", &format!("/cred/requests/{id}"), json!({})).await?; assert_eq!(request["pid"], 17); assert_eq!( request["subject"], json!({"kind":"conversation","id":"conversation"}) ); assert!(request["diff"].is_object()); assert!(request["summary"].is_string()); assert_eq!( ok(&api, "GET", "/cred/requests?state=approved", json!({})).await?, json!([]) ); assert_eq!( ok(&api, "GET", "/cred/requests?state=pending", json!({})) .await? .as_array() .unwrap() .len(), 2 ); let edit = ok(&api,"POST","/cred/requests",json!({"kind":"edit","key":"rename","request":{"item":item.id,"base_version":api.cred.item_get(&item.id)?.version,"change":[{"op":"set_name","name":"renamed"}]}})).await?; let view = ok( &api, "GET", &format!("/cred/requests/{}", edit["request"].as_str().unwrap()), json!({}), ) .await?; assert!(view["summary"].as_str().unwrap().contains("Rename")); assert_eq!(api.cred.item_get(&item.id)?.name, "ssh"); // Trusted fixture setup only. No world route creates this grant. api.cred.grant_create( cred::grants::Grant { id: String::new(), items: vec![item.id.clone()], subject: cred::types::Subject::Conversation("conversation".into()), exp: None, max_uses: Some(1), uses: 0, revoked: false, validator: None, note: None, }, &caller(), api.cred.unlocked()?, )?; let identity = ok(&api, "POST", "/ssh/identity", json!({"host":"example.com"})).await?; assert_eq!(identity["item"], item.id); assert_eq!(api.cred.grant_list()?[0].uses, 0); let public = STANDARD.decode(identity["public_key"].as_str().unwrap())?; let expected = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]).verifying_key(); assert!(public.ends_with(expected.as_bytes())); assert_ne!( send( &api, "POST", "/ssh/sign", json!({"host":"example.com","item":"different","payload":"YWJj"}) ) .await? .0, 200 ); let signed = ok( &api, "POST", "/ssh/sign", json!({"host":"example.com","item":item.id,"payload":STANDARD.encode(b"payload")}), ) .await?; assert_eq!(api.cred.grant_list()?[0].uses, 1); let audit = api.cred.audit_list()?; assert!( audit .iter() .any(|row| row.event == "sign" && row.payload_sha256.is_some()) ); assert!( !audit .iter() .any(|row| row.event == "ssh.sign.pending" || row.event == "fetch.pending") ); let signature = STANDARD.decode(signed["signature"].as_str().unwrap())?; use ed25519_dalek::Verifier; assert!(signature.starts_with(b"\0\0\0\x0bssh-ed25519\0\0\0\x40")); expected.verify( b"payload", &ed25519_dalek::Signature::from_slice(&signature[19..])?, )?; assert_eq!( send( &api, "POST", "/ssh/sign", json!({"host":"example.com","item":item.id,"payload":"YWJj"}) ) .await? .0, 403 ); ok(&api, "GET", "/cred/items", json!({})).await?; ok(&api, "GET", &format!("/cred/items/{}", item.id), json!({})).await?; ok(&api, "GET", "/cred/grants", json!({})).await?; Ok(()) } #[tokio::test(flavor = "current_thread")] async fn ssh_rejects_ambiguous_items() -> Result<()> { let dir = tempfile::tempdir()?; let db = rusqlite::Connection::open_in_memory()?; let cred = Cred::open(dir.path())?; item(&cred, "one")?; item(&cred, "two")?; let api = Api::new( cred, Passkeys::new(&db, "localhost", "http://localhost:8080")?, EntryKey::open(&dir.path().join("entry.key"))?, ); assert_eq!( send(&api, "POST", "/ssh/identity", json!({"host":"example.com"})) .await? .0, 409 ); assert!(api.cred.request_list()?.is_empty()); Ok(()) }