Files
sausage/host/src/api/mod.rs
T

230 lines
8.3 KiB
Rust

//! JSON routes for host.sausage. The transport supplies the trusted caller.
//! Host-owned pages use passkeys and typed proofs for every authorization ceremony.
mod auth;
mod pages;
mod server;
mod ssh;
pub use server::{Listener, Options, run_with_userland};
use crate::cred::{self, Caller, Cred, entry::EntryKey, webauthn::Passkeys};
use bytes::Bytes;
use http::{Request, Response};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
pub struct Api<'a> {
pub cred: Arc<Cred>,
script: cred::quickjs::QuickJs,
pub passkeys: Mutex<Passkeys<'a>>,
pub entry: EntryKey,
requests: Mutex<HashMap<u32, (Instant, u32)>>,
}
impl<'a> Api<'a> {
/// Passkeys owns the RP configuration. Its database must remain host-only.
pub fn new(cred: impl Into<Arc<Cred>>, passkeys: Passkeys<'a>, entry: EntryKey) -> Self {
Self {
cred: cred.into(),
script: cred::quickjs::QuickJs::default(),
passkeys: Mutex::new(passkeys),
entry,
requests: Mutex::new(HashMap::new()),
}
}
fn rate_limit(&self, caller: &Caller) -> Result<()> {
let now = Instant::now();
let mut requests = self.requests.lock().map_err(|_| Error(500, "storage"))?;
requests.retain(|_, (start, _)| now.duration_since(*start) < Duration::from_secs(60));
let (_, count) = requests.entry(caller.pid).or_insert((now, 0));
if *count >= 32 {
return Err(Error(429, "rate_limited"));
}
*count += 1;
Ok(())
}
}
struct Error(u16, &'static str);
type Result<T> = std::result::Result<T, Error>;
impl From<cred::Error> for Error {
fn from(error: cred::Error) -> Self {
let status = match error.0 {
"item_deleted" | "request_missing" | "grant_missing" => 404,
"ambiguous_item" | "stale" | "already_decided" | "key_conflict" => 409,
"assertion_required" | "binding_mismatch" => 403,
"storage" => 500,
_ => 400,
};
Self(status, error.0)
}
}
fn parse<T: DeserializeOwned>(value: Value) -> Result<T> {
serde_json::from_value(value).map_err(|_| Error(400, "invalid_json"))
}
fn string<'a>(value: &'a Value, key: &str) -> Result<&'a str> {
value
.get(key)
.and_then(Value::as_str)
.ok_or(Error(400, "invalid_params"))
}
fn request_key(body: &Value) -> Result<&str> {
let key = string(body, "key")?;
if key.starts_with("inject:") || key.starts_with("ssh:") {
return Err(Error(400, "reserved_key"));
}
Ok(key)
}
fn value<T: serde::Serialize>(data: T) -> Result<Value> {
serde_json::to_value(data).map_err(|_| Error(500, "serialization"))
}
fn request_view(request: cred::requests::Request) -> Result<Value> {
let summary = if let Some(requested) = request.payload.get("requested") {
let requested: cred::edits::OperationRequest = parse(requested.clone())?;
requested
.change
.iter()
.map(cred::summary::operation_summary)
.collect::<Vec<_>>()
.join(" ")
} else if request.payload.get("grant_revoke").is_some() {
"Revoke the grant. All access through this grant will stop.".into()
} else if request.payload.get("grant_create").is_some() {
"Create a grant. Review the subject and limits in Details.".into()
} else if request.payload.get("delete").is_some() {
"Delete the item and permanently discard its field values.".into()
} else if request.payload.get("revert").is_some() {
"Restore the public configuration. Discarded secret values cannot be recovered.".into()
} else {
let grant: cred::requests::GrantRequest = parse(request.payload.clone())?;
format!(
"Give access to {} credential items for {}.",
grant.items.len(),
cred::summary::human_duration(grant.duration_secs.unwrap_or(3600))
)
};
let mut out = value(&request)?;
out["diff"] = request.payload;
out["summary"] = json!(summary);
Ok(out)
}
/// Handle a request without a network transport. Errors contain only static error kinds.
pub async fn handle(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
respond(ctx, caller, request)
}
fn respond(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
let result = if request.method() == http::Method::GET {
match pages::get(ctx, request.uri().path()) {
Ok(Some(response)) => return response,
Ok(None) => route(ctx, &caller, request),
Err(error) => Err(error),
}
} else {
route(ctx, &caller, request)
};
let (status, body) = match result {
Ok(body) => (
if body.get("error").and_then(Value::as_str) == Some("grant_required") {
403
} else {
200
},
body,
),
Err(Error(status, kind)) => (status, json!({"error":kind,"message":kind})),
};
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Bytes::from(serde_json::to_vec(&body).expect("JSON value")))
.expect("valid response")
}
fn route(ctx: &Api<'_>, caller: &Caller, request: Request<Bytes>) -> Result<Value> {
let path: Vec<_> = request.uri().path().trim_matches('/').split('/').collect();
let method = request.method().as_str();
let body: Value = if request.body().is_empty() {
json!({})
} else {
serde_json::from_slice(request.body()).map_err(|_| Error(400, "invalid_json"))?
};
match (method, path.as_slice()) {
("GET", ["whoami"]) => value(caller),
("GET", ["cred", "items"]) => value(ctx.cred.item_list()?),
("GET", ["cred", "items", id]) => value(ctx.cred.item_get(id)?),
("GET", ["cred", "requests"]) => {
let state = url::form_urlencoded::parse(request.uri().query().unwrap_or("").as_bytes())
.find(|(key, _)| key == "state")
.map(|(_, state)| state.into_owned());
ctx.cred
.request_list()?
.into_iter()
.filter(|r| state.as_ref().is_none_or(|s| s == &r.state))
.map(request_view)
.collect::<Result<Vec<_>>>()
.map(Value::Array)
}
("GET", ["cred", "requests", id]) => request_view(
ctx.cred
.request_list()?
.into_iter()
.find(|r| r.id == *id)
.ok_or(Error(404, "request_missing"))?,
),
("POST", ["cred", "requests"]) => {
let key = request_key(&body)?;
ctx.rate_limit(caller)?;
let id = match string(&body, "kind")? {
"grant" => ctx
.cred
.file_request(caller, key, parse(body["request"].clone())?)?,
"edit" => {
ctx.cred
.file_operation_request(caller, key, parse(body["request"].clone())?)?
}
_ => return Err(Error(400, "invalid_kind")),
};
Ok(json!({"request":id}))
}
("GET", ["cred", "grants"]) => value(ctx.cred.grant_list()?),
("POST", ["ssh", action @ ("identity" | "sign")]) => {
ssh::handle(ctx, caller, action, &body)
}
_ => auth::route(ctx, caller, method, &path, body),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_budget_is_per_caller_and_bounded() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let db = rusqlite::Connection::open_in_memory()?;
let api = Api::new(
Cred::open(dir.path())?,
Passkeys::new(&db, "localhost", "http://localhost:8080")?,
EntryKey::open(&dir.path().join("entry.key"))?,
);
let caller = Caller::default();
for _ in 0..32 {
assert!(api.rate_limit(&caller).is_ok());
}
assert!(matches!(
api.rate_limit(&caller),
Err(Error(429, "rate_limited"))
));
assert!(
api.rate_limit(&Caller {
pid: 1,
..Default::default()
})
.is_ok()
);
Ok(())
}
}