Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions crates/agentkeys-backend-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ use reqwest::Client;
use crate::protocol::{
AuditAppendInput, AuditAppendResult, AuditAppendV2, AuditAppendV2Resp, BrokerCapRequest,
CapMintOp, CapMintRequest, CapToken, CredFetchBody, CredFetchInput, CredFetchResp,
CredFetchResult, MemoryGetBody, MemoryGetInput, MemoryGetResp, MemoryGetResult, MemoryPutBody,
MemoryPutInput, MemoryPutResp, MemoryPutResult, RevokeResult, ENVELOPE_VERSION,
CredFetchResult, CredStoreBody, CredStoreInput, CredStoreResp, CredStoreResult, MemoryGetBody,
MemoryGetInput, MemoryGetResp, MemoryGetResult, MemoryPutBody, MemoryPutInput, MemoryPutResp,
MemoryPutResult, RevokeResult, ENVELOPE_VERSION,
};

#[derive(thiserror::Error, Debug)]
Expand All @@ -40,8 +41,8 @@ pub struct BackendClient {
pub broker_url: Option<String>,
pub memory_url: Option<String>,
pub audit_url: Option<String>,
/// Cred worker base URL (#216 agent-side vaulted-key fetch). `None` → no
/// cred-fetch available.
/// Cred worker base URL — backs `/v1/cred/{store,fetch}` (the
/// `agentkeys.cred.*` tools). `None` → cred store/fetch unavailable.
pub cred_url: Option<String>,
/// Agent session JWT (omni == the actor). Used to mint per-actor STS creds
/// for the worker S3 relay (issue #90). `None` → no relay (worker falls
Expand Down Expand Up @@ -278,6 +279,42 @@ impl BackendClient {
})
}

