Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/parent-control/app/_components/pairing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ export function PairingPage({
.map(([ns, v]) => `${ns}:${v.write ? 'rw' : 'r'}`)
.join(' · ') || 'none'}
</dd>
<dt>path policy</dt>
<dd>{(a.pathPolicy?.active ?? a.status !== 'bad') ? 'active · default deny' : 'suspended · jwt denied'}</dd>
<dt>active</dt><dd className="muted">{a.lastActive}</dd>
</dl>
</div>
Expand Down
25 changes: 25 additions & 0 deletions apps/parent-control/app/_components/permissions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,34 @@ export function PermissionList({
const vaultForActor = vaultItems.filter((v) => v.actor === actor.id);
const hasEmail = services.includes('email');
const hasPay = (actor.paymentCap?.perTx ?? 0) > 0;
const pathPolicy = actor.pathPolicy ?? (
actor.role === 'agent'
? {
active: actor.status !== 'bad',
derivationPath: actor.derivation,
scope: services.join(', ') || memGranted.join(', ') || 'paired',
defaultDeny: true,
}
: undefined
);

return (
<div className="perm-list">
{/* TEE CHILD PATH POLICY */}
{pathPolicy && (
<PermSection title="TEE child path policy" summary={pathPolicy.active ? 'active' : 'suspended'}>
<PermRow
icon="∴"
title={pathPolicy.derivationPath}
why="Security-group gate before JWT issuance. Unknown paths are denied even though HDKD can derive them."
state={`${pathPolicy.defaultDeny ? 'default deny' : 'custom default'} · scope ${pathPolicy.scope || 'none'}`}
risk="high"
granted={pathPolicy.active}
control={<span className={`perm-readonly ${pathPolicy.active ? 'on' : 'off'}`}>{pathPolicy.active ? 'jwt:on' : 'jwt:deny'}</span>}
/>
</PermSection>
)}

{/* MEMORY */}
<PermSection title="Memory access" summary={`${memGranted.length} of ${NAMESPACES.length} namespaces`}>
{NAMESPACES.map((ns) => {
Expand Down
6 changes: 6 additions & 0 deletions apps/parent-control/app/_components/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export interface Actor {
timeWindow?: { start: string; end: string; tz: string };
services?: string[];
justPaired?: boolean;
pathPolicy?: {
active: boolean;
derivationPath: string;
scope: string;
defaultDeny: boolean;
};
}

export type ChipKind =
Expand Down
17 changes: 15 additions & 2 deletions apps/parent-control/lib/client/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,32 @@
import { EmptyBackend } from './empty';
import type { ConnectionStatus } from './types';

interface LoadedCore {
capMemoryPut(bearer: string, req: unknown): Promise<unknown>;
capMemoryGet(bearer: string, req: unknown): Promise<unknown>;
pairingClaim(bearer: string, req: unknown): Promise<unknown>;
pendingBindings(bearer: string): Promise<unknown>;
ackBinding(bearer: string, requestId: string): Promise<unknown>;
}

interface WasmCoreModule {
default: (wasmUrl: string) => Promise<void>;
WebCore: new (brokerUrl: string) => LoadedCore;
}

// Lazy, client-only load of the WASM master-plane core (agentkeys-web-core),
// memoized per broker URL. The dynamic import keeps the wasm glue out of the
// server bundle; init() fetches the .wasm from /wasm/ (served from public/,
// written by dev.sh's build_wasm). Keying by URL means a second CoreBackend with
// a different broker gets its own instance; on failure the entry is evicted so
// the next call retries (a transient load/broker failure must not poison it).
type LoadedCore = import('@/lib/wasm/agentkeys-web-core/agentkeys_web_core').WebCore;
const coreByUrl = new Map<string, Promise<LoadedCore>>();
function loadCore(brokerUrl: string): Promise<LoadedCore> {
let p = coreByUrl.get(brokerUrl);
if (!p) {
p = (async () => {
const wasm = await import('@/lib/wasm/agentkeys-web-core/agentkeys_web_core.js');
const wasmGlueUrl = '/wasm/agentkeys_web_core.js';
const wasm = await import(/* webpackIgnore: true */ wasmGlueUrl) as WasmCoreModule;
await wasm.default('/wasm/agentkeys_web_core_bg.wasm');
return new wasm.WebCore(brokerUrl);
})();
Expand Down
14 changes: 14 additions & 0 deletions apps/parent-control/lib/client/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,12 @@ interface ApiActor {
payment_cap?: { per_tx: number; daily: number; currency: string };
time_window?: { start: string; end: string; tz: string };
services?: string[];
path_policy?: {
active: boolean;
derivation_path: string;
scope: string;
default_deny: boolean;
};
}

interface ApiAuditEvent {
Expand Down Expand Up @@ -403,6 +409,14 @@ function apiToActor(a: ApiActor): Actor {
: undefined,
timeWindow: a.time_window,
services: a.services,
pathPolicy: a.path_policy
? {
active: a.path_policy.active,
derivationPath: a.path_policy.derivation_path,
scope: a.path_policy.scope,
defaultDeny: a.path_policy.default_deny,
}
: undefined,
};
}

Expand Down
16 changes: 16 additions & 0 deletions crates/agentkeys-broker-server/src/handlers/agent/claim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ pub async fn pairing_claim(
// round-tripped through /request) degrades to empty rather than failing.
let device_key_hash =
agentkeys_core::device_crypto::device_key_hash(&device_pubkey).unwrap_or_default();
let derivation_path = format!("//{}", body.label);
state
.grant_store
.activate_child_path(
&master_omni,
&child_omni,
&derivation_path,
&requested_scope,
now,
)
.map_err(|e| BrokerError::Internal(format!("activate child path policy: {e}")))?;

tracing::info!(
operator_omni = %master_omni,
Expand All @@ -108,6 +119,11 @@ pub async fn pairing_claim(
"operator_omni": master_omni,
"label": body.label,
"requested_scope": requested_scope,
"path_policy": {
"derivation_path": derivation_path,
"active": true,
"default": "deny",
},
"device_pubkey": device_pubkey,
"pop_sig": pop_sig,
"device_key_hash": device_key_hash,
Expand Down
1 change: 1 addition & 0 deletions crates/agentkeys-broker-server/src/handlers/grant/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

pub mod create;
pub mod list;
pub mod path_policy;
pub mod revoke;

use axum::http::HeaderMap;
Expand Down
99 changes: 99 additions & 0 deletions crates/agentkeys-broker-server/src/handlers/grant/path_policy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//! TEE-side child derivation path policy.
//!
//! Pairing creates an explicit active row for the approved HDKD path. Paths with
//! no row are denied by default; suspend/resume flips the active bit without
//! pretending the underlying child key derivation can be destroyed.

use std::time::{SystemTime, UNIX_EPOCH};

use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Json,
};
use serde::Deserialize;
use serde_json::json;

use crate::error::BrokerError;
use crate::state::SharedState;

#[derive(Debug, Deserialize)]
pub struct PathPolicyBody {
pub derivation_path: String,
}

pub async fn path_policy_list(
State(state): State<SharedState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, BrokerError> {
let session = super::require_session_jwt(&headers, &state)?;
let master = session.agentkeys.omni_account;
let policies = state
.grant_store
.list_child_path_policies(&master)
.map_err(|e| BrokerError::Internal(format!("list child path policies: {}", e)))?;

Ok((
StatusCode::OK,
Json(json!({
"owner": master,
"default": "deny",
"policies": policies,
})),
))
}

pub async fn path_policy_suspend(
State(state): State<SharedState>,
headers: HeaderMap,
Json(body): Json<PathPolicyBody>,
) -> Result<impl IntoResponse, BrokerError> {
set_active(state, headers, body, false).await
}

pub async fn path_policy_resume(
State(state): State<SharedState>,
headers: HeaderMap,
Json(body): Json<PathPolicyBody>,
) -> Result<impl IntoResponse, BrokerError> {
set_active(state, headers, body, true).await
}

async fn set_active(
state: SharedState,
headers: HeaderMap,
body: PathPolicyBody,
active: bool,
) -> Result<impl IntoResponse, BrokerError> {
let session = super::require_session_jwt(&headers, &state)?;
let master = session.agentkeys.omni_account;
let derivation_path = body.derivation_path.trim();
if derivation_path.is_empty() {
return Err(BrokerError::BadRequest("derivation_path required".into()));
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);

let changed = state
.grant_store
.set_child_path_active(&master, derivation_path, active, now)
.map_err(|e| BrokerError::Internal(format!("set child path policy: {}", e)))?;
if !changed {
return Err(BrokerError::BadRequest(format!(
"derivation_path {:?} has no policy for this master",
derivation_path
)));
}

Ok((
StatusCode::OK,
Json(json!({
"derivation_path": derivation_path,
"active": active,
"updated_at": now,
})),
))
}
39 changes: 39 additions & 0 deletions crates/agentkeys-broker-server/src/handlers/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ pub async fn mint_oidc_jwt(
// Same on-chain check the cap-mint path uses (SidecarRegistry.getDevice).
if let Some(device_pubkey) = session_claims.agentkeys.device_pubkey.as_deref() {
use crate::handlers::cap::{call_get_device, ChainContracts, ROLE_CAP_MINT};
use crate::storage::PathPolicyCheck;
// Mirror the FULL cap-mint invariant (cap.rs verify_chain): the device must
// be active AND bound to BOTH the session's operator (parent_omni) and actor
// (omni_account), with the CAP_MINT role. Checking only actor would let any
Expand All @@ -130,6 +131,44 @@ pub async fn mint_oidc_jwt(
.parent_omni
.as_deref()
.unwrap_or("");
let derivation_path = session_claims
.agentkeys
.derivation_path
.as_deref()
.unwrap_or("");
let policy_denied = if parent_omni.is_empty() || derivation_path.is_empty() {
Some("agent session missing parent_omni or derivation_path lineage — cannot verify child path policy")
} else {
match state
.grant_store
.check_child_path_policy(parent_omni, &actor_omni, derivation_path)
.map_err(|e| BrokerError::Internal(format!("child path policy read: {e}")))?
{
PathPolicyCheck::Active => None,
PathPolicyCheck::Suspended => {
Some("child derivation path is suspended by TEE-side path policy")
}
PathPolicyCheck::Missing => {
Some("child derivation path has no active policy — default deny")
}
}
};
if let Some(reason) = policy_denied {
let _ = state.audit.record_mint(
MintRecord {
requester_token: token,
requester_wallet: &report_id,
requested_role: "oidc_jwt",
session_duration_seconds: state.config.oidc_jwt_ttl_seconds as i32,
sts_session_name: "(path-policy-deny)",
outcome: MintOutcome::AuthFailed,
},
Some(reason),
);
tracing::Span::current().record("outcome", "path_policy_deny");
return Err(BrokerError::Forbidden(reason.into()));
}

let chain = ChainContracts::from_state(&state)
.map_err(|e| BrokerError::Internal(format!("chain config for agent gate: {e:?}")))?;
let dkh = agentkeys_core::device_crypto::device_key_hash(device_pubkey).map_err(|e| {
Expand Down
12 changes: 12 additions & 0 deletions crates/agentkeys-broker-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ pub fn create_router(state: SharedState) -> Router {
post(handlers::grant::revoke::grant_revoke),
)
.route("/v1/grant/list", get(handlers::grant::list::grant_list))
.route(
"/v1/path-policy/list",
get(handlers::grant::path_policy::path_policy_list),
)
.route(
"/v1/path-policy/suspend",
post(handlers::grant::path_policy::path_policy_suspend),
)
.route(
"/v1/path-policy/resume",
post(handlers::grant::path_policy::path_policy_resume),
)
// Phase B wallet endpoints (US-028).
.route("/v1/wallet/link", post(handlers::wallet::link::wallet_link))
.route(
Expand Down
Loading
Loading