236 lines
7.7 KiB
Rust
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(())
|
|
}
|