230 lines
8.3 KiB
Rust
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(())
|
|
}
|
|
}
|