Files
sausage/host/tests/it/api.rs
T

236 lines
7.7 KiB
Rust

//! 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<Value> {
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<cred::items::Item> {
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(())
}