diff --git a/.github/workflows/registry-proxy-deploy.yml b/.github/workflows/registry-proxy-deploy.yml index 6dc872a4..42dd2b7c 100644 --- a/.github/workflows/registry-proxy-deploy.yml +++ b/.github/workflows/registry-proxy-deploy.yml @@ -17,11 +17,11 @@ # NOT called here: that guard pins a local gcloud config + operator account, # whereas CI authenticates keylessly as the deploy SA via WIF below. # -# Required repository secrets — DEDICATED to the registry (dambi-registry), +# Required repository secrets — DEDICATED to the registry (project-c2aefc18-2bfc-495a-a3d), # DISTINCT from policy-server-deploy.yml's GCP_* secrets (your-gcp-project-id). # Shared with registry-publish.yml: -# REGISTRY_WIF_PROVIDER - full Workload Identity *provider* resource name (dambi-registry) -# REGISTRY_DEPLOY_SA - deploy SA email. Needs, on PROJECT_ID=dambi-registry: +# REGISTRY_WIF_PROVIDER - full Workload Identity *provider* resource name (project-c2aefc18-2bfc-495a-a3d) +# REGISTRY_DEPLOY_SA - deploy SA email. Needs, on PROJECT_ID=project-c2aefc18-2bfc-495a-a3d: # roles/run.admin, roles/cloudbuild.builds.editor, # roles/artifactregistry.writer, and # roles/iam.serviceAccountUser on the runtime SA @@ -46,7 +46,7 @@ jobs: # avoids GCS entirely (docker push, see below), but `gcloud run deploy` still # needs a billing project for its API calls — gcloud's modern clients read this # and attach x-goog-user-project (SA holds serviceUsageConsumer there). - CLOUDSDK_BILLING_QUOTA_PROJECT: dambi-registry + CLOUDSDK_BILLING_QUOTA_PROJECT: project-c2aefc18-2bfc-495a-a3d steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/registry-publish.yml b/.github/workflows/registry-publish.yml index db9f1432..f0aaaceb 100644 --- a/.github/workflows/registry-publish.yml +++ b/.github/workflows/registry-publish.yml @@ -8,12 +8,12 @@ # # Signing runs in KMS mode under keyless Workload Identity Federation — the # private key never leaves the HSM, and no SA key file ever touches the runner. -# One-time WIF setup. These are DEDICATED registry secrets (dambi-registry), +# One-time WIF setup. These are DEDICATED registry secrets (project-c2aefc18-2bfc-495a-a3d), # DISTINCT from policy-server-deploy.yml's GCP_* secrets (your-gcp-project-id) — # the two target different projects/SAs, so they cannot share secret names. # # Required repository secrets: -# REGISTRY_WIF_PROVIDER - full Workload Identity *provider* resource name (dambi-registry) +# REGISTRY_WIF_PROVIDER - full Workload Identity *provider* resource name (project-c2aefc18-2bfc-495a-a3d) # REGISTRY_DEPLOY_SA - signer/publisher SA email (needs roles/cloudkms.signerVerifier # on the key + roles/storage.objectAdmin on the bucket) # @@ -41,9 +41,9 @@ on: # CHANGE THESE for your own project. # ---------------------------------------------------------------------------- env: - PROJECT_ID: dambi-registry - REGION: asia-northeast3 - BUCKET: dambi-registry-v3-seoul + PROJECT_ID: project-c2aefc18-2bfc-495a-a3d + REGION: asia-northeast1 + BUCKET: dambi-registry-v3-unseo KMS_KEYRING: registry-signing KMS_KEY: bundle-sign-p256 KMS_KEY_VERSION: "1" @@ -53,7 +53,7 @@ env: # project (gcloud reads this on every call) makes it attach x-goog-user-project # to the storage rsync below. The local owner publish never hits this: user creds # carry an implicit quota project. - CLOUDSDK_BILLING_QUOTA_PROJECT: dambi-registry + CLOUDSDK_BILLING_QUOTA_PROJECT: project-c2aefc18-2bfc-495a-a3d permissions: contents: read diff --git a/.gitignore b/.gitignore index a1472821..87ed93a4 100644 --- a/.gitignore +++ b/.gitignore @@ -115,6 +115,9 @@ AGENTS.md !docs/security-invariants.md !docs/sdk-migration/ !docs/sdk-migration/** +# Decision log — one-line ADRs so a settled call isn't re-litigated later. +!docs/decisions/ +!docs/decisions/** # Published package changelog must ship with the package and the repo. !packages/core/CHANGELOG.md diff --git a/crates/policy-server/db/migrations/0013_api_keys_audit_events.sql b/crates/policy-server/db/migrations/0013_api_keys_audit_events.sql new file mode 100644 index 00000000..1479e541 --- /dev/null +++ b/crates/policy-server/db/migrations/0013_api_keys_audit_events.sql @@ -0,0 +1,38 @@ +-- Policy Hub: host API keys + client-reported audit events (POST /v1/audit). +-- +-- v0.1 has no tenant/organization above a key (ADR 0001, 2026-09-12): one +-- key = one integrating host app. Adding a tenant later is an additive column +-- on api_keys, not a contract change. +-- +-- api_keys stores only the SHA-256 of the presented key; the plaintext is +-- printed once by the `issue_api_key` binary and never persisted. +-- +-- audit_events is an event log of what the CLIENT decided — the server never +-- re-runs Cedar. There is deliberately no wallet-address column (and the +-- handler rejects unknown fields), so a raw address cannot reach this table. +-- Dedup key is (api_key_id, event_id): event ids are client-generated, so two +-- hosts may legitimately collide on the same id. + +CREATE TABLE IF NOT EXISTS api_keys ( + id UUID PRIMARY KEY, + key_hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + created_at BIGINT NOT NULL, + revoked_at BIGINT +); + +CREATE TABLE IF NOT EXISTS audit_events ( + api_key_id UUID NOT NULL REFERENCES api_keys(id), + event_id TEXT NOT NULL, + request_digest TEXT NOT NULL, + verdict TEXT NOT NULL, + policy_version TEXT NOT NULL, + engine_version TEXT NOT NULL, + submitted_at BIGINT, + received_at BIGINT NOT NULL, + PRIMARY KEY (api_key_id, event_id), + CHECK (verdict IN ('allow', 'warn', 'deny')) +); + +CREATE INDEX IF NOT EXISTS idx_audit_events_received_at + ON audit_events(received_at); diff --git a/crates/policy-server/db/src/lib.rs b/crates/policy-server/db/src/lib.rs index 06d42e67..33d1b35a 100644 --- a/crates/policy-server/db/src/lib.rs +++ b/crates/policy-server/db/src/lib.rs @@ -24,6 +24,6 @@ pub mod stores; pub use error::{DbError, DbResult}; pub use stores::{ - derive_user_id, market, GlobalDb, MultiUserStore, PostgresWalletMetadata, PostgresWalletStore, - TokenPriceFact, User, + audit, derive_user_id, market, GlobalDb, MultiUserStore, PostgresWalletMetadata, + PostgresWalletStore, TokenPriceFact, User, }; diff --git a/crates/policy-server/db/src/stores/audit.rs b/crates/policy-server/db/src/stores/audit.rs new file mode 100644 index 00000000..dc5855fb --- /dev/null +++ b/crates/policy-server/db/src/stores/audit.rs @@ -0,0 +1,146 @@ +//! Policy Hub API keys and client-reported audit events (`POST /v1/audit`). +//! +//! Keys are stored hashed (SHA-256 hex, computed by the server crate); this +//! module never sees plaintext. Audit rows are an append-only event log keyed +//! by `(api_key_id, event_id)` — a replay of the same event id from the same +//! key is not an error, it is simply not recorded twice. + +use sqlx_core::query::query; +use sqlx_core::row::Row; +use sqlx_postgres::PgPool; +use uuid::Uuid; + +use crate::error::{DbError, DbResult}; + +/// One issued API key (never the plaintext). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ApiKeyRow { + /// Stable key id (what `issue_api_key --revoke` takes). + pub id: Uuid, + /// Operator-chosen label, e.g. `browser-extension`. + pub label: String, + /// Unix seconds. + pub created_at: i64, + /// Unix seconds once revoked; `None` while active. + pub revoked_at: Option, +} + +/// Insert a new key record. `key_hash` is the SHA-256 hex of the plaintext. +/// +/// # Errors +/// +/// Returns [`DbError`] if the label is blank or the insert fails (including a +/// duplicate hash). +pub async fn create_api_key( + pool: &PgPool, + key_hash: &str, + label: &str, + now: i64, +) -> DbResult { + let label = label.trim(); + if label.is_empty() { + return Err(DbError::Invariant("api key label is required".to_owned())); + } + let id = Uuid::new_v4(); + query( + "INSERT INTO api_keys (id, key_hash, label, created_at) + VALUES ($1, $2, $3, $4)", + ) + .bind(id) + .bind(key_hash) + .bind(label) + .bind(now) + .execute(pool) + .await + .map_err(|e| DbError::Invariant(e.to_string()))?; + Ok(id) +} + +/// Look up a non-revoked key by hash. `None` covers unknown and revoked alike +/// so the caller cannot distinguish them (and neither can an attacker). +/// +/// # Errors +/// +/// Returns [`DbError`] if the query fails. +pub async fn find_active_api_key(pool: &PgPool, key_hash: &str) -> DbResult> { + let row = query( + "SELECT id, label, created_at, revoked_at + FROM api_keys + WHERE key_hash = $1 AND revoked_at IS NULL", + ) + .bind(key_hash) + .fetch_optional(pool) + .await + .map_err(|e| DbError::Invariant(e.to_string()))?; + Ok(row.map(|r| ApiKeyRow { + id: r.get("id"), + label: r.get("label"), + created_at: r.get("created_at"), + revoked_at: r.get("revoked_at"), + })) +} + +/// Mark a key revoked. Returns `false` when it was unknown or already revoked. +/// +/// # Errors +/// +/// Returns [`DbError`] if the update fails. +pub async fn revoke_api_key(pool: &PgPool, id: Uuid, now: i64) -> DbResult { + let res = query("UPDATE api_keys SET revoked_at = $2 WHERE id = $1 AND revoked_at IS NULL") + .bind(id) + .bind(now) + .execute(pool) + .await + .map_err(|e| DbError::Invariant(e.to_string()))?; + Ok(res.rows_affected() > 0) +} + +/// One audit event as reported by a host. Field semantics: registry-api/openapi.yaml `AuditEvent`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NewAuditEvent<'a> { + /// Key the host authenticated with; half of the dedup key. + pub api_key_id: Uuid, + /// Client-generated id; other half of the dedup key. + pub event_id: &'a str, + /// `0x` + 64 lowercase hex — a hash of the request, never its content. + pub request_digest: &'a str, + /// `allow` | `warn` | `deny` (validated by the handler; CHECK-enforced). + pub verdict: &'a str, + /// Bundle `sequence` the verdict was computed against. + pub policy_version: &'a str, + /// dambi-core / engine version string. + pub engine_version: &'a str, + /// Client clock, unix seconds, optional. + pub submitted_at: Option, + /// Server clock, unix seconds. + pub received_at: i64, +} + +/// Append one event. Returns `true` if it was newly recorded, `false` if the +/// same `(api_key_id, event_id)` already existed (idempotent replay). +/// +/// # Errors +/// +/// Returns [`DbError`] if the insert fails (e.g. the key row is gone or the +/// verdict violates the table CHECK). +pub async fn insert_audit_event(pool: &PgPool, ev: &NewAuditEvent<'_>) -> DbResult { + let res = query( + "INSERT INTO audit_events + (api_key_id, event_id, request_digest, verdict, policy_version, engine_version, + submitted_at, received_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (api_key_id, event_id) DO NOTHING", + ) + .bind(ev.api_key_id) + .bind(ev.event_id) + .bind(ev.request_digest) + .bind(ev.verdict) + .bind(ev.policy_version) + .bind(ev.engine_version) + .bind(ev.submitted_at) + .bind(ev.received_at) + .execute(pool) + .await + .map_err(|e| DbError::Invariant(e.to_string()))?; + Ok(res.rows_affected() > 0) +} diff --git a/crates/policy-server/db/src/stores/mod.rs b/crates/policy-server/db/src/stores/mod.rs index dcaa8b16..2a38c126 100644 --- a/crates/policy-server/db/src/stores/mod.rs +++ b/crates/policy-server/db/src/stores/mod.rs @@ -1,5 +1,6 @@ //! PostgreSQL-backed stores used by the policy server. +pub mod audit; pub mod market; pub mod postgres; diff --git a/crates/policy-server/server/openapi.yaml b/crates/policy-server/server/openapi.yaml index 97f16597..6a993746 100644 --- a/crates/policy-server/server/openapi.yaml +++ b/crates/policy-server/server/openapi.yaml @@ -23,6 +23,8 @@ servers: description: Local dev tags: + - name: audit + description: Host-reported verdicts (API key, not user JWT) - name: auth description: Google OAuth + JWT lifecycle - name: wallets @@ -800,17 +802,58 @@ paths: get: tags: [meta] summary: Server-Sent Events feed (wallet sync and tx lifecycle) - description: Requires `Authorization: Bearer `. Native EventSource cannot set headers; clients must use a header-capable SSE/fetch stream. + description: "Requires `Authorization: Bearer `. Native EventSource cannot set headers; clients must use a header-capable SSE/fetch stream." responses: "200": description: SSE stream + /v1/audit: + post: + tags: [audit] + summary: Record a client-computed judgment (deduplicated per key + event id) + description: | + The integrating host reports the verdict its local dambi-core computed. + The server never re-runs Cedar. Authenticates with `X-Api-Key` + (issued by the `issue_api_key` binary; one key = one host app, no + tenant grouping in v0.1 — ADR 0001). Unknown fields are rejected: + no wallet address, calldata or signature can reach this endpoint. + Replaying the same `event_id` under the same key returns 202 with + `recorded: false`. + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AuditEvent" } + responses: + "202": + description: Accepted (recorded, or already recorded) + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + event_id: { type: string } + recorded: { type: boolean, description: false when this (key, event_id) was already stored } + "400": + description: Malformed event (bad digest/verdict/id, unknown field, invalid JSON) + "401": + description: Missing, malformed, unknown or revoked API key + "503": + description: Key or audit store unavailable + components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT + apiKeyAuth: + type: apiKey + in: header + name: X-Api-Key parameters: AddressParam: @@ -821,6 +864,17 @@ components: example: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" schemas: + AuditEvent: + type: object + additionalProperties: false + required: [event_id, request_digest, verdict, policy_version, engine_version] + properties: + event_id: { type: string, maxLength: 128, pattern: "^[A-Za-z0-9._:-]+$", description: Client-generated dedup key. } + request_digest: { type: string, pattern: "^0x[0-9a-f]{64}$", description: Hash of the evaluated request — never the raw calldata or signature payload. } + verdict: { type: string, enum: [allow, warn, deny] } + policy_version: { type: string, maxLength: 64, description: Bundle `sequence` the verdict was computed against. } + engine_version: { type: string, maxLength: 64 } + submitted_at: { type: integer, minimum: 0, description: Unix seconds, client clock. } CreateMarketReportRequest: type: object required: [reason] diff --git a/crates/policy-server/server/src/app.rs b/crates/policy-server/server/src/app.rs index 78cee293..a4af7620 100644 --- a/crates/policy-server/server/src/app.rs +++ b/crates/policy-server/server/src/app.rs @@ -7,7 +7,7 @@ use axum::extract::{FromRef, Request, State}; use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode}; -use axum::middleware::{from_fn, Next}; +use axum::middleware::{from_fn, from_fn_with_state, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, patch, post}; use axum::{Extension, Json, Router}; @@ -27,6 +27,8 @@ use policy_sync::sources::fetchers::rpc::multicall::{ }; use policy_sync::{CoinGeckoClient, EtherscanClient, Orchestrator}; +use crate::audit_handlers; +use crate::auth::api_key::require_api_key; use crate::auth::{require_auth, AuthUser}; use crate::capabilities_handlers; use crate::config::ServerConfig; @@ -294,8 +296,19 @@ pub fn build_router_with_config(state: AppState, config: &ServerConfig) -> Route .route("/auth/google/callback", get(crate::auth::google_callback)) .route("/auth/refresh", post(crate::auth::refresh_token)); + // Host-facing routes authenticate with an API key (X-Api-Key), not a user + // JWT — see auth::api_key. Kept as its own group so the two identity kinds + // never share a middleware stack. `route_layer`, not `layer`: the latter + // also wraps this router's fallback, and after `merge` that fallback won — + // every unknown path then answered 401 instead of 404 + // (tests/local_only_policy_verdict_routes.rs caught it). + let host_api = Router::new() + .route("/v1/audit", post(audit_handlers::record_audit_event)) + .route_layer(from_fn_with_state(state.clone(), require_api_key)); + public .merge(protected) + .merge(host_api) .layer(TraceLayer::new_for_http().make_span_with(sanitized_trace_span)) .layer(RequestBodyLimitLayer::new(config.http_body_limit_bytes)) .layer(cors_layer(config)) @@ -332,7 +345,11 @@ fn cors_layer(config: &ServerConfig) -> CorsLayer { Method::DELETE, Method::OPTIONS, ]) - .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) + .allow_headers([ + header::AUTHORIZATION, + header::CONTENT_TYPE, + HeaderName::from_static(crate::auth::api_key::API_KEY_HEADER), + ]) .allow_private_network(config.allow_private_network) } diff --git a/crates/policy-server/server/src/audit_handlers.rs b/crates/policy-server/server/src/audit_handlers.rs new file mode 100644 index 00000000..9eef08ec --- /dev/null +++ b/crates/policy-server/server/src/audit_handlers.rs @@ -0,0 +1,185 @@ +//! `POST /v1/audit` — a host reports the verdict it computed locally. +//! +//! The server never re-runs Cedar; this is the client's own record. The body +//! is parsed with `deny_unknown_fields` so no field the contract doesn't name +//! (a wallet address, raw calldata, a signature) can be smuggled in. Same +//! `(key, event_id)` twice is a 202 with `recorded: false`, not an error. +//! Contract: registry-api/openapi.yaml `AuditEvent` (documented there for the +//! cross-service view; served here on Policy Hub per ADR 0001). + +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::extract::rejection::JsonRejection; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::Deserialize; +use serde_json::json; + +use policy_db::audit::{insert_audit_event, NewAuditEvent}; + +use crate::app::AppState; +use crate::auth::api_key::ApiKeyIdentity; + +const MAX_ID_LEN: usize = 128; +const MAX_VERSION_LEN: usize = 64; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuditEventReq { + pub event_id: String, + pub request_digest: String, + pub verdict: String, + pub policy_version: String, + pub engine_version: String, + #[serde(default)] + pub submitted_at: Option, +} + +/// Pure validation, kept separate so it is unit-testable without a DB. +pub fn validate(req: &AuditEventReq) -> Result<(), &'static str> { + if req.event_id.is_empty() || req.event_id.len() > MAX_ID_LEN { + return Err("event_id must be 1..=128 characters"); + } + if !req + .event_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.')) + { + return Err("event_id may contain only [A-Za-z0-9-_:.]"); + } + let d = &req.request_digest; + if d.len() != 66 + || !d.starts_with("0x") + || !d[2..] + .chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) + { + return Err("request_digest must be 0x + 64 lowercase hex characters"); + } + if !matches!(req.verdict.as_str(), "allow" | "warn" | "deny") { + return Err("verdict must be one of allow|warn|deny"); + } + for (name, v) in [ + ("policy_version", &req.policy_version), + ("engine_version", &req.engine_version), + ] { + if v.is_empty() || v.len() > MAX_VERSION_LEN || v.chars().any(char::is_whitespace) { + return Err(match name { + "policy_version" => "policy_version must be 1..=64 non-whitespace characters", + _ => "engine_version must be 1..=64 non-whitespace characters", + }); + } + } + if matches!(req.submitted_at, Some(t) if t < 0) { + return Err("submitted_at must be a non-negative unix timestamp"); + } + Ok(()) +} + +pub async fn record_audit_event( + State(state): State, + Extension(key): Extension, + body: Result, JsonRejection>, +) -> Response { + let Json(req) = match body { + Ok(b) => b, + Err(rej) => return bad_request(&rej.body_text()), + }; + if let Err(reason) = validate(&req) { + return bad_request(reason); + } + let ev = NewAuditEvent { + api_key_id: key.key_id, + event_id: &req.event_id, + request_digest: &req.request_digest, + verdict: &req.verdict, + policy_version: &req.policy_version, + engine_version: &req.engine_version, + submitted_at: req.submitted_at, + received_at: unix_now(), + }; + match insert_audit_event(state.global_db.pool(), &ev).await { + Ok(recorded) => ( + StatusCode::ACCEPTED, + Json(json!({ "ok": true, "event_id": req.event_id, "recorded": recorded })), + ) + .into_response(), + Err(e) => { + tracing::error!(error = %e, key = %key.label, "audit event insert failed"); + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "unavailable", "reason": "audit store unavailable" })), + ) + .into_response() + } + } +} + +fn bad_request(reason: &str) -> Response { + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "bad_request", "reason": reason })), + ) + .into_response() +} + +fn unix_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ok_req() -> AuditEventReq { + AuditEventReq { + event_id: "evt-1".into(), + request_digest: format!("0x{}", "ab".repeat(32)), + verdict: "warn".into(), + policy_version: "1".into(), + engine_version: "0.0.1".into(), + submitted_at: Some(1_757_203_200), + } + } + + #[test] + fn accepts_a_well_formed_event() { + assert_eq!(validate(&ok_req()), Ok(())); + } + + #[test] + fn rejects_bad_digest_verdict_and_ids() { + let mut r = ok_req(); + r.request_digest = "0xABCD".into(); + assert!(validate(&r).is_err()); + let mut r = ok_req(); + r.request_digest = format!("0x{}", "AB".repeat(32)); + assert!(validate(&r).is_err(), "uppercase hex rejected"); + let mut r = ok_req(); + r.verdict = "block".into(); + assert!(validate(&r).is_err()); + let mut r = ok_req(); + r.event_id = "has space".into(); + assert!(validate(&r).is_err()); + let mut r = ok_req(); + r.event_id = "x".repeat(129); + assert!(validate(&r).is_err()); + let mut r = ok_req(); + r.submitted_at = Some(-1); + assert!(validate(&r).is_err()); + } + + #[test] + fn unknown_fields_are_rejected_at_parse_time() { + let raw = json!({ + "event_id": "e", "request_digest": format!("0x{}", "00".repeat(32)), + "verdict": "allow", "policy_version": "1", "engine_version": "1", + "wallet_address": "0x0000000000000000000000000000000000000001" + }); + assert!(serde_json::from_value::(raw).is_err()); + } +} diff --git a/crates/policy-server/server/src/auth/api_key.rs b/crates/policy-server/server/src/auth/api_key.rs new file mode 100644 index 00000000..973c3072 --- /dev/null +++ b/crates/policy-server/server/src/auth/api_key.rs @@ -0,0 +1,117 @@ +//! API-key auth for host-facing routes (`POST /v1/audit`). +//! +//! Separate from the JWT middleware on purpose: JWTs identify a dashboard +//! *user*, API keys identify an integrating *host app* (the extension, a Snap, +//! a wallet vendor's backend). Keys travel in `X-Api-Key`, never in the URL. +//! The middleware hashes the presented key and looks it up; it never logs or +//! stores the plaintext. +//! +//! v0.1: one key = one host, no tenant grouping (ADR 0001, 2026-09-12). + +use axum::extract::{Request, State}; +use axum::http::StatusCode; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::app::AppState; + +/// Request header carrying the key. +pub const API_KEY_HEADER: &str = "x-api-key"; +/// Fixed prefix so a leaked key is recognisable by secret scanners. +pub const API_KEY_PREFIX: &str = "dambi_ak_"; +/// Prefix + two 128-bit UUIDs as hex. +const API_KEY_LEN: usize = API_KEY_PREFIX.len() + 64; + +/// Identity attached to the request once a key is accepted. +#[derive(Clone, Debug)] +pub struct ApiKeyIdentity { + pub key_id: Uuid, + pub label: String, +} + +/// SHA-256 hex of the plaintext key — the only form that is ever stored. +#[must_use] +pub fn hash_api_key(key: &str) -> String { + hex::encode(Sha256::digest(key.as_bytes())) +} + +/// Mint a fresh 256-bit key with the recognisable prefix. +#[must_use] +pub fn generate_api_key() -> String { + format!( + "{API_KEY_PREFIX}{}{}", + Uuid::new_v4().simple(), + Uuid::new_v4().simple() + ) +} + +/// `axum::middleware::from_fn_with_state(state, require_api_key)`. +pub async fn require_api_key( + State(state): State, + mut req: Request, + next: Next, +) -> Response { + let Some(raw) = req.headers().get(API_KEY_HEADER) else { + return reject("missing X-Api-Key header"); + }; + let key = match raw.to_str() { + Ok(s) if s.len() == API_KEY_LEN && s.starts_with(API_KEY_PREFIX) => s.to_owned(), + _ => return reject("malformed API key"), + }; + let hash = hash_api_key(&key); + match policy_db::audit::find_active_api_key(state.global_db.pool(), &hash).await { + Ok(Some(row)) => { + req.extensions_mut().insert(ApiKeyIdentity { + key_id: row.id, + label: row.label, + }); + next.run(req).await + } + Ok(None) => reject("unknown or revoked API key"), + Err(e) => { + tracing::error!(error = %e, "api key lookup failed"); + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "unavailable", "reason": "api key store unavailable" })), + ) + .into_response() + } + } +} + +fn reject(reason: &str) -> Response { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "unauthorized", "reason": reason })), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_keys_have_fixed_shape_and_are_unique() { + let a = generate_api_key(); + let b = generate_api_key(); + assert_eq!(a.len(), API_KEY_LEN); + assert!(a.starts_with(API_KEY_PREFIX)); + assert!(a[API_KEY_PREFIX.len()..] + .chars() + .all(|c| c.is_ascii_hexdigit())); + assert_ne!(a, b); + } + + #[test] + fn hash_is_stable_hex_sha256() { + let h = hash_api_key("dambi_ak_test"); + assert_eq!(h.len(), 64); + assert_eq!(h, hash_api_key("dambi_ak_test")); + assert_ne!(h, hash_api_key("dambi_ak_tesT")); + } +} diff --git a/crates/policy-server/server/src/auth/mod.rs b/crates/policy-server/server/src/auth/mod.rs index 651639ea..ba6fb21c 100644 --- a/crates/policy-server/server/src/auth/mod.rs +++ b/crates/policy-server/server/src/auth/mod.rs @@ -5,6 +5,7 @@ //! - [`oauth`]: Google OAuth 2.0 callback that maps a Google account to a user. //! - [`middleware`]: axum extractor for bearer tokens. +pub mod api_key; pub mod jwt; pub mod middleware; pub mod oauth; diff --git a/crates/policy-server/server/src/bin/issue_api_key.rs b/crates/policy-server/server/src/bin/issue_api_key.rs new file mode 100644 index 00000000..0e9bb5fa --- /dev/null +++ b/crates/policy-server/server/src/bin/issue_api_key.rs @@ -0,0 +1,70 @@ +//! `issue_api_key` — mint or revoke a Policy Hub API key (for `POST /v1/audit`). +//! +//! Stores only the SHA-256 of the key and prints the plaintext ONCE on stdout. +//! Reads DATABASE_URL like the server. Does not run migrations. +//! +//! ```text +//! cargo run -p policy-server --bin issue_api_key -- --label browser-extension +//! cargo run -p policy-server --bin issue_api_key -- --revoke +//! ``` + +use std::time::{SystemTime, UNIX_EPOCH}; + +use policy_server::auth::api_key::{generate_api_key, hash_api_key}; +use policy_server::config::ServerConfig; +use policy_server::storage::StorageBackend; +use uuid::Uuid; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let _ = dotenvy::dotenv(); + let args: Vec = std::env::args().skip(1).collect(); + let (label, revoke) = parse_args(&args)?; + + let config = ServerConfig::from_env(); + let storage = StorageBackend::open_with_options(&config, false).await?; + let db = storage.global_db(); + let now = i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())?; + + if let Some(id) = revoke { + let done = policy_db::audit::revoke_api_key(db.pool(), id, now).await?; + eprintln!( + "[issue_api_key] {} {id}", + if done { + "revoked" + } else { + "not found or already revoked:" + } + ); + return Ok(()); + } + + let label = label.ok_or("--label is required (or --revoke )")?; + let key = generate_api_key(); + let id = policy_db::audit::create_api_key(db.pool(), &hash_api_key(&key), &label, now).await?; + eprintln!("[issue_api_key] created key id={id} label={label}"); + eprintln!("[issue_api_key] plaintext below is shown ONCE and is not stored:"); + println!("{key}"); + Ok(()) +} + +fn parse_args(args: &[String]) -> Result<(Option, Option), String> { + let mut label = None; + let mut revoke = None; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--label" => { + label = Some(args.get(i + 1).cloned().ok_or("--label needs a value")?); + i += 2; + } + "--revoke" => { + let raw = args.get(i + 1).ok_or("--revoke needs a key uuid")?; + revoke = Some(Uuid::parse_str(raw).map_err(|e| format!("bad uuid: {e}"))?); + i += 2; + } + other => return Err(format!("unknown argument: {other}")), + } + } + Ok((label, revoke)) +} diff --git a/crates/policy-server/server/src/lib.rs b/crates/policy-server/server/src/lib.rs index 71e546cb..5529cad9 100644 --- a/crates/policy-server/server/src/lib.rs +++ b/crates/policy-server/server/src/lib.rs @@ -35,6 +35,7 @@ #![allow(clippy::result_large_err)] pub mod app; +pub mod audit_handlers; pub mod auth; pub mod capabilities_handlers; pub mod config; diff --git a/docs/decisions/0001-cloud-split.md b/docs/decisions/0001-cloud-split.md new file mode 100644 index 00000000..dd69508c --- /dev/null +++ b/docs/decisions/0001-cloud-split.md @@ -0,0 +1,256 @@ +# 0001 — dambi Cloud splits into Policy Hub and Registry Sync; fact computation is host-owned + +Date: 2026-09-07 +Status: decided + +## Context + +The current `policy-server` (Rust/axum) does four unrelated things in one +binary: policy/package distribution (`market_*`), wallet auth, evaluate-time +fact enrichment (`evaluate_handler`, `lending_hf.rs` — allowance, health +factor, price, sanctions screening via host-configured RPC envs), and wallet +portfolio sync (`policy_sync::{Orchestrator, RpcRouter}` — holdings, +approvals, positions). Its own `Cargo.toml` description already says +"Cedar evaluation... live[s] in the browser extension" — server-side judgment +was already understood to be going away. + +`registry-api` + `registryV2` are a second, already-separate service that +signs and serves decoder/selector data to the extension. + +## Decision + +1. **dambi-core (SDK) judges locally.** `plan()` and `evaluate()` run + client-side against the WASM Cedar engine. No server round-trip for a + verdict. +2. **The cloud surface splits in two, both under "dambi Cloud":** + - **Policy Hub** — evolves from `policy-server`'s `market_*` subsystem + (listings, versions, installs, publishers, DB, auth). This is also + where `POST /v1/audit` and API-key issuance live — it already has the + DB and auth `policy-server`'s other pieces don't. + - **Registry Sync** — `registryV2` + `registry-api` (unchanged + ownership). Serves `GET /v1/bundle` and + `GET /v1/registry/selectors`. Will ingest GIWA on-chain EAS + attestations (not built yet — 0 lines as of 2026-09-07). +3. **Chain RPC is host-provided in production; dambi runs a default for the + demo.** Each integrator configures their own RPC/provider key for the + `FactProvider` port in a real deployment — `policy-server`'s + `evaluate_handler` / `lending_hf.rs` / wallet-sync code is **not** a + dambi Cloud service, it ships as a documented reference `FactProvider` + implementation an integrator adapts and points at their own RPC. **But** + the initial demo ships on testnet by default, and a demo that requires + the visitor to bring their own RPC key first has no visitor — so dambi + runs one small testnet-only default instance (a testnet Alchemy/Infura + key, negligible cost) purely to make the out-of-the-box demo path work. + This default is explicitly not the production contract: it's scoped to + testnet, is not what an integrating host is expected to depend on, and + doesn't reopen a shared mainnet facts service. (2026-09-07, same day as + the original decision — noted here rather than as a separate ADR since + nothing shipped against the prior wording yet.) +4. **WASM ships inside `@dambi/core`**, not a separate package. The + `core-v*` tag/publish workflow publishes the engine with the SDK. + +## Consequences + +- `policy-server`'s v0.1 deploy scope is Policy Hub (market/auth/DB) **plus** + a minimal testnet-only FactProvider-reference deploy carrying one default + testnet RPC key, for the demo. It is not a production-grade shared facts + service — no mainnet key, no capacity planning, no SLA. +- The integration guide must document the reference `FactProvider` + implementation and its RPC env-var contract + (`POLICY_LENDING_RPC_URL`, `POLICY_PRICE_RPC_URL_*`, + `POLICY_SANCTIONS_RPC_URL`, …) so an integrator can stand it up themselves. +- **Risk carried forward, not resolved here:** the current WASM artifact is + 2,495,488 bytes gzip against a stated 1.5 MB budget (66% over, + `docs/sdk-migration/FINDINGS.md` F-003). Since WASM now ships inside + `@dambi/core`, this must be revisited — either the budget is revised or the + artifact needs to shrink — before the real (non-scaffold) `@dambi/core` + publishes. Not blocking for 0.0.1. +- **Open, not decided here:** the formal Policy-Hub-owns-audit-and-keys + sign-off with Track A. + +## Addendum (2026-09-07) — bundle payload schema + +Decided (see `registry-api/openapi.yaml` `BundlePayload` for the normative +shape). Note this does **not** touch `packages/core`'s types: the port's +`SignedPolicyBundle.payload` and `PolicySet` are both intentionally `unknown` +already, precisely so a decision like this can be made without reopening the +D1-D2 interface freeze. + +```json +{ + "policies": [{ "id": "...", "policy": "// cedar source", "manifest": {} }], + "sequence": 1, + "issued_at": 1757203200, + "expires_at": null, + "env": "production", + "profile": "default", + "registry_ref": null +} +``` + +- `policies[].{id,policy,manifest}` — unchanged from `policy-set-v2.json` / + `ResolvedBundle` / `seed.ts`'s existing shape. `manifest` stays opaque, the + same deferral `PolicySet` already makes to the future `policy-ir` package. +- `sequence` (not a semver string) is the single source of truth for + ordering/pinning/rollback — a rollback republishes prior content under a + new, higher `sequence`, content is never mutated in place. +- `expires_at`, `profile`, `registry_ref` are reserved fields, always + `null`/`"default"` in v0.1 (no expiry policy, no multi-profile/tenant + scoping, `registryV2` doesn't emit a root index version yet) — present so + v0.2 filling them in isn't a breaking change. +- Signing is unchanged: `canonicalize(payload)` (RFC 8785 JCS) then detached + ECDSA P-256 over SHA-256, same as `registryV2/scripts/sign-bundles.ts` + already does for decoder bundles. + +## Addendum (2026-09-07) — GET /v1/bundle parameter contract + +Decided (see `registry-api/openapi.yaml`). No live DB lookup — `GET +/v1/bundle` rewrites into the same object-proxy pattern already used for +`/v1/registry/selectors`: + +- `?profile=&version=` → `policy-bundles//.json` (content + is fixed by `sequence`, so this path is immutable — cache forever, same as + `bundles/.json` today). +- `?profile=` (no `version`) → `policy-bundles//latest.json` (a + mutable pointer — short cache, same as `index/*`). +- A publish writes the identical signed bytes to both paths at once — no + ref-materialization step to build. +- `profile` defaults to `"default"`; anything else is a real 404 in v0.1 + (no silent fallback). `version` must be a positive integer; a malformed + value and a never-issued `sequence` are both 404 — matches the rest of + registry-api never distinguishing malformed-request from not-found. +- No chain-scoping parameter — a policy's Cedar source matches its own + chain(s), it isn't selected by an external dimension the way decoders are. +- v0.1 keeps every `sequence` ever issued (no retention/GC policy) so any + previously valid `version` always resolves. + +## Addendum (2026-09-07) — signature transport location + payload strictness + +Track A's Core-side review of the bundle payload (proposed `PolicyBundlePayloadV1` +internal type + validation rules) confirmed everything already decided here +(P-256/SHA-256, JCS, base64 P1363, key trust is client-configured not +response-supplied, `registry_ref` null-only in v0.1, no signed tenant +binding) and flagged one genuinely open item plus one real schema bug: + +- **Transport location — decided: single JSON response body, `payload` as + a JCS string.** `GET /v1/bundle` returns `{payload, signature, key_id}` + as one object (not a header, not a detached `.sig` sidecar the way decoder + bundles work). `payload` is **a string** holding the RFC 8785 canonical + JSON text of the `BundlePayload` — the exact signed bytes — not an + embedded object. Chosen over an embedded object (confirmed by the user + after Track A's 5.2 proposed `SignedPayload{payloadBytes, signatureBytes, + keyId}`): the adapter becomes a byte pass-through (`utf8(payload)`), and + the SDK verifies those bytes first and only then parses them with its own + strict parser. With an embedded object the signed bytes would instead + depend on the adapter's JSON.parse + re-canonicalization, which loses + duplicate-key rejection and couples verification to adapter behaviour. + Server side, the signer emits exactly `canonicalize(bundlePayload)` into + the field. This leaves decoder bundles (sidecar `.sig`, object body) and + policy bundles (inline signature, string payload) on two conventions — + accepted for v0.1, unifying is a v0.2 item. +- **Schema bug fixed:** `expires_at` and `registry_ref` were listed as + optional in `BundlePayload`; they must be **required-but-nullable** — a + missing key is an invalid bundle, `null` is the valid v0.1 value. Also + added: `sequence` bounded to the JS safe-integer range (1..2^53-1, + matching Core's Rust-side check), `policies` rejects an empty array and + duplicate `id`s, `policies[].id` must equal `manifest.id`, an empty `{}` + manifest is invalid. See `registry-api/openapi.yaml` `BundlePayload`. +- **`registry_ref` non-null is a distinct error**, not a generic validation + failure: Core will surface it as `UNSUPPORTED_REGISTRY_REF`, separate from + "malformed bundle." +- **Policy bundles are signed with their own KMS key, separate from the + decoder-bundle key** — decided 2026-09-07. Same algorithm and pipeline + (`sign-bundles.ts`: JCS → SHA-256 → ECDSA P-256, P1363, base64), different + key. Reasons: a compromise or rotation of one does not touch the other, + and the two keys have different owners in practice (policy authors vs the + registry build). Cost: one more Cloud KMS key, and the SDK's `trustedKeys` + config pins two keys instead of one. Separating later would mean a + coordinated rotation across every installed SDK; doing it before the first + real bundle ships is nearly free. Action items: provision the key (GCP, + alongside the existing registry key), add a `BUNDLE_SIGNING_MODE=kms` + path for policy bundles in the publish script, and document both public + keys in the integration guide. +- **`maxBundleAgeSec` = 72 hours (259200 seconds)** — decided 2026-09-07. + Client-side config, not part of the API contract: effective validity = + `min(expires_at, issued_at + maxBundleAgeSec)`, so `expires_at: null` + never means "valid forever." Explicitly a v0.1 starting value, not tuned + against real publish-cadence data (none exists yet) — revisit once Policy + Hub/Registry Sync has an actual `sequence` cut frequency to check it + against. + +## Addendum (2026-09-09) — policy bundles move on-chain later, in full + +New information from the user (not in any prior planning doc): the policy +bundle contract is a **transitional off-chain form**. At an unscheduled later +date (after v0.4 as far as anything is planned) the **entire +bundle content** goes on-chain, not just a hash. This changes the weight of +several decisions above: + +| Decision above | After on-chain | +|---|---| +| Separate P-256 KMS key for policy bundles | **Replaced.** Signature becomes the attester's chain signature; SDK `trustedKeys` becomes an attester-address / schema allow-list. | +| `signature` + `key_id` fields | **Replaced** by attestation UID + attester address. | +| `payload` as a JCS string | **Survives.** The exact bytes are what gets written on-chain; fixed bytes are a prerequisite, not a casualty. | +| `sequence` monotonic | **Survives** as an attestation data field / block-order mapping. | +| `registry_ref: null` reserved | **Survives** — this is the slot for the on-chain reference. | +| `GET /v1/bundle` | **Demoted to a cache mirror** of on-chain content, not removed. | + +Consequences, effective now: + +- **Do not provision the GCP KMS policy-bundle key.** The separate-key + decision stands in principle (it costs nothing to keep the pipeline shaped + that way), but the key itself is a throwaway asset. v0.1 signs with + `sign-bundles.ts`'s `local` mode dev key; a KMS key is provisioned only if + a non-demo consumer needs off-chain bundles before the on-chain cut. +- `GET /v1/bundle` is built as decided (the serving path is reused as the + mirror), but no further off-chain-only investment goes into it — no + retention policy, no multi-profile, no bundle-level SLO. +- The `sequence` counter lives in a repo-committed file + (`registryV2/policy-bundles/default/sequence`) rather than being derived + from a bucket listing. Reason: it is reviewable in a PR, works offline, and + is the same value the on-chain attestation will later carry. +- `expires_at` stays `null` in v0.1 (72 h `maxBundleAgeSec` is the only + freshness bound). Anything that would only make sense off-chain is not + worth adding. + +Implemented the same day (working tree, `feat/registry-sync`): `GET +/v1/bundle` route + `policy-bundles/` allow-list in `registry-api`, +`registryV2/scripts/publish-policy-bundle.ts` (assemble → JCS → sign → write +`.json` + `latest.json`, bump counter), `gen-signing-key --policy` +for the separate dev key, and `policy-bundles/` added to +`publish-index.sh`'s upload order (before `index/`). + +## Addendum (2026-09-12) — audit API keys: no tenant concept in v0.1 + +Decided with the user: `POST /v1/audit` API keys are issued **per host app** +(one key = one integrator), with no tenant/organization grouping above them. +v0.1 has exactly two hosts (the extension and the demo Snap), so a tenant +table would be structure with nothing to hold. Adding one later is an +additive column on the key table, not a contract change. `profile` in the +bundle payload remains unrelated to this (not a tenant binding). + +Implemented the same day on Policy Hub (`policy-server`, working tree): +migration `0013_api_keys_audit_events.sql`, `policy_db::audit`, +`auth::api_key` (X-Api-Key middleware, SHA-256-hashed keys), `POST /v1/audit` +handler with `deny_unknown_fields`, the `issue_api_key` binary, and the +`#[ignore]`-gated Postgres integration test that CI's postgres-integration +job runs. Rate limiting/quota (backlog 19) is not part of this. + +Also recorded 2026-09-12, from live checks against the real GCP project +(`project-c2aefc18-2bfc-495a-a3d`, asia-northeast1, bucket +`dambi-registry-v3-unseo` — the repo's `dambi-registry`/`-seoul`/northeast3 +values were never real and have been corrected): the deployed decoder +registry is signed with a **local dev key** (`local-27576fda5c31`), no KMS +key exists, and bucket versioning / public-access prevention are off. User +decision: **keep the local decoder key for v0.1** rather than provisioning +KMS now; revisit at the on-chain transition. Open: which machine holds that +key file. + +## Addendum (2026-09-07) — security audit commissioning timing + +A separate, since-superseded 12-day sprint doc placed audit commissioning at +its D5 (immediately). Confirmed with the user: **commission at the 12-week +plan's D13**, after Track A's core extraction has stabilized the judgment +path — the audit's actual subject (plan/evaluate split, port boundaries, +3-state Verdict, zero network symbols in core) doesn't exist yet at D5. +Commissioning early would mean re-scoping once it does. diff --git a/registry-api/README.md b/registry-api/README.md index 3d96549a..a28937f7 100644 --- a/registry-api/README.md +++ b/registry-api/README.md @@ -21,7 +21,7 @@ media endpoint, and `canonicalize` for 3-ref materialization integrity checks. browser │ Cloud Run service: registry-api-v3 (THIS repo) │ extension │ Google Front End (GFE) + autoscaler │ (anonymous ───► │ min 1 / max 3 instances · concurrency 80 · 15s timeout│ ──► GCS bucket - HTTPS GET) │ per request: │ dambi-registry-v3-seoul + HTTPS GET) │ per request: │ dambi-registry-v3-unseo │ rate-limit → path allowlist → LRU+TTL cache → │ (PRIVATE, PAP enforced) │ single-flight → GCS read (runtime SA via ADC) │ ◄── object bytes └──────────────────────────────────────────────────────────┘ @@ -34,8 +34,8 @@ The objects in the bucket are produced by the sibling package [`../registryV2`]( which is the registry **source of truth** (it builds the index, signs every bundle, and publishes to the bucket). This proxy never writes — it is strictly read-only. -> **Self-hosting:** the canonical deployment runs in GCP project `dambi-registry` -> (`asia-northeast3`), but every resource name (bucket, service, SA, region) is +> **Self-hosting:** the canonical deployment runs in GCP project `project-c2aefc18-2bfc-495a-a3d` +> (`asia-northeast1`), but every resource name (bucket, service, SA, region) is > env-overridable through `../registryV2/scripts/deploy/_common.sh`. Nothing here is > hard-coded to one tenant. @@ -256,7 +256,7 @@ All env vars are read in `config.ts` with the defaults below. |---|---|---| | `HOST` | `0.0.0.0` | bind host | | `PORT` | `8080` | bind port | -| `REGISTRY_BUCKET` | `dambi-registry-v3-seoul` | private GCS bucket to proxy | +| `REGISTRY_BUCKET` | `dambi-registry-v3-unseo` | private GCS bucket to proxy | | `CACHE_MAX_ENTRIES` | `1024` | LRU cache capacity | | `CACHE_TTL_MS` | `300000` | positive entry TTL (5 min) | | `CACHE_NEGATIVE_TTL_MS` | `60000` | 404 entry TTL (60 s) | @@ -280,8 +280,8 @@ The live deployment shape (from `_common.sh`, verified against the running servi | Setting | Value | Why | |---|---|---| -| service | `registry-api-v3` (`asia-northeast3`) | | -| runtime SA | `registry-api-v3-sa@dambi-registry.iam.gserviceaccount.com` | read-only `storage.objectViewer` on the bucket | +| service | `registry-api-v3` (`asia-northeast1`) | | +| runtime SA | `registry-api-v3-sa@project-c2aefc18-2bfc-495a-a3d.iam.gserviceaccount.com` | read-only `storage.objectViewer` on the bucket | | CPU / memory | `1` / `256Mi` (+ startup CPU boost) | tiny JSON proxy | | min / max instances | `1` / `3` | **min 1** keeps a warm instance — the extension's JIT registry fetch has no per-fetch timeout, so a scale-to-zero cold start would blow the 8 s pre-sign budget and surface `__engine::timeout`. **max 3** is the denial-of-wallet cost ceiling. | | concurrency | `80` | requests per instance | @@ -292,7 +292,7 @@ The live deployment shape (from `_common.sh`, verified against the running servi The currently deployed image tag can lag the checked-out source until a `registry-proxy-v*` release or manual default-branch proxy deploy runs. Treat the table above as the live **runtime shape**; verify the exact image with `gcloud run services -describe registry-api-v3 --region asia-northeast3 --project dambi-registry`. +describe registry-api-v3 --region asia-northeast1 --project project-c2aefc18-2bfc-495a-a3d`. There is **no external load balancer** — the Compute Engine API isn't even enabled in the project. "Load balancing" is Cloud Run's built-in: the Google Front End terminates TLS and diff --git a/registry-api/openapi.yaml b/registry-api/openapi.yaml new file mode 100644 index 00000000..a017229a --- /dev/null +++ b/registry-api/openapi.yaml @@ -0,0 +1,375 @@ +openapi: 3.0.3 +info: + title: Dambi Registry Sync API + description: | + Public contract for **Registry Sync** (`registryV2` + `registry-api`) — one + of the two dambi Cloud services (see `docs/decisions/0001-cloud-split.md`). + Serves signed decoder/selector data an integrator's `dambi-core` instance + resolves a pending call's decoder bundle against, and — once implemented — + signed policy bundles. + + Status as of 2026-09-07: + - `GET /v1/registry/selectors` and `GET /v1/registry/by-callkey` are + **implemented and live**. + - `POST /v1/audit` is **planned, not implemented**. `GET /v1/bundle` is + implemented but transitional (bundle content moves on-chain later). + Their shapes below are the agreed contract, not yet backed by code. + `POST /v1/audit` additionally still needs its home decided — see + `docs/decisions/0001-cloud-split.md` (current lean: Policy Hub, the + other dambi Cloud service, since it already has DB + auth). + + This server also mirrors the underlying object store 1:1 at + `/index//.json`, `/tokens//.json`, + `/bundles/.json`, `/signatures/.sig`, `/contexts/.json`. + Those are the internal representation the `/v1/...` routes above rewrite + into — an SDK integrator should use the `/v1/...` contract, not the raw + object paths, which carry no stability guarantee. + + Every error response shares one envelope: `{ ok: false, error: { code, + message } }`. A found object never returns `ok`/`error` — its body is the + object's own JSON. + version: "0.1.0" + +servers: + - url: http://127.0.0.1:8787 + description: Local dev + +tags: + - name: registry + description: Decoder/selector lookups (implemented) + - name: bundle + description: Signed policy bundle distribution (planned) + - name: audit + description: Client-submitted judgment log (planned) + - name: meta + description: Health + +paths: + /health: + get: + tags: [meta] + summary: Liveness probe + responses: + "200": + description: Server is up + content: + application/json: + schema: { type: object, properties: { ok: { type: boolean, example: true } } } + + /v1/registry/selectors: + get: + tags: [registry] + summary: Resolve the decoder bundle for a pending request (tx calldata or EIP-712 typed data) + description: | + One lookup route; the parameter shape selects the index: + + | shape | index | use | + |---|---|---| + | `chain_id` + `to` + `selector` | `by-callkey` | on-chain call to a known contract | + | `chain_id` + `selector` (no `to`) | `by-selector` | address-agnostic adapter, e.g. standard NFT `setApprovalForAll` | + | `chain_id` + `verifying_contract` + `primary_type` | `by-typed-data` | off-chain EIP-712 signature (Permit2, Hyperliquid, Seaport…) | + + The server never falls back between shapes — a `by-callkey` miss is a + 404, not a retry as `by-selector`. Whether an address-agnostic decoder + may stand in is the SDK's decision, gated by the decoder's own verified + declaration. A mixed shape (e.g. `to` together with `primary_type`) or + an incomplete one is a 404. Rewrites to the matching + `index//.json` object and serves it — see + `registry-api/src/server.ts`. + parameters: + - name: chain_id + in: query + required: true + schema: { type: integer, minimum: 1 } + example: 1 + - name: to + in: query + required: false + description: Target contract address (tx shape). Omit for an address-agnostic lookup. Case-insensitive on input. + schema: { type: string, pattern: "^0x[0-9a-fA-F]{40}$" } + - name: selector + in: query + required: false + description: 4-byte function selector (tx shape), `0x` + 8 hex chars. Case-insensitive on input. + schema: { type: string, pattern: "^0x[0-9a-fA-F]{8}$" } + example: "0xa22cb465" + - name: verifying_contract + in: query + required: false + description: EIP-712 domain `verifyingContract` (typed-data shape). Case-insensitive on input. + schema: { type: string, pattern: "^0x[0-9a-fA-F]{40}$" } + - name: primary_type + in: query + required: false + description: EIP-712 `primaryType` (typed-data shape), case-sensitive. A namespaced name may be passed with `:` (e.g. `HyperliquidTransaction:UsdSend`) or already escaped as `__`. + schema: { type: string, pattern: "^[A-Za-z0-9_:]+$" } + example: PermitSingle + responses: + "200": + description: Bundle found + headers: + ETag: { schema: { type: string }, description: 'sha256 of the response body, quoted' } + Cache-Control: { schema: { type: string } } + content: + application/json: + schema: { $ref: "#/components/schemas/RegistryEntry" } + "304": + description: Body unchanged since the ETag in `If-None-Match` + "404": + description: No decoder registered for this key, or a mixed/incomplete parameter shape — real status, not a soft miss + content: + application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } + "429": + description: Rate limited + headers: + Retry-After: { schema: { type: string } } + content: + application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } + + /v1/registry/by-callkey: + get: + tags: [registry] + summary: Resolve a decoder bundle by chain + contract + selector (secondary alias) + parameters: + - name: chain_id + in: query + required: true + schema: { type: integer, minimum: 1 } + - name: to + in: query + required: true + description: Target contract address. Case-insensitive on input. + schema: { type: string, pattern: "^0x[0-9a-fA-F]{40}$" } + - name: selector + in: query + required: true + schema: { type: string, pattern: "^0x[0-9a-fA-F]{8}$" } + responses: + "200": + description: Bundle found + content: + application/json: + schema: { $ref: "#/components/schemas/RegistryEntry" } + "404": + description: No decoder registered for this (chain, contract, selector) + content: + application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } + "429": + description: Rate limited + content: + application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } + + /v1/bundle: + get: + tags: [bundle] + summary: Fetch a signed policy bundle + x-status: implemented 2026-09-09 (route + object allow-list in + registry-api, publisher in registryV2/scripts/publish-policy-bundle.ts). + Transitional — the bundle content moves on-chain in full at a later, + unscheduled date (docs/decisions/0001-cloud-split.md, 2026-09-09 + addendum); this endpoint then becomes a mirror of on-chain content. + The core's `SignedPolicyBundle.payload` stays `unknown` — this shape + lives only on the wire and in whatever parses payload after + verification, not in packages/core's types. + description: | + Signature scheme (fixed, from `registryV2/scripts/sign-bundles.ts`, + already used for decoder bundles): the signed message is + `canonicalize(payload)` (RFC 8785 JCS); the detached signature is + ECDSA P-256 over SHA-256 of that message, P1363 (`r‖s`) encoding, + base64. `alg` / `key_id` in the response are informational — the SDK + pins the verification algorithm and key, it does not trust these + fields to select either. + + Rewrites into the same object-proxy pattern `/v1/registry/selectors` + already uses, no live DB lookup: `version` given → the immutable, + forever-cacheable `policy-bundles//.json`; + `version` omitted → the short-cache mutable pointer + `policy-bundles//latest.json`. A publish writes the same + signed bytes to both paths — no server-side ref-resolution needed. + No chain scoping: policies match chains from inside their own Cedar + source, not as an external dimension. + parameters: + - name: profile + in: query + required: false + description: | + Policy profile name, `^[a-z0-9-]+$`. v0.1 only ever serves + "default" (any other value is a real 404, not a soft fallback) — + reserved for v0.2 multi-profile support. + schema: { type: string, default: default, pattern: "^[a-z0-9-]+$" } + - name: version + in: query + required: false + description: | + Pin to a specific `sequence` instead of latest. Must be a + positive integer; a malformed value or a `sequence` that was + never issued are both a 404 (registry-api never distinguishes + malformed-request from not-found — same convention as every + other route here). v0.1 retains every sequence ever issued, so + any previously-valid `version` always resolves. + schema: { type: integer, minimum: 1 } + responses: + "200": + description: Signed bundle + headers: + ETag: { schema: { type: string } } + Cache-Control: + schema: { type: string } + description: Immutable long-lived cache when `version` was given; short mutable cache for latest. + content: + application/json: + schema: { $ref: "#/components/schemas/SignedPolicyBundle" } + "304": + description: Body unchanged since the ETag in `If-None-Match` + "404": + description: Unknown profile, or no bundle at all / at the requested `version` + content: + application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } + + /v1/audit: + post: + tags: [audit] + summary: Record a client-computed judgment (deduplicated by event id) + x-status: served by Policy Hub (policy-server), implemented there + 2026-09-12 — see crates/policy-server/server/openapi.yaml for the + authoritative definition (X-Api-Key auth, 202/400/401/503). Kept here + only so the cross-service contract is visible in one place; this + service does not route it. + description: | + Audit records are the **client's own report of what it decided**, not + a server-verified judgment — the server never re-runs Cedar. A + `wallet_address` field is deliberately absent from this contract: no + field here should let a raw wallet address reach this endpoint. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AuditEvent" } + responses: + "202": + description: Recorded (or already recorded — same `event_id` is not an error) + "400": + description: Malformed event + content: + application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } + "401": + description: Missing/invalid API key + content: + application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } + +components: + schemas: + ErrorEnvelope: + type: object + required: [ok, error] + properties: + ok: { type: boolean, enum: [false] } + error: + type: object + required: [code, message] + properties: + code: { type: string, example: not_found } + message: { type: string } + + RegistryEntry: + type: object + description: Resolved decoder bundle, ref-materialized if the index entry pointed at a template. + required: [matched, bundle_id, manifest_path, bundle_sha256, bundle] + properties: + matched: { type: boolean, enum: [true] } + bundle_id: { type: string } + manifest_path: { type: string } + bundle_sha256: { type: string, pattern: "^0x[0-9a-f]{64}$" } + bundle: { type: object, description: Decoder adapter bundle (type, match, abi_fragment, emit, requires). } + + SignedPolicyBundle: + type: object + description: | + Envelope for the SDK's `PolicySource` port. **Transport location: + single JSON response body** (not a header, not a detached sidecar + file, unlike decoder bundles' `bundles/.json` + + `signatures/.sig` pair). **`payload` is a string** holding the + RFC 8785 JCS canonical JSON text of a `BundlePayload` — the exact + bytes that were signed. Decided 2026-09-07, see + `docs/decisions/0001-cloud-split.md`. + + Why a string and not an embedded object: the SDK verifies the + signature over `payloadBytes` and then parses those bytes itself with + a strict parser (duplicate keys, malformed numbers and encodings are + rejected there). If `payload` were an object, the adapter would have + to re-serialize what JSON.parse gave it, and the signed bytes would + depend on the adapter's parser + canonicalizer. As a string the + adapter is a byte pass-through: `payloadBytes = utf8(payload)`, + `signatureBytes = base64decode(signature)`. The server-side signer + emits exactly `canonicalize(bundlePayload)` into this field. + required: [payload, signature] + properties: + payload: + type: string + description: JCS (RFC 8785) canonical JSON text of a `BundlePayload`. Verify first, then parse — never parse-then-reserialize. + signature: { type: string, description: base64, P1363 r‖s, ECDSA P-256 over SHA-256 of the UTF-8 bytes of `payload` } + key_id: { type: string, description: Telemetry only — the SDK verifies against its own configured trust key(s), never a key named in the response. Policy bundles are signed with a dedicated key, separate from the decoder-bundle key; the SDK pins both. } + + BundlePayload: + type: object + description: | + Signed as `canonicalize(this)` (RFC 8785 JCS) — field order in this + document is illustrative only, canonicalization does not depend on it. + Every field below is **required to be present** (`expires_at` and + `registry_ref` included) even where its value is `null` — a missing + key is an invalid bundle, `null` is a valid, currently-supported value. + required: [policies, sequence, issued_at, expires_at, env, profile, registry_ref] + properties: + policies: + type: array + minItems: 1 + description: Unchanged from the extension's existing `policy-set-v2.json` / `ResolvedBundle` shape — no client-side translation needed. Reject duplicate `id`s. + items: + type: object + required: [id, policy, manifest] + properties: + id: { type: string, minLength: 1, description: Must equal `manifest.id`. } + policy: { type: string, description: Cedar source } + manifest: + description: >- + Opaque (ManifestV2, TBD by the future policy-ir package) — + same deferral the core's own `PolicySet` type makes. In + practice always at least id + schema_version: 2 + a + trigger; an empty object is invalid. + sequence: + type: integer + minimum: 1 + maximum: 9007199254740991 + description: Monotonic. The `version` query param pins to this. A rollback republishes prior content under a new (higher) sequence — content is never mutated in place. Safe-integer range (1..2^53-1); a larger value needs a future string-based contract. + issued_at: { type: integer, description: Unix seconds } + expires_at: + type: integer + nullable: true + description: Unix seconds, or `null` meaning "publisher declared no expiry." When numeric, must be after `issued_at`. `null` does not mean the SDK treats the bundle as valid forever — the SDK enforces its own `maxBundleAgeSec` ceiling regardless (effective validity = `min(expires_at, issued_at + maxBundleAgeSec)`, a client-side config, not part of this contract). + env: { type: string, enum: [staging, production], description: Must exactly match the client's configured expected environment. } + profile: + type: string + description: Always "default" in v0.1 — any other value is a real 404 at the API, not a soft fallback. Reserved for v0.2 multi-profile support; tenant isolation is explicitly out of v0.1 scope, and this field alone is not a signed tenant binding. + default: default + registry_ref: + type: string + nullable: true + description: | + Always `null` in v0.1 — registryV2's index build doesn't yet emit + a root version/hash to reference, and null explicitly means "this + bundle pins no remote Registry state." A non-null value is a + distinct, recognized-but-unsupported case (`UNSUPPORTED_REGISTRY_REF`), + not a generic validation failure — reserved for when the SDK can + cross-check the decoders it JIT-installs against the Registry + Sync state a bundle was authored against. + + AuditEvent: + type: object + required: [event_id, request_digest, verdict, policy_version, engine_version] + properties: + event_id: { type: string, description: Client-generated, dedup key. } + request_digest: { type: string, description: Hash of the evaluated request — never the raw calldata/signature payload. } + verdict: { type: string, enum: [allow, warn, deny] } + policy_version: { type: string } + engine_version: { type: string } + submitted_at: { type: integer, description: Unix seconds } diff --git a/registry-api/src/__tests__/config.test.ts b/registry-api/src/__tests__/config.test.ts index 8e00cb7f..05710c26 100644 --- a/registry-api/src/__tests__/config.test.ts +++ b/registry-api/src/__tests__/config.test.ts @@ -11,7 +11,7 @@ describe("loadConfig", () => { it("defaults to the registry-v3 production bucket", () => { delete process.env.REGISTRY_BUCKET; - expect(loadConfig().bucketName).toBe("dambi-registry-v3-seoul"); + expect(loadConfig().bucketName).toBe("dambi-registry-v3-unseo"); }); it("allows REGISTRY_BUCKET to override the default bucket", () => { diff --git a/registry-api/src/config.ts b/registry-api/src/config.ts index 519908b7..bd2b683e 100644 --- a/registry-api/src/config.ts +++ b/registry-api/src/config.ts @@ -41,7 +41,7 @@ export function loadConfig(): RegistryApiConfig { return { host: stringFromEnv("HOST", "0.0.0.0"), port: intFromEnv("PORT", 8080), - bucketName: stringFromEnv("REGISTRY_BUCKET", "dambi-registry-v3-seoul"), + bucketName: stringFromEnv("REGISTRY_BUCKET", "dambi-registry-v3-unseo"), cacheMaxEntries: intFromEnv("CACHE_MAX_ENTRIES", 1024), cacheTtlMs: intFromEnv("CACHE_TTL_MS", 300_000), cacheNegativeTtlMs, diff --git a/registry-api/src/server.ts b/registry-api/src/server.ts index 9507d233..b77227d1 100644 --- a/registry-api/src/server.ts +++ b/registry-api/src/server.ts @@ -16,6 +16,9 @@ * → generated per-target context object * GET /v1/registry/by-callkey?chain_id&to&selector * → spec §6.1 callkey proxy alias (secondary) + * GET /v1/registry/selectors?chain_id&selector + * → by-selector proxy alias (address-agnostic + * adapters, e.g. standard NFT setApprovalForAll) * OPTIONS → 204 CORS preflight * * Proxy 의미 (핵심 — 익스텐션 negative cache 가 의존): @@ -181,6 +184,45 @@ async function routeRequest(input: RouteInput): Promise { const selector = (url.searchParams.get("selector") ?? "").toLowerCase(); proxyPath = `/index/by-callkey/${chainId}__${to}__${selector}.json`; } + // Public decoder lookup — ONE route, the parameter shape picks the index: + // chain_id + to + selector → by-callkey + // chain_id + selector (no `to`) → by-selector (address-agnostic) + // chain_id + verifying_contract + primary_type → by-typed-data (EIP-712) + // No server-side fallback between shapes (callkey miss does NOT retry as + // by-selector): whether an address-agnostic decoder may stand in is the + // client's call, gated by the decoder's own verified declaration. A mixed + // or incomplete shape falls through to the 404 below. + if (method === "GET" && url.pathname === "/v1/registry/selectors") { + const q = url.searchParams; + const chainId = q.get("chain_id") ?? ""; + const to = q.get("to"); + const selector = q.get("selector"); + const verifyingContract = q.get("verifying_contract"); + const primaryType = q.get("primary_type"); + const isTx = selector !== null && verifyingContract === null && primaryType === null; + const isTypedData = + verifyingContract !== null && primaryType !== null && to === null && selector === null; + if (isTx && to !== null) { + proxyPath = `/index/by-callkey/${chainId}__${to.toLowerCase()}__${selector.toLowerCase()}.json`; + } else if (isTx) { + proxyPath = `/index/by-selector/${chainId}__${selector.toLowerCase()}.json`; + } else if (isTypedData) { + // primary_type keeps its case (it is the struct name); a ":" namespace + // separator is escaped as "__" like build-index's typedDataFilename. + proxyPath = `/index/by-typed-data/${chainId}__${verifyingContract.toLowerCase()}__${primaryType.replace(/:/g, "__")}.json`; + } + } + + // GET /v1/bundle — signed policy bundle (docs/decisions/0001-cloud-split.md). + // ?profile=&version= → policy-bundles//.json (immutable) + // ?profile= → policy-bundles//latest.json (mutable pointer) + // profile defaults to "default". Malformed profile/version falls through to + // parseProxyTarget's 404 — never a 400, same as every other route here. + if (method === "GET" && url.pathname === "/v1/bundle") { + const profile = url.searchParams.get("profile") ?? "default"; + const version = url.searchParams.get("version") ?? "latest"; + proxyPath = `/policy-bundles/${profile}/${version}.json`; + } if ( method === "GET" && @@ -190,7 +232,8 @@ async function routeRequest(input: RouteInput): Promise { proxyPath.startsWith("/tokens/") || proxyPath.startsWith("/bundles/") || proxyPath.startsWith("/signatures/") || - proxyPath.startsWith("/contexts/")) + proxyPath.startsWith("/contexts/") || + proxyPath.startsWith("/policy-bundles/")) ) { await handleProxy(input, proxyPath); return; @@ -626,6 +669,17 @@ async function materializeIfRefIndex( }; } +/** + * Weak content hash of the served bytes, quoted per RFC 9110 §8.8.3. Computed + * from the response body itself (not the upstream object name), so it also + * covers materialized/ref-resolved responses (`materializeIfRefIndex`) — + * those bytes differ from the raw GCS object, so a caller comparing against + * `bundle_sha256` alone would miss a change to the resolved shape. + */ +function computeEtag(body: Buffer): string { + return `"${createHash("sha256").update(body).digest("hex")}"`; +} + function sendCacheValue( input: RouteInput, value: CacheValue, @@ -640,18 +694,36 @@ function sendCacheValue( const cacheControl = isContentAddressed(proxyPath) ? input.config.immutableCacheControlValue : input.config.cacheControlValue; + const etag = computeEtag(value.body); + const ifNoneMatch = input.request.headers["if-none-match"]; + if (typeof ifNoneMatch === "string" && ifNoneMatch === etag) { + input.response.writeHead(304, { + ...CORS_HEADERS, + "cache-control": cacheControl, + etag, + }); + input.response.end(); + return; + } input.response.writeHead(200, { ...CORS_HEADERS, "content-type": value.contentType, "cache-control": cacheControl, + etag, }); input.response.end(value.body); } -/** Content-addressed leaves — the sha IS the version, so safe to cache forever. */ +const POLICY_BUNDLE_SEQUENCE_RE = /^\/policy-bundles\/[a-z0-9-]+\/[1-9][0-9]*\.json$/; + +/** Content-addressed leaves — the sha IS the version, so safe to cache forever. + * policy-bundles//.json counts too: a sequence is never + * rewritten (a rollback issues a new, higher sequence), only latest.json moves. */ function isContentAddressed(proxyPath: string): boolean { return ( - proxyPath.startsWith("/bundles/") || proxyPath.startsWith("/signatures/") + proxyPath.startsWith("/bundles/") || + proxyPath.startsWith("/signatures/") || + POLICY_BUNDLE_SEQUENCE_RE.test(proxyPath) ); } diff --git a/registry-api/src/validation.ts b/registry-api/src/validation.ts index d1e23c01..17b098de 100644 --- a/registry-api/src/validation.ts +++ b/registry-api/src/validation.ts @@ -10,6 +10,8 @@ * GET /bundles/.json * GET /signatures/.sig * GET /contexts///
.json + * GET /policy-bundles//.json (immutable, sequence IS the version) + * GET /policy-bundles//latest.json (mutable pointer) * * 이 regex 는 browser-extension/backend/service-worker/registry/client.ts * (CALL_KEY_ADDRESS_RE / CALL_KEY_SELECTOR_RE) 를 미러. 단 to/address/selector @@ -35,6 +37,14 @@ const BUNDLE_FILE_RE = new RegExp(`^${SHA256_LC}\\.json$`); // Detached bundle signature sidecar = .sig (content-addressed, // 0x + 64 lowercase hex). Tightly bounded → path-traversal-safe. const SIG_FILE_RE = new RegExp(`^${SHA256_LC}\\.sig$`); +// Policy bundle = /.json. profile is ^[a-z0-9-]+$ (the +// GET /v1/bundle contract, docs/decisions/0001-cloud-split.md); sequence is a +// positive integer with no leading zeros so "007" and "7" can't name two +// objects. Both fragments are tightly bounded → path-traversal-safe. +const POLICY_PROFILE = "[a-z0-9-]+"; +const POLICY_BUNDLE_RE = new RegExp( + `^(${POLICY_PROFILE})/(${CHAIN_ID}|latest)\\.json$`, +); // typed-data key = ____. // primaryType 는 EIP-712 콜론(:)이 "__" 로 escape 된 형태라 자체적으로 "__" 를 @@ -63,6 +73,9 @@ export function isValidAddressSegment(s: string): boolean { export function isValidSignatureFile(s: string): boolean { return SIG_FILE_RE.test(s); } +export function isValidPolicyBundleFile(s: string): boolean { + return POLICY_BUNDLE_RE.test(s); +} export interface ProxyTargetOk { ok: true; @@ -80,6 +93,7 @@ const TOKENS_PREFIX = "/tokens/"; const BUNDLES_PREFIX = "/bundles/"; const SIGNATURES_PREFIX = "/signatures/"; const CONTEXTS_PREFIX = "/contexts/"; +const POLICY_BUNDLES_PREFIX = "/policy-bundles/"; const JSON_SUFFIX = ".json"; /** @@ -158,6 +172,16 @@ export function parseProxyTarget(pathname: string): ProxyTarget { : { ok: false }; } + // Signed policy bundles for GET /v1/bundle — {payload, signature, key_id} + // served verbatim. .json never changes once written; latest.json + // is a pointer the publish script overwrites. + if (pathname.startsWith(POLICY_BUNDLES_PREFIX)) { + const file = pathname.slice(POLICY_BUNDLES_PREFIX.length); + return POLICY_BUNDLE_RE.test(file) + ? { ok: true, objectName: `policy-bundles/${file}` } + : { ok: false }; + } + if (pathname.startsWith(CONTEXTS_PREFIX) && pathname.endsWith(JSON_SUFFIX)) { const inner = pathname.slice(CONTEXTS_PREFIX.length); const parts = inner.split("/"); diff --git a/registryV2/README.md b/registryV2/README.md index 15156eb3..a581b4e6 100644 --- a/registryV2/README.md +++ b/registryV2/README.md @@ -29,7 +29,7 @@ output**, never hand-edited. publish-index.sh / registry-publish.yml │ gcloud storage rsync (leaves before pointers) ▼ - gs://dambi-registry-v3-seoul (PRIVATE, versioned) + gs://dambi-registry-v3-unseo (PRIVATE, versioned) │ ▼ ../registry-api Cloud Run proxy (read-only) browser extension @@ -43,8 +43,8 @@ output**, never hand-edited. > bucket or proxy can withhold or corrupt a bundle (availability), but cannot forge one the > extension will accept (integrity) — see [Signing & verification](#signing--verification). -> **Self-hosting:** the canonical deployment is GCP project `dambi-registry` -> (`asia-northeast3`), but all resource names are env-overridable via +> **Self-hosting:** the canonical deployment is GCP project `project-c2aefc18-2bfc-495a-a3d` +> (`asia-northeast1`), but all resource names are env-overridable via > `scripts/deploy/_common.sh`. --- @@ -295,18 +295,18 @@ The canonical deployment, verified against the live project: | Resource | Value | |---|---| -| Project | `dambi-registry` (`asia-northeast3`) | -| Bucket | `gs://dambi-registry-v3-seoul` — `STANDARD`, **UBLA**, **Public Access Prevention enforced**, **versioning on**, soft-delete 7 days, lifecycle: keep the 3 newest noncurrent versions, delete others ≥ 30 days noncurrent | +| Project | `project-c2aefc18-2bfc-495a-a3d` (`asia-northeast1`) | +| Bucket | `gs://dambi-registry-v3-unseo` — `STANDARD`, **UBLA**, **Public Access Prevention enforced**, **versioning on**, soft-delete 7 days, lifecycle: keep the 3 newest noncurrent versions, delete others ≥ 30 days noncurrent | | KMS | keyring `registry-signing` / key `bundle-sign-p256` / `ASYMMETRIC_SIGN` `EC_SIGN_P256_SHA256` / **HSM** protection (FIPS 140-2 L3, non-extractable) / version `1` enabled | | Proxy service | Cloud Run `registry-api-v3` (see [`../registry-api`](../registry-api)) | -| Artifact Registry | `asia-northeast3-docker.pkg.dev/dambi-registry/dambi` (Docker) | +| Artifact Registry | `asia-northeast1-docker.pkg.dev/project-c2aefc18-2bfc-495a-a3d/dambi` (Docker) | **Two service accounts, split by least privilege:** | Service account | Role(s) | Used by | |---|---|---| -| `registry-signer@dambi-registry.iam.gserviceaccount.com` | `roles/cloudkms.signerVerifier` on the key (sign + `getPublicKey`, **not** export) + `roles/storage.objectAdmin` on the bucket + Cloud Run deployer | CI publish & proxy-deploy (the WIF identity) | -| `registry-api-v3-sa@dambi-registry.iam.gserviceaccount.com` | `roles/storage.objectViewer` on the bucket | the proxy **runtime** (read-only) | +| `registry-signer@project-c2aefc18-2bfc-495a-a3d.iam.gserviceaccount.com` | `roles/cloudkms.signerVerifier` on the key (sign + `getPublicKey`, **not** export) + `roles/storage.objectAdmin` on the bucket + Cloud Run deployer | CI publish & proxy-deploy (the WIF identity) | +| `registry-api-v3-sa@project-c2aefc18-2bfc-495a-a3d.iam.gserviceaccount.com` | `roles/storage.objectViewer` on the bucket | the proxy **runtime** (read-only) | The bucket is not anonymously readable (`Public Access Prevention` is enforced and there is no `allUsers` bucket binding). Admin/project legacy IAM bindings still exist, so the @@ -384,7 +384,7 @@ Build's GCS staging bucket. Both workflows authenticate via WIF (`REGISTRY_WIF_PROVIDER` + `REGISTRY_DEPLOY_SA` secrets), gate on the `production` GitHub Environment, and pin -`CLOUDSDK_BILLING_QUOTA_PROJECT=dambi-registry` so external-account credentials attach +`CLOUDSDK_BILLING_QUOTA_PROJECT=project-c2aefc18-2bfc-495a-a3d` so external-account credentials attach `x-goog-user-project`. --- diff --git a/registryV2/docs/REGISTRY_ARCHITECTURE.md b/registryV2/docs/REGISTRY_ARCHITECTURE.md index 1c3738e8..c626d868 100644 --- a/registryV2/docs/REGISTRY_ARCHITECTURE.md +++ b/registryV2/docs/REGISTRY_ARCHITECTURE.md @@ -231,7 +231,7 @@ uptime failure (`<50%` of probes passing / 10 min) — plus a **log-based error beta `gcloud monitoring channels` component — see the script header). `provision-budget.sh` (operator-run; needs `BILLING_ACCOUNT`) sets a Cloud Billing budget scoped -to `dambi-registry` with 50/90/100% actual + 100% forecast thresholds — a denial-of-wallet / +to `project-c2aefc18-2bfc-495a-a3d` with 50/90/100% actual + 100% forecast thresholds — a denial-of-wallet / egress-spike safety net (**notify-only, not a hard cap**). Bucket **lifecycle** (`bucket-lifecycle.json`, applied by `provision-infra.sh`): a single rule @@ -300,35 +300,35 @@ and broad `editor` bindings on a shared project are a poor home for one. | | **Prod (canonical)** | |---|---| -| Project ID | `dambi-registry` (org ``) | +| Project ID | `project-c2aefc18-2bfc-495a-a3d` (org ``) | | Project # | `` | -| Bucket | `gs://dambi-registry-v3-seoul` | +| Bucket | `gs://dambi-registry-v3-unseo` | | KMS key | `registry-signing/bundle-sign-p256` — **HSM** | | Proxy (Cloud Run) | `registry-api-v3` → `https://` (stable) | -| Runtime SA | `registry-api-v3-sa@dambi-registry` (objectViewer only) | -| Signer SA (CI/WIF) | `registry-signer@dambi-registry` (signerVerifier + objectAdmin) | +| Runtime SA | `registry-api-v3-sa@project-c2aefc18-2bfc-495a-a3d` (objectViewer only) | +| Signer SA (CI/WIF) | `registry-signer@project-c2aefc18-2bfc-495a-a3d` (signerVerifier + objectAdmin) | | WIF | pool `github-pool` / provider `github-provider` (repo-pinned `errkat4up/DAMBI`) | | gcloud config | `dambi` | -Bucket settings: `asia-northeast3` · versioning on · Public Access Prevention enforced · +Bucket settings: `asia-northeast1` · versioning on · Public Access Prevention enforced · UBLA. The extension's channel pin is this project's HSM public key — see §6. ### Provisioning (idempotent gcloud / scripts) -1. `gcloud projects create dambi-registry --organization=` + billing link + enable +1. `gcloud projects create project-c2aefc18-2bfc-495a-a3d --organization=` + billing link + enable APIs (cloudkms, run, storage, artifactregistry, iamcredentials, sts, cloudbuild). 2. Bucket (versioning + PAP + UBLA). 3. KMS **HSM** keyring/key (`EC_SIGN_P256_SHA256`); extract the public key → channel pin. -4. Runtime SA + `objectViewer`; AR repo `dambi`; **`PROJECT_ID=dambi-registry deploy-proxy.sh`** +4. Runtime SA + `objectViewer`; AR repo `dambi`; **`PROJECT_ID=project-c2aefc18-2bfc-495a-a3d deploy-proxy.sh`** (verify: proxy-fetch sha == bucket sha, CORS behaviour, 404 on a bad path). 5. Signer SA + KMS `signerVerifier` + bucket `objectAdmin`; WIF pool/provider **pinned to `errkat4up/DAMBI`**; `workloadIdentityUser` binding. -6. In-repo targeting: `_common.sh` defaults (`PROJECT_ID=dambi-registry`, config map) and +6. In-repo targeting: `_common.sh` defaults (`PROJECT_ID=project-c2aefc18-2bfc-495a-a3d`, config map) and `registry-publish.yml` env (`PROJECT_ID` / `BUCKET`). ### Manual finish steps (operator) - **GitHub secrets** (repo `errkat4up/DAMBI`) for CI keyless signing/publish: - `GCP_WIF_PROVIDER = projects//locations/global/workloadIdentityPools/github-pool/providers/github-provider` - - `GCP_DEPLOY_SA = registry-signer@dambi-registry.iam.gserviceaccount.com` + - `GCP_DEPLOY_SA = registry-signer@project-c2aefc18-2bfc-495a-a3d.iam.gserviceaccount.com` - **Signing**: every published bundle carries a detached signature under `signatures/`, produced by CI (`registry-publish.yml`, KMS via WIF) once those secrets are set. - **Custom domain**: not used for the registry proxy — the `*.run.app` host is the stable diff --git a/registryV2/docs/REGISTRY_RUNBOOK.md b/registryV2/docs/REGISTRY_RUNBOOK.md index 797c982d..174d9e1c 100644 --- a/registryV2/docs/REGISTRY_RUNBOOK.md +++ b/registryV2/docs/REGISTRY_RUNBOOK.md @@ -5,7 +5,7 @@ identifier (project, bucket, KMS key, SA, proxy URL, pins) lives in `REGISTRY_ARCHITECTURE.md` §10 — this doc does not duplicate them, it tells you which command to run. **Run every command below from the `registryV2/` directory** (where this doc lives); the `scripts/deploy/…` paths are relative to it. All deploy scripts source -`scripts/deploy/_common.sh`, which pins the prod target (`PROJECT_ID=dambi-registry`, +`scripts/deploy/_common.sh`, which pins the prod target (`PROJECT_ID=project-c2aefc18-2bfc-495a-a3d`, config `dambi`) and guards the active gcloud account. Override with env (`PROJECT_ID=… GCLOUD_CONFIG=…`) to target the legacy PoC. @@ -35,11 +35,11 @@ GA gcloud has no `channels` group; install the beta component once: gcloud components install beta gcloud beta monitoring channels create --type=email \ --display-name="registry oncall" \ - --channel-labels=email_address=YOU@example.com --project=dambi-registry -gcloud beta monitoring channels list --project=dambi-registry --format='value(name)' + --channel-labels=email_address=YOU@example.com --project=project-c2aefc18-2bfc-495a-a3d +gcloud beta monitoring channels list --project=project-c2aefc18-2bfc-495a-a3d --format='value(name)' ``` Re-run monitoring with that resource name to wire paging: -`NOTIFICATION_CHANNEL=projects/dambi-registry/notificationChannels/ID bash scripts/deploy/provision-monitoring.sh` +`NOTIFICATION_CHANNEL=projects/project-c2aefc18-2bfc-495a-a3d/notificationChannels/ID bash scripts/deploy/provision-monitoring.sh` --- @@ -47,15 +47,15 @@ Re-run monitoring with that resource name to wire paging: | Alert | First checks | Likely fix | |---|---|---| -| **uptime /health failing** | `gcloud run services describe registry-api-v3 --region asia-northeast3 --format='value(status.url,status.conditions)'`; hit `${URL}/health` | bad revision → roll back revision (below); GCS unreachable → check bucket IAM | +| **uptime /health failing** | `gcloud run services describe registry-api-v3 --region asia-northeast1 --format='value(status.url,status.conditions)'`; hit `${URL}/health` | bad revision → roll back revision (below); GCS unreachable → check bucket IAM | | **5xx burst** | Cloud Run logs `severity>=ERROR` (log-metric `registry_api_errors`); is GCS reachable / object present? | a bad publish (missing object) → republish / `rollback-index.sh`; transient → watch | | **p95 latency** | min-instances warm? cold-start? GCS latency? | ensure `MIN_INSTANCES≥1`; the JIT fetch shares the 8s pre-sign budget | | **signature coverage gap** (REQUIRE on) | `SKIP_FAITHFULNESS=1 bash scripts/deploy/verify-bucket-parity.sh` | a sha without a `.sig` → re-run `sign-bundles.ts` + publish `signatures/` | ### Roll back a bad proxy revision ``` -gcloud run revisions list --service registry-api-v3 --region asia-northeast3 -gcloud run services update-traffic registry-api-v3 --region asia-northeast3 --to-revisions =100 +gcloud run revisions list --service registry-api-v3 --region asia-northeast1 +gcloud run services update-traffic registry-api-v3 --region asia-northeast1 --to-revisions =100 ``` Cloud Run keeps prior revisions; an unhealthy new revision fails the deploy and traffic stays on the old one, so most "deploys" never need this. @@ -138,7 +138,7 @@ build artifacts are produced if this variable is not exactly `true`. | What | Where | Value | |---|---|---| | WIF provider secret | repo Secrets `GCP_WIF_PROVIDER` | `projects//locations/global/workloadIdentityPools/github-pool/providers/github-provider` | -| Deploy/sign SA secret | repo Secrets `GCP_DEPLOY_SA` | `registry-signer@dambi-registry.iam.gserviceaccount.com` | +| Deploy/sign SA secret | repo Secrets `GCP_DEPLOY_SA` | `registry-signer@project-c2aefc18-2bfc-495a-a3d.iam.gserviceaccount.com` | | `production` Environment | repo Settings → Environments | + required reviewer (gates publish + proxy-deploy) | | Extension build vars | repo Variables | `REGISTRY_BASE_URL` / `PINNED_BUNDLE_PUBLIC_KEY` / `DAMBI_REQUIRE_BUNDLE_SIGNATURE` | diff --git a/registryV2/package.json b/registryV2/package.json index f8b071ad..d50ab9c5 100644 --- a/registryV2/package.json +++ b/registryV2/package.json @@ -1,7 +1,7 @@ { "name": "dambi-registryV2", "version": "0.3.0", - "description": "Dambi Adapter Loader registry v3 — PDF FSM spec (hierarchical emit.body + live_inputs.source)", + "description": "Dambi Adapter Loader registry v3 \u2014 PDF FSM spec (hierarchical emit.body + live_inputs.source)", "private": true, "license": "Apache-2.0", "type": "module", @@ -15,7 +15,8 @@ "sign": "tsx scripts/sign-bundles.ts", "gen-signing-key": "tsx scripts/gen-signing-key.ts", "typecheck": "tsc --noEmit", - "serve": "python3 -m http.server 8000" + "serve": "python3 -m http.server 8000", + "publish:policy-bundle": "tsx scripts/publish-policy-bundle.ts" }, "devDependencies": { "@google-cloud/kms": "^5.5.1", diff --git a/registryV2/policy-bundles/default/1.json b/registryV2/policy-bundles/default/1.json new file mode 100644 index 00000000..d578c8c9 --- /dev/null +++ b/registryV2/policy-bundles/default/1.json @@ -0,0 +1 @@ +{"payload":"{\"env\":\"staging\",\"expires_at\":null,\"issued_at\":1789208851,\"policies\":[{\"id\":\"permit2-sign-allowance-confirm\",\"manifest\":{\"custom_context\":{\"fields\":{}},\"id\":\"permit2-sign-allowance-confirm\",\"policy_rpc\":[],\"schema_version\":2,\"trigger\":{\"where\":{\"action.tag\":{\"eq\":\"permit2_sign_allowance\"}}}},\"policy\":\"// Default v2 policy — confirm before signing a Permit2 token allowance.\\n//\\n// EIP-712 `Permit2` allowance signatures (`permit2_sign_allowance`) grant a\\n// `spender` an off-chain-authorized allowance on the underlying token — a major\\n// phishing surface. Like the HyperLiquid agent-approval confirm, we warn on\\n// every such signature so the user reviews the decoded spender / amount /\\n// deadline before approving. Unconditional (no `when`): `spender`/`amount` are\\n// statically decoded, so this manifest declares no policy_rpc calls.\\n@id(\\\"permit2-sign-allowance-confirm\\\")\\n@severity(\\\"warn\\\")\\n@reason(\\\"Signing a Permit2 token allowance — confirm the spender and amount before approving\\\")\\nforbid(\\n principal,\\n action == Token::Action::\\\"Permit2SignAllowance\\\",\\n resource\\n);\\n\"},{\"id\":\"send-first-time-or-burn-recipient-warn\",\"manifest\":{\"id\":\"send-first-time-or-burn-recipient-warn\",\"schema_version\":2,\"trigger\":{\"where\":{\"action.tag\":{\"eq\":\"erc20_transfer\"}}}},\"policy\":\"// dambi default v2 policy — demand signal: irreversible-typo-fatfinger-send\\n// (Send Safety Set). Coinbase/MetaMask/ENS/Bitdeer all warn that a confirmed\\n// transfer is irreversible; the most certain way to lose funds permanently is a\\n// send to the burn / zero address.\\n//\\n// `recipient` is a base, non-optional `String` field of\\n// `Token::Erc20TransferContext` (same shape as swap-recipient-not-self-deny),\\n// so this judges with NO enrichment and NO policy_rpc — it just compares the\\n// recipient against the known burn-address literals. Addresses are lowercase\\n// hex on both sides (host-normalized), so exact String equality / Set membership\\n// holds. The zero address and 0x…dead have no key holder: a transfer there is\\n// permanent loss, hence `deny` (a hard block, not a one-beat warn).\\n//\\n// NOTE (dedup): the originating signal also bundled a \\\"first-time recipient,\\n// pause\\\" warn. That half duplicates the existing draft\\n// transfer-unknown-recipient-warn (same threat, severity, and external history\\n// Bool) and is intentionally NOT carried here; this policy keeps only the novel\\n// burn-address clause.\\n@id(\\\"send-first-time-or-burn-recipient-warn\\\")\\n@severity(\\\"deny\\\")\\n@reason(\\\"This send goes to a burn address (0x000…000 / 0x…dead) — funds sent there are permanently lost\\\")\\nforbid(principal, action == Token::Action::\\\"Erc20Transfer\\\", resource)\\nwhen {\\n [\\\"0x0000000000000000000000000000000000000000\\\",\\n \\\"0x000000000000000000000000000000000000dead\\\"].contains(context.recipient)\\n};\\n\"},{\"id\":\"swap-recipient-not-self-deny\",\"manifest\":{\"id\":\"swap-recipient-not-self-deny\",\"schema_version\":2,\"trigger\":{\"where\":{\"action.tag\":{\"eq\":\"swap\"}}}},\"policy\":\"// dambi default v2 policy — explorer id: UNI-01 (swap output redirected to\\n// an address other than the signer).\\n//\\n// Drainers dress a swap up as normal but flip only `recipient` to the attacker:\\n// you sell your tokens and someone else receives the proceeds. `recipient` is a\\n// base `String` field of `Amm::SwapContext` and `principal.address` is the\\n// evaluated wallet's address (Wallet entity attr) — both non-optional Strings —\\n// so this compares directly with NO enrichment and NO policy_rpc (sv = COV).\\n// Addresses are lowercase hex on both sides, so exact String equality holds.\\n@id(\\\"swap-recipient-not-self-deny\\\")\\n@severity(\\\"deny\\\")\\n@reason(\\\"This swap sends the bought tokens to an address that is not your wallet\\\")\\nforbid(principal, action == Amm::Action::\\\"Swap\\\", resource)\\nwhen { context.recipient != principal.address };\\n\"},{\"id\":\"unknown-blind-sign-warning\",\"manifest\":{\"id\":\"unknown-blind-sign-warning\",\"schema_version\":2,\"trigger\":{\"where\":{\"action.domain\":{\"eq\":\"unknown\"}}}},\"policy\":\"// dambi default v2 policy — explorer id: GEN-09 (blind / opaque signature).\\n//\\n// Warn whenever an action could not be decoded by any manifest and therefore\\n// surfaces as `Core::Action::\\\"Unknown\\\"` (opaque EIP-712, raw `eth_sign`, or\\n// calldata no adapter matched). Signature-phishing leans on exactly these\\n// undecodable requests, so the wallet should flag them for a human look.\\n//\\n// This is a pure action-shape policy (Aggregator type 8 — Decode\\n// Introspection): the verdict depends only on the decoded action's *identity*,\\n// not on any enriched fact. The manifest therefore declares no `policy_rpc`\\n// calls and no `custom_context`.\\n@id(\\\"unknown-blind-sign-warning\\\")\\n@severity(\\\"warn\\\")\\nforbid(principal, action == Core::Action::\\\"Unknown\\\", resource);\\n\"},{\"id\":\"unlimited-approval-deny\",\"manifest\":{\"id\":\"unlimited-approval-deny\",\"schema_version\":2,\"trigger\":{\"where\":{\"action.tag\":{\"eq\":\"erc20_approve\"}}}},\"policy\":\"// dambi default v2 policy — explorer id: GEN-01 (unlimited ERC20 approval\\n// to a non-allowlisted spender).\\n//\\n// An `approve(spender, amount)` with `amount == U256::MAX` hands `spender` an\\n// open-ended claim on the token — the canonical token-drain primitive.\\n//\\n// STATIC detection: `amount` is a raw lowercase U256 hex String (`{:#x}`, no\\n// leading zeros) on the BASE context, so the two canonical \\\"unlimited\\\" sentinels\\n// are matched by string equality — `uint256::MAX` (64 f's, standard ERC20\\n// max-approve) and `uint160::MAX` (40 f's, the Permit2-style max). No enrichment\\n// is needed, so this works fully offline (server-independent).\\n//\\n// NOTE: this slice was rewritten from the earlier `context.custom.approvalIsUnlimited`\\n// form, which depended on the `approval.unlimited_over_balance` policy_rpc method.\\n// That method is NOT implemented, and its output is `required: true`, so the manifest\\n// tripped `SystemFail` on EVERY approve (deny-all) once the bundle's manifest was\\n// actually shipped. The static check denies ONLY a genuinely unlimited approve and\\n// lets bounded approvals pass. (Over-balance scoring — approve-amount vs holdings —\\n// is dropped here; it needs balance enrichment and returns in a later increment.)\\n//\\n// The allowlist (spenders for whom an unlimited approval is expected) is inlined\\n// as a Cedar set literal; the entry below is the canonical Permit2 contract.\\n// Addresses are lowercase hex.\\n@id(\\\"unlimited-approval-deny\\\")\\n@severity(\\\"warn\\\")\\nforbid(principal, action == Token::Action::\\\"Erc20Approve\\\", resource)\\nwhen {\\n (context.amount == \\\"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\\\"\\n || context.amount == \\\"0xffffffffffffffffffffffffffffffffffffffff\\\")\\n && !([\\\"0x000000000022d473030f116ddee9f6b43ac78ba3\\\"].contains(context.spender))\\n};\\n\"}],\"profile\":\"default\",\"registry_ref\":null,\"sequence\":1}","signature":"jHxIM+r6cAvp/J1MBcN3a6AVbCTDWB37QX37fy+i9Q5q0K5dogHz7rh0Yq45njrsG3dohPcg6fIgIU3ZucfaRA==","key_id":"policy-local-416b1764fb8d"} diff --git a/registryV2/policy-bundles/default/latest.json b/registryV2/policy-bundles/default/latest.json new file mode 100644 index 00000000..d578c8c9 --- /dev/null +++ b/registryV2/policy-bundles/default/latest.json @@ -0,0 +1 @@ +{"payload":"{\"env\":\"staging\",\"expires_at\":null,\"issued_at\":1789208851,\"policies\":[{\"id\":\"permit2-sign-allowance-confirm\",\"manifest\":{\"custom_context\":{\"fields\":{}},\"id\":\"permit2-sign-allowance-confirm\",\"policy_rpc\":[],\"schema_version\":2,\"trigger\":{\"where\":{\"action.tag\":{\"eq\":\"permit2_sign_allowance\"}}}},\"policy\":\"// Default v2 policy — confirm before signing a Permit2 token allowance.\\n//\\n// EIP-712 `Permit2` allowance signatures (`permit2_sign_allowance`) grant a\\n// `spender` an off-chain-authorized allowance on the underlying token — a major\\n// phishing surface. Like the HyperLiquid agent-approval confirm, we warn on\\n// every such signature so the user reviews the decoded spender / amount /\\n// deadline before approving. Unconditional (no `when`): `spender`/`amount` are\\n// statically decoded, so this manifest declares no policy_rpc calls.\\n@id(\\\"permit2-sign-allowance-confirm\\\")\\n@severity(\\\"warn\\\")\\n@reason(\\\"Signing a Permit2 token allowance — confirm the spender and amount before approving\\\")\\nforbid(\\n principal,\\n action == Token::Action::\\\"Permit2SignAllowance\\\",\\n resource\\n);\\n\"},{\"id\":\"send-first-time-or-burn-recipient-warn\",\"manifest\":{\"id\":\"send-first-time-or-burn-recipient-warn\",\"schema_version\":2,\"trigger\":{\"where\":{\"action.tag\":{\"eq\":\"erc20_transfer\"}}}},\"policy\":\"// dambi default v2 policy — demand signal: irreversible-typo-fatfinger-send\\n// (Send Safety Set). Coinbase/MetaMask/ENS/Bitdeer all warn that a confirmed\\n// transfer is irreversible; the most certain way to lose funds permanently is a\\n// send to the burn / zero address.\\n//\\n// `recipient` is a base, non-optional `String` field of\\n// `Token::Erc20TransferContext` (same shape as swap-recipient-not-self-deny),\\n// so this judges with NO enrichment and NO policy_rpc — it just compares the\\n// recipient against the known burn-address literals. Addresses are lowercase\\n// hex on both sides (host-normalized), so exact String equality / Set membership\\n// holds. The zero address and 0x…dead have no key holder: a transfer there is\\n// permanent loss, hence `deny` (a hard block, not a one-beat warn).\\n//\\n// NOTE (dedup): the originating signal also bundled a \\\"first-time recipient,\\n// pause\\\" warn. That half duplicates the existing draft\\n// transfer-unknown-recipient-warn (same threat, severity, and external history\\n// Bool) and is intentionally NOT carried here; this policy keeps only the novel\\n// burn-address clause.\\n@id(\\\"send-first-time-or-burn-recipient-warn\\\")\\n@severity(\\\"deny\\\")\\n@reason(\\\"This send goes to a burn address (0x000…000 / 0x…dead) — funds sent there are permanently lost\\\")\\nforbid(principal, action == Token::Action::\\\"Erc20Transfer\\\", resource)\\nwhen {\\n [\\\"0x0000000000000000000000000000000000000000\\\",\\n \\\"0x000000000000000000000000000000000000dead\\\"].contains(context.recipient)\\n};\\n\"},{\"id\":\"swap-recipient-not-self-deny\",\"manifest\":{\"id\":\"swap-recipient-not-self-deny\",\"schema_version\":2,\"trigger\":{\"where\":{\"action.tag\":{\"eq\":\"swap\"}}}},\"policy\":\"// dambi default v2 policy — explorer id: UNI-01 (swap output redirected to\\n// an address other than the signer).\\n//\\n// Drainers dress a swap up as normal but flip only `recipient` to the attacker:\\n// you sell your tokens and someone else receives the proceeds. `recipient` is a\\n// base `String` field of `Amm::SwapContext` and `principal.address` is the\\n// evaluated wallet's address (Wallet entity attr) — both non-optional Strings —\\n// so this compares directly with NO enrichment and NO policy_rpc (sv = COV).\\n// Addresses are lowercase hex on both sides, so exact String equality holds.\\n@id(\\\"swap-recipient-not-self-deny\\\")\\n@severity(\\\"deny\\\")\\n@reason(\\\"This swap sends the bought tokens to an address that is not your wallet\\\")\\nforbid(principal, action == Amm::Action::\\\"Swap\\\", resource)\\nwhen { context.recipient != principal.address };\\n\"},{\"id\":\"unknown-blind-sign-warning\",\"manifest\":{\"id\":\"unknown-blind-sign-warning\",\"schema_version\":2,\"trigger\":{\"where\":{\"action.domain\":{\"eq\":\"unknown\"}}}},\"policy\":\"// dambi default v2 policy — explorer id: GEN-09 (blind / opaque signature).\\n//\\n// Warn whenever an action could not be decoded by any manifest and therefore\\n// surfaces as `Core::Action::\\\"Unknown\\\"` (opaque EIP-712, raw `eth_sign`, or\\n// calldata no adapter matched). Signature-phishing leans on exactly these\\n// undecodable requests, so the wallet should flag them for a human look.\\n//\\n// This is a pure action-shape policy (Aggregator type 8 — Decode\\n// Introspection): the verdict depends only on the decoded action's *identity*,\\n// not on any enriched fact. The manifest therefore declares no `policy_rpc`\\n// calls and no `custom_context`.\\n@id(\\\"unknown-blind-sign-warning\\\")\\n@severity(\\\"warn\\\")\\nforbid(principal, action == Core::Action::\\\"Unknown\\\", resource);\\n\"},{\"id\":\"unlimited-approval-deny\",\"manifest\":{\"id\":\"unlimited-approval-deny\",\"schema_version\":2,\"trigger\":{\"where\":{\"action.tag\":{\"eq\":\"erc20_approve\"}}}},\"policy\":\"// dambi default v2 policy — explorer id: GEN-01 (unlimited ERC20 approval\\n// to a non-allowlisted spender).\\n//\\n// An `approve(spender, amount)` with `amount == U256::MAX` hands `spender` an\\n// open-ended claim on the token — the canonical token-drain primitive.\\n//\\n// STATIC detection: `amount` is a raw lowercase U256 hex String (`{:#x}`, no\\n// leading zeros) on the BASE context, so the two canonical \\\"unlimited\\\" sentinels\\n// are matched by string equality — `uint256::MAX` (64 f's, standard ERC20\\n// max-approve) and `uint160::MAX` (40 f's, the Permit2-style max). No enrichment\\n// is needed, so this works fully offline (server-independent).\\n//\\n// NOTE: this slice was rewritten from the earlier `context.custom.approvalIsUnlimited`\\n// form, which depended on the `approval.unlimited_over_balance` policy_rpc method.\\n// That method is NOT implemented, and its output is `required: true`, so the manifest\\n// tripped `SystemFail` on EVERY approve (deny-all) once the bundle's manifest was\\n// actually shipped. The static check denies ONLY a genuinely unlimited approve and\\n// lets bounded approvals pass. (Over-balance scoring — approve-amount vs holdings —\\n// is dropped here; it needs balance enrichment and returns in a later increment.)\\n//\\n// The allowlist (spenders for whom an unlimited approval is expected) is inlined\\n// as a Cedar set literal; the entry below is the canonical Permit2 contract.\\n// Addresses are lowercase hex.\\n@id(\\\"unlimited-approval-deny\\\")\\n@severity(\\\"warn\\\")\\nforbid(principal, action == Token::Action::\\\"Erc20Approve\\\", resource)\\nwhen {\\n (context.amount == \\\"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\\\"\\n || context.amount == \\\"0xffffffffffffffffffffffffffffffffffffffff\\\")\\n && !([\\\"0x000000000022d473030f116ddee9f6b43ac78ba3\\\"].contains(context.spender))\\n};\\n\"}],\"profile\":\"default\",\"registry_ref\":null,\"sequence\":1}","signature":"jHxIM+r6cAvp/J1MBcN3a6AVbCTDWB37QX37fy+i9Q5q0K5dogHz7rh0Yq45njrsG3dohPcg6fIgIU3ZucfaRA==","key_id":"policy-local-416b1764fb8d"} diff --git a/registryV2/policy-bundles/default/sequence b/registryV2/policy-bundles/default/sequence new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/registryV2/policy-bundles/default/sequence @@ -0,0 +1 @@ +1 diff --git a/registryV2/scripts/deploy/_common.sh b/registryV2/scripts/deploy/_common.sh index 25db7733..f3614943 100755 --- a/registryV2/scripts/deploy/_common.sh +++ b/registryV2/scripts/deploy/_common.sh @@ -22,9 +22,9 @@ set -euo pipefail # --- Identity / resources ----------------------------------------------------- -PROJECT_ID="${PROJECT_ID:-dambi-registry}" -REGION="${REGION:-asia-northeast3}" -BUCKET="${BUCKET:-dambi-registry-v3-seoul}" +PROJECT_ID="${PROJECT_ID:-project-c2aefc18-2bfc-495a-a3d}" +REGION="${REGION:-asia-northeast1}" +BUCKET="${BUCKET:-dambi-registry-v3-unseo}" SA_NAME="${SA_NAME:-registry-api-v3-sa}" SA_EMAIL="${SA_EMAIL:-${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com}" SERVICE_NAME="${SERVICE_NAME:-registry-api-v3}" @@ -84,7 +84,7 @@ REPO_ROOT="$(cd "${RV2_DIR}/.." && pwd)" # repo root # Activate the gcloud config (GCLOUD_CONFIG, default `dambi`) + project, then # assert the active account matches EXPECTED_ACCOUNT (misfire guard). Fatal on -# mismatch. Config map: PROD = config `dambi` / project `dambi-registry`. +# mismatch. Config map: PROD = config `dambi` / project `project-c2aefc18-2bfc-495a-a3d`. rv3_activate_and_guard() { echo "=== gcloud config 활성 + 계정 가드 ===" gcloud config configurations activate "${GCLOUD_CONFIG:-dambi}" >/dev/null diff --git a/registryV2/scripts/deploy/provision-budget.sh b/registryV2/scripts/deploy/provision-budget.sh index 4975ccde..043564ac 100755 --- a/registryV2/scripts/deploy/provision-budget.sh +++ b/registryV2/scripts/deploy/provision-budget.sh @@ -21,7 +21,7 @@ rv3_activate_and_guard : "${BILLING_ACCOUNT:?set BILLING_ACCOUNT=XXXXXX-XXXXXX-XXXXXX (gcloud billing accounts list)}" # The amount's CURRENCY must match the billing account's currency or the API -# rejects it (INVALID_ARGUMENT). The dambi-registry billing account is KRW, so +# rejects it (INVALID_ARGUMENT). The project-c2aefc18-2bfc-495a-a3d billing account is KRW, so # the default is KRW; override BUDGET_AMOUNT= for a different account # (e.g. 50USD). ~100k KRW (~$75) is generous headroom over the warm-instance # baseline so the 50% threshold doesn't false-alarm. diff --git a/registryV2/scripts/deploy/provision-monitoring.sh b/registryV2/scripts/deploy/provision-monitoring.sh index 7ebd3b9b..9c5a1399 100755 --- a/registryV2/scripts/deploy/provision-monitoring.sh +++ b/registryV2/scripts/deploy/provision-monitoring.sh @@ -6,7 +6,7 @@ # delete-then-re-run). See deploy/_common.sh for project/region/service. # # bash registryV2/scripts/deploy/provision-monitoring.sh -# NOTIFICATION_CHANNEL=projects/dambi-registry/notificationChannels/123… \ +# NOTIFICATION_CHANNEL=projects/project-c2aefc18-2bfc-495a-a3d/notificationChannels/123… \ # bash registryV2/scripts/deploy/provision-monitoring.sh # + paging # ERR_5XX_PER_5M=20 LATENCY_P95_MS=1500 bash …/provision-monitoring.sh # @@ -20,8 +20,8 @@ # gcloud components install beta # gcloud beta monitoring channels create --type=email \ # --display-name="registry oncall" \ -# --channel-labels=email_address=you@example.com --project=dambi-registry -# gcloud beta monitoring channels list --project=dambi-registry \ +# --channel-labels=email_address=you@example.com --project=project-c2aefc18-2bfc-495a-a3d +# gcloud beta monitoring channels list --project=project-c2aefc18-2bfc-495a-a3d \ # --format='value(name)' # then pass that resource name via NOTIFICATION_CHANNEL. Without it the policies # are still created (they surface in the console); attach a channel later. diff --git a/registryV2/scripts/deploy/publish-index.sh b/registryV2/scripts/deploy/publish-index.sh index 048b0797..fc51821e 100755 --- a/registryV2/scripts/deploy/publish-index.sh +++ b/registryV2/scripts/deploy/publish-index.sh @@ -62,7 +62,7 @@ fi # Must match the proxy path allowlist (registry-api/src/validation.ts): index / tokens # / bundles / signatures / contexts; manifests uploaded for provenance per v2 convention. # Strip OS cruft before upload so it never pollutes the private bucket. -find bundles contexts tokens signatures manifests index -name '.DS_Store' -delete 2>/dev/null || true +find bundles contexts tokens signatures manifests index policy-bundles -name '.DS_Store' -delete 2>/dev/null || true # Phase 1 — additive upload, LEAVES before POINTERS (no inconsistency window). # index entries are 3-ref docs the proxy resolves by re-reading bundles/ + @@ -71,7 +71,7 @@ find bundles contexts tokens signatures manifests index -name '.DS_Store' -delet # signatures/ goes BEFORE index/ too: the extension derives the sig URL from a # new bundle_sha256 the moment the index lands, so the sig must already be present # or a REQUIRE-on install would 404 its signature during the publish window. -for prefix in bundles contexts tokens signatures manifests index; do +for prefix in bundles contexts tokens signatures manifests policy-bundles index; do if [[ -d "${prefix}" ]]; then echo " rsync (additive) ${prefix}/ → gs://${BUCKET}/${prefix}" gcloud storage rsync --recursive "${prefix}" "gs://${BUCKET}/${prefix}" @@ -84,7 +84,7 @@ done # it prunes in the leaves group alongside bundles, AFTER index. if [[ "${PRUNE:-0}" == "1" ]]; then echo " PRUNE=1 — orphan 객체 삭제 (pointers→leaves 순)" - for prefix in index manifests tokens contexts signatures bundles; do + for prefix in index policy-bundles manifests tokens contexts signatures bundles; do if [[ -d "${prefix}" ]]; then gcloud storage rsync --recursive --delete-unmatched-destination-objects "${prefix}" "gs://${BUCKET}/${prefix}" fi diff --git a/registryV2/scripts/deploy/verify-bucket-parity.sh b/registryV2/scripts/deploy/verify-bucket-parity.sh index a3df4188..f4d35f30 100644 --- a/registryV2/scripts/deploy/verify-bucket-parity.sh +++ b/registryV2/scripts/deploy/verify-bucket-parity.sh @@ -14,7 +14,7 @@ # bash registryV2/scripts/deploy/verify-bucket-parity.sh set -uo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/../.." || exit 1 # registryV2/ -PROD_BUCKET="${PROD_BUCKET:-${BUCKET:-dambi-registry-v3-seoul}}" +PROD_BUCKET="${PROD_BUCKET:-${BUCKET:-dambi-registry-v3-unseo}}" POC_BUCKET="${POC_BUCKET:-}" RC=0 diff --git a/registryV2/scripts/gen-signing-key.ts b/registryV2/scripts/gen-signing-key.ts index 6c071bfc..6be2d06d 100644 --- a/registryV2/scripts/gen-signing-key.ts +++ b/registryV2/scripts/gen-signing-key.ts @@ -8,7 +8,11 @@ * Production keys live in Cloud KMS (never on disk); get the prod pinned key with * `gcloud kms keys versions get-public-key` (SPKI PEM → strip headers → base64). * - * npm run gen-signing-key + * npm run gen-signing-key # decoder-bundle dev key → dev-signing-key.hex + * npm run gen-signing-key -- --policy # policy-bundle dev key → dev-policy-signing-key.hex + * + * The two are deliberately separate keys (ADR 0001): policy bundles served by + * GET /v1/bundle are never signed with the decoder-bundle key. */ import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; @@ -18,7 +22,12 @@ import { publicKeySpkiBase64 } from "./sign-bundles.js"; const HERE = dirname(fileURLToPath(import.meta.url)); const KEYS_DIR = resolve(HERE, "deploy", "keys"); -const KEY_PATH = join(KEYS_DIR, "dev-signing-key.hex"); +const isPolicy = process.argv.includes("--policy"); +const KEY_PATH = join( + KEYS_DIR, + isPolicy ? "dev-policy-signing-key.hex" : "dev-signing-key.hex", +); +const PIN_VAR = isPolicy ? "PINNED_POLICY_PUBLIC_KEY" : "PINNED_BUNDLE_PUBLIC_KEY"; const priv = p256.utils.randomSecretKey(); const privHex = Buffer.from(priv).toString("hex"); @@ -27,5 +36,5 @@ mkdirSync(KEYS_DIR, { recursive: true }); writeFileSync(KEY_PATH, privHex + "\n", "utf8"); console.error(`[gen-signing-key] wrote secret key → ${KEY_PATH} (gitignored, dev only)`); -console.error(`[gen-signing-key] PINNED public key (SPKI base64) — set as PINNED_BUNDLE_PUBLIC_KEY:`); +console.error(`[gen-signing-key] PINNED public key (SPKI base64) — set as ${PIN_VAR}:`); console.log(publicKeySpkiBase64(privHex)); diff --git a/registryV2/scripts/publish-policy-bundle.ts b/registryV2/scripts/publish-policy-bundle.ts new file mode 100644 index 00000000..76687470 --- /dev/null +++ b/registryV2/scripts/publish-policy-bundle.ts @@ -0,0 +1,302 @@ +/** + * publish-policy-bundle — assemble, sign and write ONE policy bundle for + * `GET /v1/bundle` (registry-api). Contract: docs/decisions/0001-cloud-split.md + * and registry-api/openapi.yaml (`BundlePayload`, `SignedPolicyBundle`). + * + * Input: browser-extension/default-bundles//policies//{policy.cedar,manifest.json} + * Output: policy-bundles//.json (immutable — never rewritten) + * policy-bundles//latest.json (same bytes; mutable pointer) + * policy-bundles//sequence (counter, bumped by 1) + * + * Each output is `{ payload, signature, key_id }` where `payload` is the RFC 8785 + * JCS text of the BundlePayload — the exact signed bytes — and `signature` is + * base64 P1363 r||s of ECDSA P-256 over SHA-256(payload). Same pipeline as + * sign-bundles.ts, different key: policy bundles never share the decoder key. + * + * The sequence counter is a repo-committed file rather than a bucket listing so + * a publish is reviewable in a PR and works offline. A rollback is a new, + * higher sequence carrying the old content — content is never mutated in place. + * + * Modes (BUNDLE_SIGNING_MODE): + * local (default) — POLICY_SIGNING_KEY_PATH or scripts/deploy/keys/dev-policy-signing-key.hex + * kms — POLICY_KMS_KEY_NAME (crypto key VERSION). Not provisioned yet: + * the bundle contract moves on-chain in full later, so the KMS + * key is deliberately deferred (ADR 0001, 2026-09-09). + * + * npm run publish:policy-bundle # env=staging, profile=default + * npm run publish:policy-bundle -- --env production + * npm run publish:policy-bundle -- --set day1-safety --profile default --dry-run + */ +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { p256 } from "@noble/curves/nist.js"; +import { derToP1363 } from "./sign-bundles.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REGISTRY_ROOT = resolve(HERE, ".."); +const REPO_ROOT = resolve(REGISTRY_ROOT, ".."); +const DEFAULT_POLICY_SETS = resolve(REPO_ROOT, "browser-extension", "default-bundles"); + +export const MAX_SEQUENCE = Number.MAX_SAFE_INTEGER; +const PROFILE_RE = /^[a-z0-9-]+$/; +const ENVS = ["staging", "production"] as const; +type Env = (typeof ENVS)[number]; + +export interface PolicyEntry { + id: string; + policy: string; + manifest: Record; +} + +export interface BundlePayload { + policies: PolicyEntry[]; + sequence: number; + issued_at: number; + expires_at: number | null; + env: Env; + profile: string; + registry_ref: string | null; +} + +export interface SignedPolicyBundle { + payload: string; + signature: string; + key_id: string; +} + +export interface PublishOptions { + /** directory holding /{policy.cedar,manifest.json} */ + policiesDir: string; + /** directory holding / — defaults to registryV2/policy-bundles */ + outRoot?: string; + profile?: string; + env?: Env; + issuedAt?: number; + mode?: "local" | "kms"; + privKeyHex?: string; + kmsKeyName?: string; + keyId?: string; + dryRun?: boolean; + log?: (msg: string) => void; +} + +export interface PublishResult { + sequence: number; + payload: BundlePayload; + signed: SignedPolicyBundle; + written: string[]; +} + +// ---- input ------------------------------------------------------------------ + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** Read every /{policy.cedar,manifest.json} pair; enforce the BundlePayload + * invariants the SDK will check (non-empty, unique ids, id === manifest.id, + * non-empty manifest) here so a bad bundle never gets signed. */ +export function loadPolicies(policiesDir: string): PolicyEntry[] { + if (!existsSync(policiesDir)) { + throw new Error(`policies dir not found: ${policiesDir}`); + } + const entries: PolicyEntry[] = []; + const seen = new Set(); + const dirs = readdirSync(policiesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort(); + for (const id of dirs) { + const dir = join(policiesDir, id); + const cedarPath = join(dir, "policy.cedar"); + const manifestPath = join(dir, "manifest.json"); + if (!existsSync(cedarPath) || !existsSync(manifestPath)) { + throw new Error(`policy ${id}: missing policy.cedar or manifest.json`); + } + const policy = readFileSync(cedarPath, "utf8"); + if (policy.trim().length === 0) throw new Error(`policy ${id}: empty policy.cedar`); + const manifest: unknown = JSON.parse(readFileSync(manifestPath, "utf8")); + if (!isRecord(manifest) || Object.keys(manifest).length === 0) { + throw new Error(`policy ${id}: manifest must be a non-empty object`); + } + if (manifest.id !== id) { + throw new Error(`policy ${id}: manifest.id (${String(manifest.id)}) !== directory name`); + } + if (seen.has(id)) throw new Error(`policy ${id}: duplicate id`); + seen.add(id); + entries.push({ id, policy, manifest }); + } + if (entries.length === 0) throw new Error(`no policies under ${policiesDir}`); + return entries; +} + +// ---- sequence counter --------------------------------------------------------- + +export function readSequence(counterPath: string): number { + if (!existsSync(counterPath)) return 0; + const raw = readFileSync(counterPath, "utf8").trim(); + if (!/^(0|[1-9][0-9]*)$/.test(raw)) { + throw new Error(`sequence counter ${counterPath} is not a non-negative integer: "${raw}"`); + } + const n = Number(raw); + if (n > MAX_SEQUENCE) throw new Error(`sequence counter exceeds safe-integer range`); + return n; +} + +// ---- signing ---------------------------------------------------------------- + +type CanonicalizeFn = (value: unknown) => string | undefined; +async function canonical(value: unknown): Promise { + const mod = (await import("canonicalize")) as { default: CanonicalizeFn }; + const out = mod.default(value); + if (typeof out !== "string") throw new Error("payload canonicalization failed"); + return out; +} + +function privKeyBytes(hex: string): Uint8Array { + const h = hex.trim().replace(/^0x/, ""); + if (!/^[0-9a-fA-F]{64}$/.test(h)) { + throw new Error("policy signing key must be 32-byte hex (64 chars)"); + } + return Uint8Array.from(Buffer.from(h, "hex")); +} + +function readLocalKey(): string { + const path = + process.env.POLICY_SIGNING_KEY_PATH ?? + join(REGISTRY_ROOT, "scripts", "deploy", "keys", "dev-policy-signing-key.hex"); + if (!existsSync(path)) { + throw new Error( + `local policy signing key not found at ${path}. Generate one with: npm run gen-signing-key -- --policy`, + ); + } + return readFileSync(path, "utf8"); +} + +function localKeyId(priv: Uint8Array): string { + const pub = p256.getPublicKey(priv, false); + return "policy-local-" + createHash("sha256").update(pub).digest("hex").slice(0, 12); +} + +async function signDigest( + digest: Uint8Array, + opts: PublishOptions, +): Promise<{ sig: Uint8Array; keyId: string }> { + const mode = opts.mode ?? (process.env.BUNDLE_SIGNING_MODE === "kms" ? "kms" : "local"); + if (mode === "local") { + const priv = privKeyBytes(opts.privKeyHex ?? readLocalKey()); + return { + sig: p256.sign(digest, priv, { prehash: false }), + keyId: opts.keyId ?? localKeyId(priv), + }; + } + const keyName = opts.kmsKeyName ?? process.env.POLICY_KMS_KEY_NAME; + if (!keyName) throw new Error("kms mode requires POLICY_KMS_KEY_NAME (key version resource name)"); + const { KeyManagementServiceClient } = await import("@google-cloud/kms"); + const client = new KeyManagementServiceClient(); + const [resp] = await client.asymmetricSign({ name: keyName, digest: { sha256: Buffer.from(digest) } }); + if (!resp.signature) throw new Error(`KMS returned no signature for ${keyName}`); + return { sig: derToP1363(Uint8Array.from(resp.signature as Buffer)), keyId: opts.keyId ?? keyName }; +} + +/** Sign a BundlePayload → `{payload: , signature, key_id}`. Exported so + * a verifier test can round-trip it without touching the filesystem. */ +export async function signPayload( + payload: BundlePayload, + opts: PublishOptions, +): Promise { + const text = await canonical(payload); + const digest = Uint8Array.from(createHash("sha256").update(text, "utf8").digest()); + const { sig, keyId } = await signDigest(digest, opts); + return { + payload: text, + signature: Buffer.from(sig).toString("base64"), + key_id: keyId, + }; +} + +// ---- main ------------------------------------------------------------------- + +export async function publishPolicyBundle(opts: PublishOptions): Promise { + const log = opts.log ?? (() => {}); + const profile = opts.profile ?? "default"; + const env = opts.env ?? "staging"; + if (!PROFILE_RE.test(profile)) throw new Error(`profile must match ${PROFILE_RE}: "${profile}"`); + if (!ENVS.includes(env)) throw new Error(`env must be one of ${ENVS.join("|")}: "${env}"`); + + const outRoot = opts.outRoot ?? join(REGISTRY_ROOT, "policy-bundles"); + const profileDir = join(outRoot, profile); + const counterPath = join(profileDir, "sequence"); + + const policies = loadPolicies(opts.policiesDir); + const previous = readSequence(counterPath); + const sequence = previous + 1; + if (sequence > MAX_SEQUENCE) throw new Error("sequence would exceed safe-integer range"); + const versionPath = join(profileDir, `${sequence}.json`); + if (existsSync(versionPath)) { + throw new Error(`${versionPath} already exists — sequence ${sequence} was issued; counter is behind`); + } + + const payload: BundlePayload = { + policies, + sequence, + issued_at: opts.issuedAt ?? Math.floor(Date.now() / 1000), + expires_at: null, + env, + profile, + registry_ref: null, + }; + const signed = await signPayload(payload, opts); + const bytes = JSON.stringify(signed) + "\n"; + + const written: string[] = []; + if (!opts.dryRun) { + mkdirSync(profileDir, { recursive: true }); + writeFileSync(versionPath, bytes, "utf8"); + writeFileSync(join(profileDir, "latest.json"), bytes, "utf8"); + writeFileSync(counterPath, `${sequence}\n`, "utf8"); + written.push(versionPath, join(profileDir, "latest.json"), counterPath); + } + log( + `[publish-policy-bundle] profile=${profile} env=${env} sequence=${sequence} policies=${policies.length} key_id=${signed.key_id}${opts.dryRun ? " (dry-run, nothing written)" : ""}`, + ); + return { sequence, payload, signed, written }; +} + +// ---- CLI -------------------------------------------------------------------- + +function parseArgs(argv: string[]): { set: string; profile: string; env: Env; dryRun: boolean } { + const out = { set: "day1-safety", profile: "default", env: "staging" as Env, dryRun: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--dry-run") out.dryRun = true; + else if (a === "--set") out.set = argv[++i] ?? out.set; + else if (a === "--profile") out.profile = argv[++i] ?? out.profile; + else if (a === "--env") out.env = (argv[++i] ?? out.env) as Env; + else throw new Error(`unknown argument: ${a}`); + } + return out; +} + +const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + const args = parseArgs(process.argv.slice(2)); + publishPolicyBundle({ + policiesDir: join(DEFAULT_POLICY_SETS, args.set, "policies"), + profile: args.profile, + env: args.env, + dryRun: args.dryRun, + log: (m) => console.error(m), + }).catch((e) => { + console.error(`[publish-policy-bundle] FAILED: ${e instanceof Error ? e.message : String(e)}`); + process.exit(1); + }); +}