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

221 lines
8.1 KiB
Rust

//! Authorization ceremonies. Build bindings from stored data and consume typed proofs.
use super::{Api, Error, Result, parse, string, value};
use crate::cred::{
self, Caller,
requests::Overrides,
webauthn::{Binding, Decision, Gate, Proof, Verified},
};
use serde_json::{Value, json};
fn choice(body: &Value) -> Result<(Decision, Overrides)> {
let decision: Decision = parse(body["decision"].clone())?;
let overrides: Overrides = parse(body.get("overrides").cloned().unwrap_or(json!({})))?;
if decision == Decision::Deny && overrides != Overrides::default() {
return Err(Error(400, "invalid_overrides"));
}
Ok((decision, overrides))
}
fn verify(ctx: &Api<'_>, body: &Value) -> Result<Verified> {
ctx.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
.finish_assertion(
string(body, "challenge_id")?,
parse(body["assertion"].clone())?,
)
.map_err(|_| Error(403, "assertion_failed"))
}
pub(super) fn field(ctx: &Api<'_>, item: &str, field: &str) -> Result<cred::items::Field> {
let field = ctx
.cred
.item_get(item)?
.fields
.into_iter()
.find(|f| f.field_id == field)
.ok_or(Error(404, "field_missing"))?;
if field.public {
return Err(Error(400, "secret_field_required"));
}
Ok(field)
}
pub(super) fn route(
ctx: &Api<'_>,
caller: &Caller,
method: &str,
path: &[&str],
body: Value,
) -> Result<Value> {
match (method, path) {
("POST", ["cred", "requests", id, "challenge"]) => {
ctx.rate_limit(caller)?;
let (decision, overrides) = choice(&body)?;
let binding = ctx.cred.decision_binding(id, decision, overrides)?;
let mut passkeys = ctx.passkeys.lock().map_err(|_| Error(500, "storage"))?;
if passkeys.is_unlocked() {
return Ok(json!({"unlocked":true}));
}
value(
passkeys
.start_assertion(binding)
.map_err(|_| Error(403, "assertion_failed"))?,
)
}
("POST", ["cred", "requests", id, "decide"]) => {
let (decision, overrides, proof) = if ctx
.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
.is_unlocked()
{
let (decision, overrides) = choice(&body)?;
(decision, overrides, Proof::from(ctx.cred.unlocked()?))
} else {
let verified = verify(ctx, &body)?;
let Binding::Decision {
request_id,
decision,
overrides,
..
} = verified.binding()
else {
return Err(Error(403, "binding_mismatch"));
};
if request_id.as_str() != *id
|| (body.get("decision").is_some()
&& choice(&body)? != (*decision, overrides.clone()))
{
return Err(Error(403, "binding_mismatch"));
}
(*decision, overrides.clone(), Proof::from(verified))
};
match decision {
Decision::Approve => ctx.cred.approve(id, decision, overrides, proof)?,
Decision::Deny => ctx.cred.deny(id, decision, proof)?,
}
Ok(json!({"ok":true}))
}
("GET", ["cred", "passkeys"]) => value(
ctx.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
.list()
.map_err(|_| Error(500, "storage"))?,
),
("POST", ["cred", "passkeys", "register", "challenge"]) => {
ctx.rate_limit(caller)?;
value(
ctx.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
.start_assertion(Binding::Enrollment {
label: string(&body, "label")?.into(),
})
.map_err(|_| Error(403, "assertion_failed"))?,
)
}
("POST", ["cred", "passkeys", "register", "start"]) => {
ctx.rate_limit(caller)?;
let gate: Option<Gate> = body
.get("gate")
.filter(|v| !v.is_null())
.cloned()
.map(parse)
.transpose()?;
value(
ctx.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
.start_registration(string(&body, "label")?.into(), gate)
.map_err(|_| Error(403, "registration_failed"))?,
)
}
("POST", ["cred", "passkeys", "register", "finish"]) => {
ctx.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
.finish_registration(
string(&body, "challenge_id")?,
parse(body["response"].clone())?,
)
.map_err(|_| Error(403, "registration_failed"))?;
Ok(json!({"ok":true}))
}
("GET", ["cred", "entry", "key"]) => Ok(json!({"public_key":ctx.entry.public_key()})),
("POST", ["cred", "items", item, "fields", name, "challenge"]) => {
ctx.rate_limit(caller)?;
let field = field(ctx, item, name)?;
let hash = string(&body, "ciphertext_sha256")?;
if hash.len() != 64
|| !hash
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
{
return Err(Error(400, "invalid_hash"));
}
let binding = Binding::SecretEntry {
item: (*item).into(),
field: (*name).into(),
ciphertext_sha256: hash.into(),
field_type: Some(field.field_type),
key_type: field.key_type,
};
value(
ctx.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
.start_assertion(binding)
.map_err(|_| Error(403, "assertion_failed"))?,
)
}
("POST", ["cred", "items", item, "fields", name]) => {
let envelope: cred::entry::Envelope = parse(body["envelope"].clone())?;
let proof = verify(ctx, &body)?;
let Binding::SecretEntry {
item: bound_item,
field: bound_field,
ciphertext_sha256,
field_type,
key_type,
} = proof.binding()
else {
return Err(Error(403, "binding_mismatch"));
};
if bound_item.as_str() != *item
|| bound_field.as_str() != *name
|| *ciphertext_sha256 != envelope.sha256()
{
return Err(Error(403, "binding_mismatch"));
}
field(ctx, item, name)?;
let input = cred::items::FieldInput {
value: cred::items::FieldValue::Encrypted {
key: &ctx.entry,
envelope: &envelope,
},
field_type: field_type.clone(),
key_type: key_type.clone(),
};
ctx.cred
.set_field_input(item, name, &input, caller, proof)?;
Ok(json!({"ok":true}))
}
("POST", ["cred", "grants", id, "revoke"]) => {
ctx.rate_limit(caller)?;
ctx.cred.grant_status(id)?;
let request = ctx.cred.file_operation_request(
caller,
super::request_key(&body)?,
cred::edits::OperationRequest {
item: None,
base_version: None,
change: vec![cred::edits::Operation::GrantRevoke {
target: (*id).into(),
}],
},
)?;
Ok(json!({"request":request}))
}
_ => Err(Error(404, "route_missing")),
}
}