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

111 lines
4.4 KiB
Rust

//! SSH signing oracle. Rules and grants precede every key operation.
use super::{Api, Error, Result, string};
use crate::cred::{self, Caller, script::Script, types::FieldType};
use base64::{Engine, engine::general_purpose::STANDARD};
use serde_json::{Value, json};
pub(super) fn handle(ctx: &Api<'_>, caller: &Caller, action: &str, body: &Value) -> Result<Value> {
let cred = &ctx.cred;
let host = string(body, "host")?;
let mut items = cred.item_list()?.into_iter().filter(|item| {
item.rules
.iter()
.any(|rule| cred::items::rule_matches(rule, "ssh", host, "/", "POST"))
});
let item = items.next().ok_or(Error(404, "item_missing"))?;
if items.next().is_some() {
return Err(Error(409, "ambiguous_item"));
}
if action == "sign" && string(body, "item")? != item.id {
return Err(Error(403, "item_mismatch"));
}
let event = if action == "sign" {
"ssh.sign.pending"
} else {
"ssh.identity.pending"
};
let prepared = cred.prepare_use(&item, caller, action == "sign", event)?;
let Some((grant, values, audit_id)) = prepared else {
ctx.rate_limit(caller)?;
let request = cred.file_request(
caller,
&format!("ssh:{}", item.id),
cred::requests::GrantRequest {
items: vec![item.id.clone()],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
return Ok(
json!({"error":"grant_required","message":"grant_required","item":item.id,"request":request}),
);
};
let public = cred.credential(&item, false)?;
let context = json!({"request":{"method":"POST","url":format!("ssh://{host}/"),"headers":[]},"credential":public,"item":{"id":item.id,"name":item.name},"grant":grant.as_ref().map(|g|json!({"conversation":match &g.subject {cred::types::Subject::Conversation(id)=>Some(id),cred::types::Subject::Program(_)=>None},"subject":g.subject,"uses_left":g.max_uses.map(|m|m-g.uses),"exp":g.exp}))});
for source in [
item.validate_js.as_deref(),
grant.as_ref().and_then(|g| g.validator.as_deref()),
]
.into_iter()
.flatten()
{
let output = ctx
.script
.evaluate(source, &context.to_string(), None, Default::default())
.map_err(|_| Error(403, "validator_denied"))?;
let output: Value =
serde_json::from_str(&output).map_err(|_| Error(403, "validator_denied"))?;
if output
.as_bool()
.or_else(|| output.get("allow").and_then(Value::as_bool))
!= Some(true)
{
return Err(Error(403, "validator_denied"));
}
}
let field = item
.fields
.iter()
.find(|field| field.field_type == FieldType::SshPrivateKey)
.ok_or(Error(400, "signing_key_missing"))?;
let secret = values
.get(&field.name)
.and_then(Value::as_str)
.ok_or(Error(400, "signing_key_missing"))?;
let format = field.key_type.as_deref().ok_or(Error(400, "invalid_key"))?;
if action == "identity" {
let public_key = cred::sign::public_key(secret, None, format, "ssh")
.map_err(|_| Error(400, "invalid_key"))?;
cred.access()?
.db
.execute(
"UPDATE audit SET event='ssh.identity' WHERE id=?",
[audit_id],
)
.map_err(cred::Error::from)?;
Ok(
json!({"item":item.id,"public_key":public_key,"user":public.get("user").and_then(Value::as_str)}),
)
} else {
let payload = STANDARD
.decode(string(body, "payload")?)
.map_err(|_| Error(400, "invalid_payload"))?;
let (padding, hash) = match format {
"rs256" | "rsa-sha2-256" => (Some("pkcs1v15"), Some("sha256")),
"rsa-sha2-512" => (Some("pkcs1v15"), Some("sha512")),
_ => (None, None),
};
let signature = cred::sign::sign(secret, None, format, &payload, "ssh", padding, hash)
.map_err(|_| Error(400, "sign_failed"))?;
cred.access()?
.db
.execute(
"UPDATE audit SET event='sign',payload_sha256=?1 WHERE id=?2",
rusqlite::params![cred::webauthn::digest(&payload), audit_id],
)
.map_err(cred::Error::from)?;
Ok(json!({"signature":signature.signature}))
}
}