/// `POST /v1/cred/store` — encrypt + store a credential's plaintext. The
/// `cap` (a cred-store cap with the `service` signed inside) is minted
/// separately via [`Self::cap_mint`]; this forwards per-actor STS creds
/// under the VAULT role so the cred worker's S3 PUT is scoped to
/// `bots/<actor>/credentials/<service>.enc`.
pub async fn cred_store(&self, input: CredStoreInput) -> Result<CredStoreResult, BackendError> {
let url = format!("{}/v1/cred/store", self.cred()?);
let mut req = self.client.post(&url).json(&CredStoreBody {
cap: input.cap,
plaintext_b64: input.plaintext_b64,
});
if let Some(headers) = self.sts_headers(self.vault_role_arn.as_ref()).await? {
for (k, v) in headers {
req = req.header(k, v);
}
}
let resp = req
.send()
.await
.map_err(|e| BackendError::Transport(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(BackendError::Http { status, body });
}
let parsed: CredStoreResp = resp
.json()
.await
.map_err(|e| BackendError::Parse(e.to_string()))?;
Ok(CredStoreResult {
ok: parsed.ok,
s3_key: parsed.s3_key,
envelope_size: parsed.envelope_size,
})
}

/// `POST /v1/cred/fetch` — fetch + decrypt a stored credential's plaintext
/// (#216 agent-side vaulted-key fetch). The `cap` (a cred-fetch cap with the
/// `service` signed inside) is minted separately via [`Self::cap_mint`]; this
Expand Down
29 changes: 27 additions & 2 deletions crates/agentkeys-backend-client/src/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
use serde_json::{json, Value};

use crate::protocol::{
AuditAppendV2, BrokerCapRequest, ConfigGetBody, ConfigPutBody, MemoryGetBody, MemoryPutBody,
ENVELOPE_VERSION,
AuditAppendV2, BrokerCapRequest, ConfigGetBody, ConfigPutBody, CredFetchBody, CredStoreBody,
MemoryGetBody, MemoryPutBody, ENVELOPE_VERSION,
};

/// One canonical fixture: the on-disk file stem + the sample body.
Expand Down Expand Up @@ -55,6 +55,13 @@ pub fn canonical_fixtures() -> Vec<Fixture> {
let config_get = ConfigGetBody {
cap: json!("<cap-token>"),
};
let cred_store = CredStoreBody {
cap: json!("<cap-token>"),
plaintext_b64: "<base64-plaintext>".into(),
};
let cred_fetch = CredFetchBody {
cap: json!("<cap-token>"),
};
let audit = AuditAppendV2 {
version: ENVELOPE_VERSION,
ts_unix: 0,
Expand Down Expand Up @@ -87,6 +94,14 @@ pub fn canonical_fixtures() -> Vec<Fixture> {
name: "config_get_body",
body: serde_json::to_value(&config_get).expect("config_get serializes"),
},
Fixture {
name: "cred_store_body",
body: serde_json::to_value(&cred_store).expect("cred_store serializes"),
},
Fixture {
name: "cred_fetch_body",
body: serde_json::to_value(&cred_fetch).expect("cred_fetch serializes"),
},
Fixture {
name: "audit_append_v2",
body: serde_json::to_value(&audit).expect("audit serializes"),
Expand Down Expand Up @@ -157,6 +172,16 @@ mod tests {
assert_eq!(keys_of("config_get_body"), vec!["cap"]);
}

#[test]
fn cred_store_body_keys_frozen() {
assert_eq!(keys_of("cred_store_body"), vec!["cap", "plaintext_b64"]);
}

#[test]
fn cred_fetch_body_keys_frozen() {
assert_eq!(keys_of("cred_fetch_body"), vec!["cap"]);
}

#[test]
fn audit_append_v2_keys_frozen() {
assert_eq!(
Expand Down
5 changes: 3 additions & 2 deletions crates/agentkeys-backend-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub use protocol::{
normalize_omni_0x, service_memory, AuditAppendInput, AuditAppendResult, AuditAppendV2,
AuditAppendV2Resp, BrokerCapRequest, CapMintOp, CapMintRequest, CapToken, ConfigGetBody,
ConfigGetResp, ConfigPutBody, CredFetchBody, CredFetchInput, CredFetchResp, CredFetchResult,
MemoryGetBody, MemoryGetInput, MemoryGetResp, MemoryGetResult, MemoryPutBody, MemoryPutInput,
MemoryPutResp, MemoryPutResult, RevokeResult, ENVELOPE_VERSION,
CredStoreBody, CredStoreInput, CredStoreResp, CredStoreResult, MemoryGetBody, MemoryGetInput,
MemoryGetResp, MemoryGetResult, MemoryPutBody, MemoryPutInput, MemoryPutResp, MemoryPutResult,
RevokeResult, ENVELOPE_VERSION,
};
31 changes: 30 additions & 1 deletion crates/agentkeys-backend-client/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,23 @@ pub struct ConfigGetResp {
pub plaintext_b64: String,
}

// ── cred worker (`/v1/cred/fetch`) — #216 agent-side vaulted-key fetch ────────
// ── cred worker (`/v1/cred/{store,fetch}`) — agent-side vaulted-key ops ──────

/// Cred-worker `/v1/cred/store` request body. Mirrors
/// `agentkeys_worker_creds::handlers::StoreRequest` — the credential `service`
/// rides INSIDE the cap payload (it can't be spoofed at the body level).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredStoreBody {
pub cap: CapToken,
pub plaintext_b64: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct CredStoreResp {
pub ok: bool,
pub s3_key: String,
pub envelope_size: usize,
}

/// Cred-worker `/v1/cred/fetch` request body. Mirrors
/// `agentkeys_worker_creds::handlers::FetchRequest` — just the signed cap; the
Expand Down Expand Up @@ -249,6 +265,19 @@ pub struct MemoryGetResult {
pub namespace: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredStoreInput {
pub cap: CapToken,
pub plaintext_b64: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredStoreResult {
pub ok: bool,
pub s3_key: String,
pub envelope_size: usize,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredFetchInput {
pub cap: CapToken,
Expand Down
48 changes: 48 additions & 0 deletions crates/agentkeys-cli/src/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,54 @@ pub async fn memory_put(
Ok(result.to_string())
}

/// `agentkeys cred store --service <svc> --content <secret>` — write an
/// agent-owned credential via `agentkeys.cred.store`. The MCP tool mints a
/// cred-store cap as the agent itself (`operator_omni == actor_omni`), then
/// sends the plaintext to the cred worker through the vault-role STS relay.
pub async fn cred_store(
service: &str,
content: &str,
mcp_url: Option<String>,
vendor_token: Option<String>,
actor: Option<String>,
) -> Result<String> {
let client = HookClient::resolve(mcp_url, vendor_token, actor, None);
let mut args = json!({"service": service, "content": content});
if !client.actor.is_empty() {
args["actor"] = json!(client.actor);
}
let result = client
.call_tool("agentkeys.cred.store", args)
.await
.context("cred.store")?;
Ok(result.to_string())
}

/// `agentkeys cred fetch --service <svc>` — fetch an agent-owned credential via
/// `agentkeys.cred.fetch`. Plaintext is returned to stdout to support shell
/// roundtrip checks; callers should avoid logging real secrets.
pub async fn cred_fetch(
service: &str,
mcp_url: Option<String>,
vendor_token: Option<String>,
actor: Option<String>,
) -> Result<String> {
let client = HookClient::resolve(mcp_url, vendor_token, actor, None);
let mut args = json!({"service": service});
if !client.actor.is_empty() {
args["actor"] = json!(client.actor);
}
let result = client
.call_tool("agentkeys.cred.fetch", args)
.await
.context("cred.fetch")?;
if let Some(content) = result.get("content").and_then(|v| v.as_str()) {
Ok(content.to_string())
} else {
Ok(result.to_string())
}
}

/// Extract the `content` field of an `agentkeys.memory.get` result. The
/// MCP tool layer already base64-decodes the worker's `plaintext_b64`
/// into a UTF-8 `content` string (see
Expand Down
73 changes: 73 additions & 0 deletions crates/agentkeys-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,14 @@ enum Commands {
#[command(subcommand)]
action: MemoryAction,
},
#[command(
about = "Agent-owned credential helpers (real cred worker)",
long_about = "Direct agent-owned credential operations against the AgentKeys MCP server. `store` writes a service credential using the sandbox-held agent session; `fetch` reads it back. This is separate from the legacy master-side `store/read` commands."
)]
Cred {
#[command(subcommand)]
action: CredAction,
},
/// Agent-side device bootstrap (interim §10.2 — full ceremony: issue #144).
Agent {
#[command(subcommand)]
Expand Down Expand Up @@ -750,6 +758,39 @@ enum MemoryAction {
},
}

#[derive(Subcommand)]
enum CredAction {
/// Store this agent's own service credential through the real cred worker.
#[command(about = "Store an agent-owned service credential via agentkeys.cred.store")]
Store {
/// Service name to store (e.g. `openrouter`).
#[arg(long)]
service: String,
/// Plaintext credential value to store.
#[arg(long)]
content: String,
#[arg(long, env = "AGENTKEYS_MCP_URL")]
mcp_url: Option<String>,
#[arg(long, env = "AGENTKEYS_MCP_VENDOR_TOKEN")]
vendor_token: Option<String>,
#[arg(long, env = "AGENTKEYS_ACTOR_OMNI")]
actor: Option<String>,
},
/// Fetch this agent's own service credential through the real cred worker.
#[command(about = "Fetch an agent-owned service credential via agentkeys.cred.fetch")]
Fetch {
/// Service name to fetch (e.g. `openrouter`).
#[arg(long)]
service: String,
#[arg(long, env = "AGENTKEYS_MCP_URL")]
mcp_url: Option<String>,
#[arg(long, env = "AGENTKEYS_MCP_VENDOR_TOKEN")]
vendor_token: Option<String>,
#[arg(long, env = "AGENTKEYS_ACTOR_OMNI")]
actor: Option<String>,
},
}

#[derive(Subcommand)]
enum AgentAction {
/// Generate (or reuse) THIS machine's secp256k1 device key, mint a broker
Expand Down Expand Up @@ -1355,6 +1396,38 @@ async fn main() {
.await
}
},
Commands::Cred { action } => match action {
CredAction::Store {
service,
content,
mcp_url,
vendor_token,
actor,
} => {
agentkeys_cli::hook::cred_store(
service,
content,
mcp_url.clone(),
vendor_token.clone(),
actor.clone(),
)
.await
}
CredAction::Fetch {
service,
mcp_url,
vendor_token,
actor,
} => {
agentkeys_cli::hook::cred_fetch(
service,
mcp_url.clone(),
vendor_token.clone(),
actor.clone(),
)
.await
}
},
Commands::Agent { action } => match action {
AgentAction::DeviceSession {
broker_url,
Expand Down
15 changes: 14 additions & 1 deletion crates/agentkeys-mcp-server/src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ use agentkeys_backend_client::BackendClient;
// existing `crate::backend::CapMintOp` / `BackendError` / … paths keep working.
pub use agentkeys_backend_client::{
AuditAppendInput, AuditAppendResult, BackendError, CapMintOp, CapMintRequest, CapToken,
MemoryGetInput, MemoryGetResult, MemoryPutInput, MemoryPutResult, RevokeResult,
CredFetchInput, CredFetchResult, CredStoreInput, CredStoreResult, MemoryGetInput,
MemoryGetResult, MemoryPutInput, MemoryPutResult, RevokeResult,
};

#[async_trait]
Expand All @@ -42,6 +43,10 @@ pub trait Backend: Send + Sync {

async fn memory_get(&self, input: MemoryGetInput) -> Result<MemoryGetResult, BackendError>;

async fn cred_store(&self, input: CredStoreInput) -> Result<CredStoreResult, BackendError>;

async fn cred_fetch(&self, input: CredFetchInput) -> Result<CredFetchResult, BackendError>;

async fn audit_append(
&self,
input: AuditAppendInput,
Expand Down Expand Up @@ -81,6 +86,14 @@ impl Backend for BackendClient {
self.memory_get(input).await
}

async fn cred_store(&self, input: CredStoreInput) -> Result<CredStoreResult, BackendError> {
self.cred_store(input).await
}

async fn cred_fetch(&self, input: CredFetchInput) -> Result<CredFetchResult, BackendError> {
self.cred_fetch(input).await
}

async fn audit_append(
&self,
input: AuditAppendInput,
Expand Down
Loading