//! 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 { 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})) } }