api: add public credential routes and SSH signing oracle
This commit is contained in:
Generated
+1
@@ -4995,6 +4995,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chacha20poly1305",
|
||||
"clap",
|
||||
"deno_ast",
|
||||
|
||||
@@ -51,6 +51,7 @@ ssh-key = { version = "0.6.7", features = ["encryption", "ed25519", "ecdsa", "rs
|
||||
uuid = { version = "1.29.0", features = ["v4"] }
|
||||
webauthn-rs = "0.5"
|
||||
openssl = { version = "0.10.82", features = ["vendored"] }
|
||||
bytes = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Host API
|
||||
|
||||
`handle(&Api, Caller, Request<Bytes>)` answers JSON requests for `host.sausage`.
|
||||
The transport supplies the caller from the process table, never from request JSON or headers.
|
||||
The transport hook is outside this module.
|
||||
Use one `Cred` per host process.
|
||||
SQLite and script work must run on a blocking thread in the transport.
|
||||
|
||||
T25 Note 1 pauses authorization routes for security decisions C1 and H2.
|
||||
T27 will change the mutation methods to require typed proofs.
|
||||
This module currently exposes only reads, request filing, and SSH operations.
|
||||
It does not expose decisions, enrollment, revocation, or secret entry.
|
||||
Those routes return 404 until the orchestrator authorizes further work.
|
||||
The API retains the passkey manager and entry key for the later routes.
|
||||
The host supplies their configuration and host-only paths.
|
||||
Tests use RP ID `localhost` and origin `http://localhost:8080`.
|
||||
|
||||
Reads are open to every caller.
|
||||
Successful responses use status 200 and JSON.
|
||||
Errors use `{"error":"kind","message":"kind"}` with static, secret-free text.
|
||||
Unknown routes return 404.
|
||||
Invalid JSON or parameters return 400.
|
||||
Conflicts return 409.
|
||||
Request filing permits 32 attempts per process within a 60-second window.
|
||||
The next attempt returns 429 until that window expires.
|
||||
Explicit requests and automatic SSH requests share this budget.
|
||||
Reads and granted SSH operations do not spend the budget.
|
||||
|
||||
## Current routes
|
||||
|
||||
- `GET /whoami` returns the trusted caller.
|
||||
- `GET /cred/items` returns public item snapshots.
|
||||
- `GET /cred/items/{id}` returns one public snapshot.
|
||||
- `GET /cred/requests?state=pending` returns requests with diffs and summaries.
|
||||
Omit `state` to return every request.
|
||||
- `GET /cred/requests/{id}` returns one request with its diff and summary.
|
||||
- `POST /cred/requests` accepts `{"kind":"grant","key":"stable-key","request":{...}}`.
|
||||
The request contains `items` and optional `duration_secs`, `max_uses`, and `validator`.
|
||||
Kind `edit` accepts an `OperationRequest` with `item`, `base_version`, and `change`.
|
||||
This route returns `{"request":"id"}` without approving the request.
|
||||
The key prefixes `inject:` and `ssh:` belong to automatic requests.
|
||||
An explicit request with either prefix returns 400.
|
||||
- `GET /cred/grants` returns the grant ledger.
|
||||
- `POST /ssh/identity` accepts `{"host":"example.com"}`.
|
||||
It returns `{"item":"id","public_key":"base64 SSH public key blob","user":null}`.
|
||||
A public `user` field supplies the optional default user.
|
||||
- `POST /ssh/sign` accepts `{"host":"example.com","item":"id","payload":"base64"}`.
|
||||
It returns `{"signature":"base64 SSH wire signature"}`.
|
||||
|
||||
SSH operations match SSH rules and enforce free use or a caller grant.
|
||||
Both operations evaluate item and grant validators.
|
||||
Identity discovery checks the grant without spending a use.
|
||||
Signing reserves one grant use.
|
||||
An ungranted caller receives 403 with `error`, `message`, `item`, and `request`.
|
||||
The host files that request before it responds.
|
||||
Two matching items produce 409.
|
||||
The signing item must match the selected host rule.
|
||||
The host never returns a private key or decrypted field value.
|
||||
|
||||
## Held routes
|
||||
|
||||
The original brief also requests these route groups:
|
||||
|
||||
- Request challenges and decisions.
|
||||
- Passkey enrollment and listing.
|
||||
- Grant revocation through an approved request.
|
||||
- The entry public key, entry challenges, and encrypted field writes.
|
||||
|
||||
Their final contracts depend on the security decisions and T27's typed proof interface.
|
||||
The host must not trust world-served pages to describe the approved action.
|
||||
No public route accepts a caller identity, a raw binding, or an approver string.
|
||||
@@ -0,0 +1,207 @@
|
||||
//! JSON routes for host.sausage. The transport supplies the trusted caller.
|
||||
//! Authorization routes remain on hold until the host-origin and proof decisions land.
|
||||
mod ssh;
|
||||
|
||||
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::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub struct Api<'a> {
|
||||
pub cred: Cred,
|
||||
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: Cred, passkeys: Passkeys<'a>, entry: EntryKey) -> Self {
|
||||
Self {
|
||||
cred,
|
||||
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,
|
||||
"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 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> {
|
||||
let result = 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 = string(&body, "key")?;
|
||||
if key.starts_with("inject:") || key.starts_with("ssh:") {
|
||||
return Err(Error(400, "reserved_key"));
|
||||
}
|
||||
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)
|
||||
}
|
||||
_ => Err(Error(404, "route_missing")),
|
||||
}
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! 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 prepared = if item.free_use {
|
||||
Some((None, cred.credential(&item, true)?))
|
||||
} else {
|
||||
cred.prepare_use(&item, caller, action == "sign")?
|
||||
.map(|(grant, values)| (Some(grant), values))
|
||||
};
|
||||
let Some((grant, values)) = 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 = cred::v8::V8
|
||||
.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"))?;
|
||||
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.audit_sign(&item.id, caller, &payload)?;
|
||||
Ok(json!({"signature":signature.signature}))
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -139,6 +139,15 @@ impl Cred {
|
||||
&self,
|
||||
item: &super::items::Item,
|
||||
caller: &Caller,
|
||||
) -> Result<Option<(Grant, serde_json::Value)>> {
|
||||
self.prepare_use(item, caller, true)
|
||||
}
|
||||
/// Public-key discovery checks grants without spending a signing use.
|
||||
pub(crate) fn prepare_use(
|
||||
&self,
|
||||
item: &super::items::Item,
|
||||
caller: &Caller,
|
||||
consume: bool,
|
||||
) -> Result<Option<(Grant, serde_json::Value)>> {
|
||||
let mut s = self.access()?;
|
||||
let master = s.master.clone();
|
||||
@@ -156,11 +165,13 @@ impl Cred {
|
||||
return Err(Error("stale"));
|
||||
}
|
||||
let credential = super::items::credential(&tx, &master, item, true)?;
|
||||
let changed=tx.execute("UPDATE grants SET uses=uses+1 WHERE id=?1 AND revoked=0 AND (exp IS NULL OR exp>?2) AND (max_uses IS NULL OR uses<max_uses)",params![g.id,now()])?;
|
||||
if changed != 1 {
|
||||
return Err(Error("grant_exhausted"));
|
||||
if consume {
|
||||
let changed=tx.execute("UPDATE grants SET uses=uses+1 WHERE id=?1 AND revoked=0 AND (exp IS NULL OR exp>?2) AND (max_uses IS NULL OR uses<max_uses)",params![g.id,now()])?;
|
||||
if changed != 1 {
|
||||
return Err(Error("grant_exhausted"));
|
||||
}
|
||||
g.uses += 1;
|
||||
}
|
||||
g.uses += 1;
|
||||
tx.commit()?;
|
||||
Ok(Some((g, credential)))
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The Sausage v3 host: a world folder behind a virtual filesystem, WASI programs in a process table,
|
||||
//! and the durable agent harness. See `~/research/sausage-v3/PLAN.md` until the docs move here.
|
||||
|
||||
pub mod api;
|
||||
pub mod cred;
|
||||
pub mod exec;
|
||||
pub mod harness;
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Direct host API tests for reads, request filing, and the SSH signing oracle.
|
||||
//! Authorization UI routes wait for the security decisions in T25 Note 1.
|
||||
use anyhow::Result;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use bytes::Bytes;
|
||||
use sausage::{
|
||||
api::{Api, handle},
|
||||
cred::{self, Caller, Cred, entry::EntryKey, webauthn::Passkeys},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn caller() -> Caller {
|
||||
Caller {
|
||||
pid: 17,
|
||||
program: "/bin/agent".into(),
|
||||
conversations: vec!["conversation".into()],
|
||||
}
|
||||
}
|
||||
async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
|
||||
let request = http::Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header("x-sausage-pid", "999")
|
||||
.body(Bytes::from(serde_json::to_vec(&body)?))?;
|
||||
let response = handle(api, caller(), request).await;
|
||||
let text = std::str::from_utf8(response.body())?;
|
||||
assert!(!text.contains("browser-only-value"));
|
||||
assert!(!text.contains(&STANDARD.encode([7u8; 32])));
|
||||
assert_eq!(response.headers()["content-type"], "application/json");
|
||||
Ok((
|
||||
response.status().as_u16(),
|
||||
serde_json::from_slice(response.body())?,
|
||||
))
|
||||
}
|
||||
async fn ok(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<Value> {
|
||||
let (status, value) = send(api, method, path, body).await?;
|
||||
assert_eq!(status, 200, "unexpected API status");
|
||||
Ok(value)
|
||||
}
|
||||
fn item(cred: &Cred, name: &str) -> Result<cred::items::Item> {
|
||||
Ok(cred.create_item(serde_json::from_value(json!({"name":name,"rules":[{"scheme":"ssh","hosts":["example.com"],"path_prefixes":["/"]}],"transform_js":"","fields":[{"name":"secret","public":false,"field_type":"ssh_private_key","key_type":"ed25519-raw"}]}))?,&caller(),"fixture")?)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn public_routes_and_ssh() -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite"))?;
|
||||
let cred = Cred::open(dir.path())?;
|
||||
let item = item(&cred, "ssh")?;
|
||||
cred.set_field(
|
||||
&item.id,
|
||||
&item.fields[0].field_id,
|
||||
&STANDARD.encode([7u8; 32]),
|
||||
&caller(),
|
||||
"fixture",
|
||||
)?;
|
||||
let api = Api::new(
|
||||
cred,
|
||||
Passkeys::new(&db, "localhost", "http://localhost:8080")?,
|
||||
EntryKey::open(&dir.path().join("entry.key"))?,
|
||||
);
|
||||
let who = ok(
|
||||
&api,
|
||||
"GET",
|
||||
"/whoami",
|
||||
json!({"pid":999,"conversations":["forged"]}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(who["pid"], 17);
|
||||
assert_eq!(who["conversations"], json!(["conversation"]));
|
||||
let mut pending = String::new();
|
||||
for action in ["identity", "sign"] {
|
||||
let (status, denied) = send(
|
||||
&api,
|
||||
"POST",
|
||||
&format!("/ssh/{action}"),
|
||||
json!({"host":"example.com","item":item.id,"payload":"YWJj"}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, 403);
|
||||
assert_eq!(denied["item"], item.id);
|
||||
let request = denied["request"].as_str().unwrap();
|
||||
if pending.is_empty() {
|
||||
pending = request.into();
|
||||
} else {
|
||||
assert_eq!(pending, request);
|
||||
}
|
||||
}
|
||||
for prefix in ["inject:", "ssh:"] {
|
||||
assert_eq!(
|
||||
send(
|
||||
&api,
|
||||
"POST",
|
||||
"/cred/requests",
|
||||
json!({"kind":"grant","key":format!("{prefix}spoof"),"request":{"items":[item.id]}})
|
||||
)
|
||||
.await?
|
||||
.1["error"],
|
||||
"reserved_key"
|
||||
);
|
||||
}
|
||||
let filed = ok(
|
||||
&api,
|
||||
"POST",
|
||||
"/cred/requests",
|
||||
json!({"kind":"grant","key":"explicit","request":{"items":[item.id]},"caller":{"pid":999}}),
|
||||
)
|
||||
.await?;
|
||||
let id = filed["request"].as_str().unwrap();
|
||||
let request = ok(&api, "GET", &format!("/cred/requests/{id}"), json!({})).await?;
|
||||
assert_eq!(request["pid"], 17);
|
||||
assert_eq!(
|
||||
request["subject"],
|
||||
json!({"kind":"conversation","id":"conversation"})
|
||||
);
|
||||
assert!(request["diff"].is_object());
|
||||
assert!(request["summary"].is_string());
|
||||
assert_eq!(
|
||||
ok(&api, "GET", "/cred/requests?state=approved", json!({})).await?,
|
||||
json!([])
|
||||
);
|
||||
assert_eq!(
|
||||
ok(&api, "GET", "/cred/requests?state=pending", json!({}))
|
||||
.await?
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
let edit = ok(&api,"POST","/cred/requests",json!({"kind":"edit","key":"rename","request":{"item":item.id,"base_version":api.cred.item_get(&item.id)?.version,"change":[{"op":"set_name","name":"renamed"}]}})).await?;
|
||||
let view = ok(
|
||||
&api,
|
||||
"GET",
|
||||
&format!("/cred/requests/{}", edit["request"].as_str().unwrap()),
|
||||
json!({}),
|
||||
)
|
||||
.await?;
|
||||
assert!(view["summary"].as_str().unwrap().contains("Rename"));
|
||||
assert_eq!(api.cred.item_get(&item.id)?.name, "ssh");
|
||||
// Trusted fixture setup only. No world route creates this grant.
|
||||
api.cred.grant_create(
|
||||
cred::grants::Grant {
|
||||
id: String::new(),
|
||||
items: vec![item.id.clone()],
|
||||
subject: cred::types::Subject::Conversation("conversation".into()),
|
||||
exp: None,
|
||||
max_uses: Some(1),
|
||||
uses: 0,
|
||||
revoked: false,
|
||||
validator: None,
|
||||
note: None,
|
||||
},
|
||||
&caller(),
|
||||
Some("fixture"),
|
||||
)?;
|
||||
let identity = ok(&api, "POST", "/ssh/identity", json!({"host":"example.com"})).await?;
|
||||
assert_eq!(identity["item"], item.id);
|
||||
assert_eq!(api.cred.grant_list()?[0].uses, 0);
|
||||
let public = STANDARD.decode(identity["public_key"].as_str().unwrap())?;
|
||||
let expected = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]).verifying_key();
|
||||
assert!(public.ends_with(expected.as_bytes()));
|
||||
assert_ne!(
|
||||
send(
|
||||
&api,
|
||||
"POST",
|
||||
"/ssh/sign",
|
||||
json!({"host":"example.com","item":"different","payload":"YWJj"})
|
||||
)
|
||||
.await?
|
||||
.0,
|
||||
200
|
||||
);
|
||||
let signed = ok(
|
||||
&api,
|
||||
"POST",
|
||||
"/ssh/sign",
|
||||
json!({"host":"example.com","item":item.id,"payload":STANDARD.encode(b"payload")}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(api.cred.grant_list()?[0].uses, 1);
|
||||
let signature = STANDARD.decode(signed["signature"].as_str().unwrap())?;
|
||||
use ed25519_dalek::Verifier;
|
||||
assert!(signature.starts_with(b"\0\0\0\x0bssh-ed25519\0\0\0\x40"));
|
||||
expected.verify(
|
||||
b"payload",
|
||||
&ed25519_dalek::Signature::from_slice(&signature[19..])?,
|
||||
)?;
|
||||
assert_eq!(
|
||||
send(
|
||||
&api,
|
||||
"POST",
|
||||
"/ssh/sign",
|
||||
json!({"host":"example.com","item":item.id,"payload":"YWJj"})
|
||||
)
|
||||
.await?
|
||||
.0,
|
||||
403
|
||||
);
|
||||
ok(&api, "GET", "/cred/items", json!({})).await?;
|
||||
ok(&api, "GET", &format!("/cred/items/{}", item.id), json!({})).await?;
|
||||
ok(&api, "GET", "/cred/grants", json!({})).await?;
|
||||
for path in [
|
||||
"/cred/passkeys/register/start",
|
||||
"/cred/items/x/fields/y",
|
||||
"/cred/requests/x/decide",
|
||||
"/cred/grants/x/revoke",
|
||||
] {
|
||||
assert_eq!(send(&api, "POST", path, json!({})).await?.0, 404);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ssh_rejects_ambiguous_items() -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let db = rusqlite::Connection::open_in_memory()?;
|
||||
let cred = Cred::open(dir.path())?;
|
||||
item(&cred, "one")?;
|
||||
item(&cred, "two")?;
|
||||
let api = Api::new(
|
||||
cred,
|
||||
Passkeys::new(&db, "localhost", "http://localhost:8080")?,
|
||||
EntryKey::open(&dir.path().join("entry.key"))?,
|
||||
);
|
||||
assert_eq!(
|
||||
send(&api, "POST", "/ssh/identity", json!({"host":"example.com"}))
|
||||
.await?
|
||||
.0,
|
||||
409
|
||||
);
|
||||
assert!(api.cred.request_list()?.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user