Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
f790dd1
feat: #216 agent cred-fetch — CLI consumer + real e2e (VERIFIED again…
hanwencheng Jun 6, 2026
5b8ebbd
feat: #216 cred-wire-demo.sh — the FULL agent-side wire e2e (VERIFIED…
hanwencheng Jun 6, 2026
c7e3922
feat: #216 phase1-wire Phase 4.0b — plant the VAULT-fetched key (env …
hanwencheng Jun 6, 2026
346ef5e
feat: #216 `agentkeys cred store` — symmetric store half + #204 daemo…
hanwencheng Jun 6, 2026
7978716
docs: #216 make operator-runbook-wire.md the single source of truth (…
hanwencheng Jun 7, 2026
a8bf1bd
docs: #216 fix Path A — the web app doesn't provision the agent device
hanwencheng Jun 7, 2026
0356793
docs+harness: #216 make wire runbook Path A / Path B fully independen…
hanwencheng Jun 7, 2026
e7ea8cf
docs: #216 fix Path A pairing command — --request-pairing requires --…
hanwencheng Jun 7, 2026
bc9cbd9
feat: #216 default the agent pairing broker to prod (no --broker-url …
hanwencheng Jun 7, 2026
c02e782
fix: #214 web pairing register 502 — daemon couldn't find heima-agent…
hanwencheng Jun 7, 2026
d2a7828
Merge branch 'main' of https://github.com/litentry/agentKeys into cla…
hanwencheng Jun 7, 2026
7c35770
style: rustfmt the merged ui_bridge.rs (register path-fix block)
hanwencheng Jun 7, 2026
39c78ca
feat: #224 pairing-card cross-verification — show device_key_hash + f…
hanwencheng Jun 7, 2026
1b3f903
ui: #224 relabel pairing card D_pub → 'device public address · verify…
hanwencheng Jun 7, 2026
a986ac1
ui: refresh paired-device list after accept so it shows without a man…
hanwencheng Jun 7, 2026
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions apps/parent-control/app/_components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,11 @@ export function App() {
}
showToast(`Registered ${req.agent} on chain. Grant its scope next (Touch ID).`);
await refreshPairing();
// The newly-registered agent now exists in the actor tree — re-fetch it so the
// paired device appears in the device/permission views immediately, without the
// operator having to reload the page (matches finishPairingCeremony's refresh).
const a = await client.listActors();
if (a.ok) setActors(a.data);
};
const declinePairing = (id: string) => {
setPairingRequests((prev) => prev.filter((r) => r.id !== id));
Expand Down
14 changes: 10 additions & 4 deletions apps/parent-control/app/_components/pairing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,18 @@ export function PairingPage({
<div className="pair-v">{req.runtime}</div>
</div>
<div>
<div className="pair-k">pair-code</div>
<div className="pair-v mono" style={{ fontSize: 16, letterSpacing: '0.1em' }}>{req.pairCode}</div>
{/* #224 — verify-on-agent: the one-time pairing code is consumed at
claim, so the operator confirms the DEVICE instead. device_key_hash
+ D_pub are both printed by the agent's `--request-pairing`; they
must match here before approving. request id is the master handle. */}
<div className="pair-k">device key hash · verify on agent</div>
<div className="pair-v mono" style={{ fontSize: 12, wordBreak: 'break-all' }}>{req.deviceKeyHash || req.deviceKeyHashShort}</div>
<div className="pair-k">device public address · verify on agent</div>
<div className="pair-v mono" style={{ fontSize: 11, wordBreak: 'break-all' }}>{req.dpubFull || req.dpub}</div>
<div className="pair-k">request id</div>
<div className="pair-v mono" style={{ fontSize: 11, wordBreak: 'break-all' }}>{req.id}</div>
<div className="pair-k">derivation</div>
<div className="pair-v mono">O_master{req.derivation}</div>
<div className="pair-k">D_pub</div>
<div className="pair-v mono" style={{ fontSize: 11 }}>{req.dpub}</div>
</div>
</div>

Expand Down
4 changes: 4 additions & 0 deletions apps/parent-control/app/_components/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ export interface PairingRequest {
runtime: string;
dpub: string;
dpubFull: string;
// #224 — the cross-verifiable device identity: the agent's `--request-pairing`
// prints `device_key_hash`, so the operator confirms it matches before approving.
deviceKeyHash: string;
deviceKeyHashShort: string;
pairCode: string;
derivation: string;
requested: RequestedPerm[];
Expand Down
40 changes: 38 additions & 2 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 Down Expand Up @@ -314,6 +315,41 @@ impl BackendClient {
})
}

/// `POST /v1/cred/store` — vault a credential (#216). The signed cap carries
/// the `service` + data-class; the per-actor STS creds (vault role) scope the
/// S3 PUT to `bots/<actor>/credentials/`. The plaintext is base64 in the body
/// (the worker encrypts with the K3 KEK).
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/audit/append/v2` — append a signed audit envelope. `ts_unix`
/// is stamped here; `intent_commitment` is always `None` on this side
/// (the broker computes it).
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,
};
30 changes: 30 additions & 0 deletions crates/agentkeys-backend-client/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,23 @@ pub struct CredFetchResp {
pub plaintext_b64: String,
}

/// Cred-worker `/v1/cred/store` request body. Mirrors
/// `agentkeys_worker_creds::handlers::StoreRequest` — the signed cap (the
/// credential `service` rides INSIDE the cap payload) plus the base64 plaintext.
/// The worker encrypts (K3 KEK) + S3-PUTs `bots/<actor>/credentials/<service>.enc`.
#[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,
}

// ── audit worker (`/v1/audit/append/v2`) ────────────────────────────────────

/// Audit envelope version, pinned to `agentkeys_core::audit::ENVELOPE_VERSION`.
Expand Down Expand Up @@ -260,6 +277,19 @@ pub struct CredFetchResult {
pub plaintext_b64: 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 AuditAppendInput {
pub operator_omni: String,
Expand Down
1 change: 1 addition & 0 deletions crates/agentkeys-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ agentkeys-core = { workspace = true }
agentkeys-memory-engine = { workspace = true }
agentkeys-memory-openviking = { workspace = true }
agentkeys-provisioner = { path = "../agentkeys-provisioner" }
agentkeys-backend-client = { workspace = true } # #216 cred-fetch primitive (agent vaulted-key)
clap = { version = "4", features = ["derive", "env"] }
tokio = { workspace = true }
serde_json = { workspace = true }
Expand Down
120 changes: 120 additions & 0 deletions crates/agentkeys-cli/src/cred_admin.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//! Agent-side credential fetch (#216) — the agent pulls its AUTHORIZED
//! credential (e.g. its LLM key) from the vault to *use* it. Unlike the master's
//! store/list (which never reveal a secret), this returns the decrypted
//! plaintext: the agent needs the actual secret to make calls. It is gated by the
//! agent's `cred:<service>` scope — the broker won't mint a cred-fetch cap the
//! actor isn't scoped for, and the worker re-checks the cap.
//!
//! Routes through the shared `agentkeys-backend-client` (issue #204): cap-mint
//! (`CredFetch`) → per-actor STS under the VAULT role → cred worker
//! `/v1/cred/fetch` → decrypt → plaintext. No re-typed wire shapes.

use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD, Engine as _};

use agentkeys_backend_client::{
normalize_omni_0x, BackendClient, CapMintOp, CapMintRequest, CredFetchInput, CredStoreInput,
};

/// Fetch + decrypt the credential `service` the actor is authorized for, returning
/// the plaintext secret. `operator_omni` == `actor_omni` for a master-self fetch;
/// for an agent they are (master, agent). The omnis are normalized to the broker's
/// `0x`-prefixed shape (issue #200 — the bare-vs-0x drift normalizer).
#[allow(clippy::too_many_arguments)]
pub async fn cred_fetch(
service: &str,
operator_omni: &str,
actor_omni: &str,
device_key_hash: &str,
session_bearer: &str,
broker_url: &str,
cred_url: &str,
vault_role_arn: &str,
region: &str,
) -> Result<String> {
let client = BackendClient::new(
Some(broker_url.to_string()),
None, // memory_url
None, // audit_url
Some(cred_url.to_string()),
Some(session_bearer.to_string()), // agent_session_bearer → per-actor STS
None, // memory_role_arn
Some(vault_role_arn.to_string()),
region.to_string(),
);
let cap = client
.cap_mint(
CapMintOp::CredFetch,
CapMintRequest {
operator_omni: normalize_omni_0x(operator_omni),
actor_omni: normalize_omni_0x(actor_omni),
service: service.to_string(),
device_key_hash: device_key_hash.to_string(),
ttl_seconds: 300,
},
session_bearer,
)
.await
.with_context(|| format!("cap-mint cred-fetch for service `{service}`"))?;
let result = client
.cred_fetch(CredFetchInput { cap })
.await
.with_context(|| format!("cred worker fetch for service `{service}`"))?;
let bytes = STANDARD
.decode(&result.plaintext_b64)
.context("decode cred plaintext_b64")?;
String::from_utf8(bytes).context("cred plaintext is not valid UTF-8")
}

/// Vault the credential `service` = `secret` (the symmetric store half of
/// [`cred_fetch`]). `operator_omni` == `actor_omni` for a master-self store (the
/// master vaulting into its OWN vault — the common case, e.g. seeding the agent's
/// LLM key). Returns the worker's S3 key. Routes through the shared
/// `agentkeys-backend-client` (#204): cap-mint (`CredStore`) → per-actor STS under
/// the VAULT role → cred worker `/v1/cred/store` → encrypt + S3 PUT.
#[allow(clippy::too_many_arguments)]
pub async fn cred_store(
service: &str,
secret: &str,
operator_omni: &str,
actor_omni: &str,
device_key_hash: &str,
session_bearer: &str,
broker_url: &str,
cred_url: &str,
vault_role_arn: &str,
region: &str,
) -> Result<String> {
let client = BackendClient::new(
Some(broker_url.to_string()),
None, // memory_url
None, // audit_url
Some(cred_url.to_string()),
Some(session_bearer.to_string()), // session bearer → per-actor STS
None, // memory_role_arn
Some(vault_role_arn.to_string()),
region.to_string(),
);
let cap = client
.cap_mint(
CapMintOp::CredStore,
CapMintRequest {
operator_omni: normalize_omni_0x(operator_omni),
actor_omni: normalize_omni_0x(actor_omni),
service: service.to_string(),
device_key_hash: device_key_hash.to_string(),
ttl_seconds: 300,
},
session_bearer,
)
.await
.with_context(|| format!("cap-mint cred-store for service `{service}`"))?;
let result = client
.cred_store(CredStoreInput {
cap,
plaintext_b64: STANDARD.encode(secret.as_bytes()),
})
.await
.with_context(|| format!("cred worker store for service `{service}`"))?;
Ok(result.s3_key)
}
1 change: 1 addition & 0 deletions crates/agentkeys-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::sync::Arc;

pub mod agent_admin;
pub mod cred_admin;
pub mod device_session;
pub mod hook;
pub mod k11;
Expand Down
Loading
Loading