diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index e6362a7..f200eb0 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,28 +1,26 @@ @@ -51,15 +49,23 @@ DX regression is **blocking, at the same tier as the Security Requirements**. ### II. The three execution contexts are sacred Every example and design decision MUST respect the split (spec §0): (1) the MCP tool handler runs ONCE when checkout is requested and only mints the link + reports requirements — there is no phone in the loop, so it -MUST NOT perform a credential ceremony; (2) the checkout page/phone is where the gates actually run; (3) a -poll reports completion. Conflating these contexts is the documented root cause of confusion and is -forbidden. +MUST NOT perform a credential ceremony. It MAY, however, **verify a pre-existing delegation grant** minted +in an earlier, separate live ceremony: redeeming a grant is verification of consent already given, not a +new ceremony, and MUST run entirely server-side against the full gate chain. (2) the checkout page/phone is +where ceremonies actually run — including the **delegate ceremony** that mints a grant; (3) a poll reports +completion. Conflating these contexts is the documented root cause of confusion and is forbidden. ### III. Consolidated checkout flow Checkout MUST be one handoff: the buyer opens the link once and completes all verifications and payment in a single browser session. The agent orchestrates URLs and polls; it MUST NOT perform the ceremony. A blocking-tool mode (for page-less tools) is roadmap, not v0.1. +These rules govern **human-present** flows. A human-not-present redemption has, by definition, no browser +session and no live buyer; its consolidation invariant is instead: **one delegate ceremony** (itself a +single consolidated handoff, per this principle) authorizes a bounded set of later redemptions, and **every +redemption runs the full server-side gate chain on every completion path** (Security Requirements). The +agent still only orchestrates and polls; it MUST NOT hold or perform authorization itself. + ### IV. One ordered, conditional policy array Gates MUST be expressed as a single ordered array: array position is run order, payment MUST settle last, and the payment amount MUST be derived server-side from the order — never passed as a field. Gates are @@ -80,10 +86,17 @@ a flat data manifest (`[{ credential, required, effect, label, minAge? }]`). Fun wire; `requirements()` is the code→data boundary. ### VII. Honesty in the types; prefer simplicity -Status MUST be carried in types, not prose: `enforcedAt: "tool" | "checkout"` and -`trust_level: "presence-only-demo" | "issuer-verified"`. v0.1 is presence-only (disclosure + nonce -binding, NOT issuer/device signatures) and MUST be fenced as a demonstration, never sold as a real safety -control. Prefer simplicity — defer complexity (e.g. real mdoc-verifier integration) rather than overbuild. +Status MUST be carried in types, not prose: `enforcedAt: "tool" | "checkout" | "intent"`, an orthogonal +**`presence`** axis (`"live" | "delegated-demo" | "delegated"`) carrying *when consent happened*, and +`trust_level` (`"presence-only-demo" | "server-issued-demo" | "issuer-verified"`) carrying *how strongly +the authorization is bound* — the live-ceremony/nonce connotation lives on the presence axis, not in +`trust_level`. `"server-issued-demo"` is WEAKER than `"presence-only-demo"`: it proves issuance only, not +user authorization. Every `presence: "delegated-demo"` surface MUST carry a non-empty `disclaimer`, MUST +NOT expose any `authorizedByUser`-style field, and MUST NOT settle real value. A real HNP control requires +`presence: "delegated"` AND `trust_level: "issuer-verified"`. v0.1 human-present rails remain +presence-only (disclosure + nonce binding, NOT issuer/device signatures) and MUST be fenced as a +demonstration, never sold as a real safety control. Prefer simplicity — defer complexity (e.g. real +mdoc-verifier integration) rather than overbuild. ## Security Requirements @@ -105,6 +118,15 @@ Load-bearing controls; a change that breaks one is blocking even in demo code (m Until cryptographic mdoc trust verification (issuer/device signatures) lands, any gate relying on it MUST be fenced behind a demo-only mode and MUST NOT be presented as a real safety control. +## Terminology & Retained-Primitive Rulings + +- **"envelope"** refers ONLY to the Mode-B `verification_required` wire envelope (`envelope.ts`). A + standing HNP authorization is a **grant** (typed `ap2.IntentMandate`); the merchant's standing + delegation config is the **delegation policy**. The phrase "spending envelope" MUST NOT be used. +- **Mode-B `gated()`** and the wire envelope are **retained unchanged** as the page-less blocking + primitive, decoupled from HNP. Async step-up (an HNP redeem answering with a blocking envelope) remains + roadmap — explicitly out of 005's scope. + ## Development Workflow & Quality Gates - **Spec-grounded.** The spec and docs MUST cite real code (file/line). Claims that drift from the code are @@ -126,4 +148,4 @@ Versioning (semantic): **MAJOR** — backward-incompatible principle removal or new principle/section or materially expanded guidance; **PATCH** — clarifications and wording. Runtime guidance for agents lives in `CLAUDE.md` and `specs/001-attesto-sdk/spec.md`. -**Version**: 1.1.0 | **Ratified**: 2026-06-25 | **Last Amended**: 2026-07-10 +**Version**: 1.2.0 | **Ratified**: 2026-06-25 | **Last Amended**: 2026-07-10 diff --git a/STATUS.md b/STATUS.md index 6e92bd2..1f6ad60 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,7 +1,7 @@ # Project Status — CredentAgent _Single source of truth for what's done, what's next, and what's waiting on you._ -_Updated **2026-07-08** · rename shipped (#37): repos + packages live as CredentAgent · CI green · 247 tests pass._ +_Updated **2026-07-10** · rename shipped · 0.2.0 published · 005 seams + `DelegatedGate` facade (#41, awaiting merge) · DX gate merged (#43) · constitution 1.2.0 · 288 tests pass._ > **How this file works.** Read it at the start of every working session and update it at the end. It is > decisions-first: "Decisions for you" (each a checkbox + recommendation), then In flight / next, a rolling @@ -19,21 +19,20 @@ _Updated **2026-07-08** · rename shipped (#37): repos + packages live as Creden us; that window is the accepted risk. - [ ] **Add the `CLAUDE_CODE_OAUTH_TOKEN` secret** + a `claude-code-review.yml` workflow if you want the automated PR review (the org-managed review also covers it). -- [ ] **005 sequencing fork — decision memo ready to ratify (2026-07-03).** Ship merchant-side v0.1 - (server-HMAC) first, or re-scope 005 to wallet-custody directly? Full analysis + - recommendation in [`sequencing-fork-memo.md`](specs/005-human-not-present/sequencing-fork-memo.md). - **Recommendation: Option B (wallet-custody directly), seams-first** — the spike (Runs 1–5, incl. a - green settlement) retired Option A's de-risking rationale, so A now only ships a merchant-side minting - rail the wallet model obsoletes. Build the shared gate seams first (envelope, completeOrder branch, - revocation store, typed refusals), then the wallet server (the one new backend). Not urgent to - *execute* (005 builds on 004 → publish → rename, deferred a week), but ratifying unblocks the 005 plan. -- [ ] **Confirm the 005 Group-A decisions** (D1–D3, still *tentative* per the 2026-07-01 discussion) + the - Decision-13 constitution amendment — both gate `/speckit-plan` → `/speckit-implement` for 005. +- [ ] **Approve + merge [PR #41](https://github.com/openmobilehub/credentagent/pull/41)** — 005 seams + + `DelegatedGate` facade. All checks green (DCO · build-test · claude-review), MERGEABLE, reconciled with + `main` (constitution folded to 1.2.0). Blocked only on your approving review. +- [x] **005 Group-A (D1–D3) + sequencing (Option B) + Decision-13 — RATIFIED/DONE 2026-07-08** (see Done). --- ## 🔨 In flight / next +- **005 NEXT INCREMENT (after #41 merges): the HTTP intent rail → the wallet server.** The seams + facade + land in #41; the next pieces turn them into the live "delegate on your phone → agent buys while you sleep" + demo: a `ceremony/intent/` rail (mirroring the `dcql`/`request`/`verify`/`page`/`routes` split), then the + wallet server (`credentagent-wallet`, Kotlin/JVM — the one new backend, likely a sibling repo). Wants a + short `/speckit-plan` pass. Both were explicitly out of #41's scope. - **AP2 v2 alignment — captured as [#39](https://github.com/openmobilehub/credentagent/issues/39) + [#40](https://github.com/openmobilehub/credentagent/issues/40)** (2026-07-08), prompted by AP2-team feedback (Yanhe Chen, Google, Discord 2026-06-02: v1-shaped mandate, unsigned amount, unverified issuer/deviceAuth). @@ -119,6 +118,9 @@ _Updated **2026-07-08** · rename shipped (#37): repos + packages live as Creden | What | Where | | :-- | :-- | +| **DX rubric is now a BINDING review gate + constitution 1.2.0** — `architecture-principles.md` gains Principle 12 ("the example IS the DX test") + the `DelegatedGate` golden before/after; CLAUDE.md DX section at the security-invariant tier; automated review checks it. Folds with the HNP amendment (II/III/VII) into constitution v1.2.0. | [#43](https://github.com/openmobilehub/credentagent/pull/43) | +| **005 ratified + first increment built** — Group-A D1–D3 + sequencing **Option B** + Decision-13 (constitution amendment) all ratified 2026-07-08; the shared gate seams (`checkDraw`, `RevocationStore`, `completeOrder` draw branch, typed refusals) + the Stripe-grade **`DelegatedGate`** facade + a 36-line example, **288 tests** | [PR #41](https://github.com/openmobilehub/credentagent/pull/41) (awaiting merge) | +| **HNP §12 spikes COMPLETE** — on-device (Runs 1–5, incl. a green settlement) + headless-auth (PASS: claude.ai routines carry the unattended leg; two setup gates found) | `specs/005-…` | | **Rename EXECUTED: AttestoMCP → CredentAgent (2026-07-08)** — library ([PR #38](https://github.com/openmobilehub/credentagent/pull/38), 132 files, verified live both custody modes), GitHub repos renamed (`credentagent`, `credentagent-website`), website content ([credentagent-website#8](https://github.com/openmobilehub/credentagent-website/pull/8), Pages live), #31 retrofitted via the committed rename script | [#37](https://github.com/openmobilehub/credentagent/issues/37) | | **Published `0.2.0` as `@openmobilehub/credentagent-*`** (release `v0.2.0-credentagent` → CI publish with provenance; `NPM_TOKEN` secret set). Full deprecation chain: `attesto-*` + `attestomcp-*` all point at `credentagent-*` | npm | | `AttestoMcp` → `AttestoMCP` brand-casing rename (class, options type, ~171 sites across code + docs), version bumped `0.1.0` → `0.2.0` *(historical — pre-CredentAgent)* | [#26](https://github.com/openmobilehub/credentagent/issues/26) | @@ -134,3 +136,4 @@ _Updated **2026-07-08** · rename shipped (#37): repos + packages live as Creden - **Honesty:** `trust_level` stays `presence-only-demo` for the OpenID4VP rails (real wire crypto, no issuer trust anchor yet) — never sold as a real safety control. A pro trademark search is advised before publish. - **DCO** `git commit -s` on every commit; bypass tests must fail with their control removed. +- **DX is Stripe-grade or it's a request-changes** (constitution 1.2.0 Principle I + `docs/reference/architecture-principles.md`) — the example IS the DX test: if it needs a plumbing block, fix the API, not the example. Same blocking tier as the security invariants. diff --git a/examples/README.md b/examples/README.md index ce7710b..b39b24e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,26 @@ # CredentAgent examples +Each is runnable against the two `@openmobilehub/credentagent-*` packages (build them first: +`npm run build:packages`). Grouped by what they show: + +**Storefront + gate** (connect to Goose / any MCP host) +- [`storefront.mjs`](#storefrontmjs--a-credential-gated-storefront-in-8-lines) — a credential-gated storefront in ~8 lines +- [`custom-credential.mjs`](#custom-credentialmjs--gate-any-action-with-any-credential) — gate any action with **any** credential (Principle V) +- [`with-x402-settlement.mjs`](#with-x402-settlementmjs--settle-payment-on-chain-via-the-settle-seam) — settle payment on-chain via the `settle` seam +- [`storefront-redis.mjs`](storefront-redis.mjs) / [`storefront-firestore.mjs`](storefront-firestore.mjs) — injectable persistence (Upstash Redis stores / Firestore catalog source) +- [`run-storefront/`](run-storefront/) — run THIS repo's storefront directly (stateful + stateless side by side) + +**Gating patterns** (identity-first, beyond commerce) +- [`gate-any-action.mjs`](#gate-any-actionmjs--gate-a-non-commerce-action-identity-first-no-checkout) — gate a non-commerce action, no checkout + +**Cart Mandate / stateless** (004) +- [`stateless-orders/`](stateless-orders/) — the created order rides in a signed Cart Mandate on the link + +**Human-not-present** (005, preview) +- [`hnp-draws/`](hnp-draws/) — the delegated-draw "doorman": one pre-approval, good + bad draws, all decided server-side + +--- + ## `storefront.mjs` — a credential-gated storefront in ~8 lines A minimal, runnable agentic storefront you add to **Goose** (or any MCP host) as an HTTP connector and diff --git a/examples/hnp-draws/README.md b/examples/hnp-draws/README.md new file mode 100644 index 0000000..c07b56a --- /dev/null +++ b/examples/hnp-draws/README.md @@ -0,0 +1,45 @@ +# `hnp-draws/` — the human-not-present "doorman" in action (005 seams, preview) + +Watch the [PR #41](https://github.com/openmobilehub/credentagent/pull/41) HNP seams decide, with **no web +page and no phone** — a self-contained script that mints a **pre-approval** (an Intent Mandate) and throws +good + bad purchases (**draws**) at `completeOrder`, printing what the gate allows and refuses. + +## Run it + +```bash +npm run build:packages # build the two @openmobilehub/credentagent-* packages +node examples/hnp-draws/demo.mjs +``` + +## What you see + +``` +🎫 Pre-approval: "Reorder Blue Bottle coffee, up to $40, this month" +✅ 1 bag of coffee ($18) → COMPLETED (no real money moved) +⛔ reuse same transaction → REFUSED: replay +⛔ $54 cart, over the cap → REFUSED: over-cap, over-total +⛔ different store (Starbucks) → REFUSED: out-of-scope +⛔ wine on a coffee approval → REFUSED: step-up (age is never delegable) +🔴 you revoke from your phone… +⛔ another reorder → REFUSED: revoked +``` + +## What it proves + +- **The gate is the doorman, not the agent.** Every draw is re-checked server-side in `completeOrder` — + bounds (`checkDraw`), revocation, and an **atomic single-use** consume — so a producer reaching the seam + directly is still fully checked (invariant 1). Refusals are **typed data** (a `code` per failure), not prose. +- **One pre-approval = one purchase.** Reusing a transaction id is refused; two racing draws yield exactly one + completion. +- **Age is non-delegable.** An age-restricted line always steps up to a live ceremony — a grant can never + complete it. +- **Revocation is immediate + fail-closed.** Revoke, and the next draw is refused; an unreachable revocation + store refuses too (never fail-open). + +## Honest limits + +The wire crypto is **real** (ES256 over the canonical draw; content-addressed `intentId`), but v0.1 has **no +issuer/DeviceKey trust anchor and no per-draw proof-of-possession** — the grant is a bearer instrument, +demo-fenced (`presence: "delegated-demo"`, `trust_level: "server-issued-demo"`), and **no real money moves**. +The user ceremony that seals the bounds on a phone, the HTTP intent rail, and the wallet server that provide a +*real* control are later increments (spec.md Out of Scope). This script is that flow's logic, minus the web. diff --git a/examples/hnp-draws/demo.mjs b/examples/hnp-draws/demo.mjs new file mode 100644 index 0000000..9f11ffa --- /dev/null +++ b/examples/hnp-draws/demo.mjs @@ -0,0 +1,38 @@ +// CredentAgent — a pre-approval your AI agent can spend against, but can't abuse. +// +// You pre-approve ONCE; your agent then shops on its own while you sleep. It never holds a +// blank cheque — every purchase is re-checked against your limits and refused if it breaks +// one, in plain terms. Revoke and the next one dies. A demo — no real money moves. +// Run: node examples/hnp-draws/demo.mjs +import { DelegatedGate } from "@openmobilehub/credentagent-gate"; + +// 1. Configure the gate once with your priced catalog. (Like `new Stripe(key)`.) +const gate = new DelegatedGate({ + catalog: { coffee: 18, wine: { price: 20, minAge: 21 } }, +}); + +// 2. Pre-approve once. Your agent holds the returned grant and shops while you sleep. 😴 +const grant = await gate.preApprove({ + merchant: "blue-bottle", + perOrder: 30, // no single order over $30 + total: 100, // and $100 total before the grant is spent out +}); +console.log("\n🎫 Pre-approved: coffee at blue-bottle, up to $30/order. Off to sleep. 😴\n"); + +// 3. Your agent spends. Each spend() returns { ok, amount, remaining, reason? } — no throwing. +// Two real purchases draw the $100 down; the rest are refused, each with a reason. +await attempt("1 coffee", { paymentId: "c1", item: "coffee" }); +await attempt("another coffee (new id)", { paymentId: "c2", item: "coffee" }); +await attempt("reuse c1 — double-spend", { paymentId: "c1", item: "coffee" }); +await attempt("3 coffees at once", { paymentId: "c3", item: "coffee", quantity: 3 }); +await attempt("coffee — different store", { paymentId: "c4", item: "coffee", merchant: "starbucks" }); +await attempt("wine — age-restricted", { paymentId: "c5", item: "wine" }); +await grant.revoke(); // you change your mind, from your phone +await attempt("1 coffee — after revoke", { paymentId: "c6", item: "coffee" }); + +// Attempt one purchase and print the verdict (demo output only — not part of the API). +async function attempt(label, purchase) { + const { ok, amount, remaining, reason } = await grant.spend(purchase); + const verdict = ok ? `approved — $${remaining} of $100 left` : `refused — ${reason}`; + console.log(` ${ok ? "✅" : "⛔"} ${label.padEnd(26)} $${String(amount).padStart(2)} ${verdict}`); +} diff --git a/package.json b/package.json index 3710ae4..b0f4b97 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ ], "scripts": { "build": "npm run build --workspaces --if-present", + "build:packages": "npm run build --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present", "test": "vitest run" }, diff --git a/packages/credentagent-gate/README.md b/packages/credentagent-gate/README.md index 56bb83b..acfe68e 100644 --- a/packages/credentagent-gate/README.md +++ b/packages/credentagent-gate/README.md @@ -136,6 +136,23 @@ cryptography. The mandate is AP2-shaped and dev-signed (integrity hash), not key > **`verification_required`** envelope the agent *drives* (which credential, a per-order approve link, > the tool to poll) instead of completing — the retained blocking **Mode B** primitive. +## Delegated draws — human-not-present seams (005, preview) + +The gate exposes the first HNP increment: **signer-agnostic seams** for redeeming a user-sealed +**Intent Mandate** (a bounded, revocable delegation) with no live human — `sealIntent` / `checkDraw` +(pure, total, typed refusals), a `RevocationStore` (per-intent + subject kill-switch, atomic +single-use consume), and an additive, fail-closed **draw branch** in `completeOrder` that re-runs +every bounds + revocation check server-side, writes a `delegationId`, and **suppresses settlement**. +Age is **non-delegable** — an age-restricted cart always steps up to a live ceremony. + +Honesty (Principle VII, constitution v1.1.0): draws carry a **`presence`** axis (`"delegated"` / +`"delegated-demo"`) — *when* consent happened — separate from `trust_level` — *how strongly it's +bound*. The wire crypto is **real** (ES256 over the canonical draw; content-addressed `intentId`), but +v0.1 has **no issuer/DeviceKey trust anchor and no per-draw proof-of-possession** — the grant is +effectively a bearer instrument, fenced as a demo. A *real* HNP control requires `presence: +"delegated"` **and** `trust_level: "issuer-verified"`; the HTTP intent rail + the wallet server that +provide those are later increments. + ## API surface (v0.1) ```ts diff --git a/packages/credentagent-gate/src/ceremony/completion.test.ts b/packages/credentagent-gate/src/ceremony/completion.test.ts index 7829dcd..052c093 100644 --- a/packages/credentagent-gate/src/ceremony/completion.test.ts +++ b/packages/credentagent-gate/src/ceremony/completion.test.ts @@ -46,8 +46,9 @@ function harness(opts: { settle?: CompletionContext["settle"] } = {}): Harness { ...(opts.settle ? { settle: opts.settle } : {}), }; const input: Harness["input"] = (items, over = {}) => { - const order = catalog.createOrder(items, over.order?.id ?? "ORD-1", {}); - return { order, mandateId: "m", amount: order.total, currency: "USD", method: "test", gates: [{ gate: "g", pass: true, detail: "" }], ...over }; + const { order: overOrder, ...rest } = over; + const order = catalog.createOrder(items, overOrder?.id ?? "ORD-1", {}); + return { order, mandateId: "m", amount: order.total, currency: "USD", method: "test", gates: [{ gate: "g", pass: true, detail: "" }], ...rest }; }; return { ctx, records, store, cleared, input }; } @@ -123,3 +124,155 @@ describe("completeOrder — core controls (direct seam tests)", () => { expect(h.cleared.count).toBe(1); }); }); + +// ── HNP delegated-draw branch (005 FR-006/009) — bypass tests, each control-dependent. +import { MemoryRevocationStore } from "./revocation.js"; +import { sealIntent, signDraw, generateDelegate, type IntentBounds, type Draw } from "./mandate.js"; + +async function drawFixture(over: Partial = {}) { + const { privateKey, delegate } = await generateDelegate(); + const intent = await sealIntent({ + type: "credentagent.IntentBounds/v0", + merchants: ["utopia-marketplace"], + currency: "USD", + maxAmount: 120, + totalAmount: 120, + stepUpOver: 500, + delegate, + mayPresent: [], + presence: "delegated", + trust_level: "issuer-verified (demo PKI)", + ...over, + }); + const mkDraw = (amount: number, pspTransactionId = "tx_1") => + signDraw( + { type: "credentagent.Draw/v0", intentId: intent.intentId, paymentMandateId: "d", merchant: "utopia-marketplace", amount, currency: "USD", pspTransactionId }, + privateKey, + ); + return { intent, mkDraw, privateKey, delegate }; +} + +describe("completeOrder — HNP delegated-draw branch (bypass tests)", () => { + it("in-bounds draw completes: delegationId written, settle NOT called", async () => { + const settle = vi.fn(async () => ({ network: "demo", txId: "x", status: "ok" }) as SettlementRecordLike); + const h = harness({ settle }); + const rev = new MemoryRevocationStore(); + const { intent, mkDraw } = await drawFixture(); + const draw = await mkDraw(20); // 2 widgets @ 10 + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw } }), { ...h.ctx, revocation: rev }); + expect(res.completed).toBe(true); + expect(res.delegationId).toBe(intent.intentId); + expect(h.records.get("ORD-1")?.delegationId).toBe(intent.intentId); + expect(settle).not.toHaveBeenCalled(); // ← settlement suppression is the control (FR-014) + }); + + it("BYPASS: an over-cap draw submitted DIRECTLY to completeOrder is refused, nothing recorded", async () => { + const h = harness(); + const rev = new MemoryRevocationStore(); + const { intent, mkDraw } = await drawFixture({ maxAmount: 15 }); + const draw = await mkDraw(20); + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw } }), { ...h.ctx, revocation: rev }); + expect(res.completed).toBe(false); + expect(res.reason).toBe("draw"); + expect(res.refusals?.some((r) => r.code === "over-cap")).toBe(true); + expect(h.records.size).toBe(0); + }); + + it("BYPASS: a tampered-signature draw is refused", async () => { + const h = harness(); + const rev = new MemoryRevocationStore(); + const { intent, mkDraw } = await drawFixture(); + const draw = { ...(await mkDraw(20)), amount: 5 } as Draw; // edit after signing + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw } }), { ...h.ctx, revocation: rev }); + expect(res.completed).toBe(false); + expect(res.refusals?.some((r) => r.code === "signature")).toBe(true); + }); + + it("BYPASS: a revoked grant is refused (revocation is the control)", async () => { + const h = harness(); + const rev = new MemoryRevocationStore(); + const { intent, mkDraw } = await drawFixture(); + rev.revoke(intent.intentId); + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw: await mkDraw(20) } }), { ...h.ctx, revocation: rev }); + expect(res.completed).toBe(false); + expect(res.refusals?.[0]?.code).toBe("revoked"); + }); + + it("TOCTOU: revocation landing before the seam's own check still refuses", async () => { + const h = harness(); + const rev = new MemoryRevocationStore(); + const { intent, mkDraw } = await drawFixture(); + const draw = await mkDraw(20); + // Simulate a revoke that lands between an upstream rail-verify and completeOrder: the + // seam re-checks isRevoked itself, so it refuses regardless of any prior pass. + rev.revoke(intent.intentId); + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw } }), { ...h.ctx, revocation: rev }); + expect(res.completed).toBe(false); + expect(res.refusals?.[0]?.code).toBe("revoked"); + }); + + it("FAIL-CLOSED: an unreachable revocation store refuses (never fail-open)", async () => { + const h = harness(); + const throwing = { isRevoked: () => { throw new Error("down"); }, revoke() {}, revokeSubject() {}, priorDraws() { return []; }, commitDraw() { return { ok: true } as const; } }; + const { intent, mkDraw } = await drawFixture(); + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw: await mkDraw(20) } }), { ...h.ctx, revocation: throwing }); + expect(res.completed).toBe(false); + expect(res.refusals?.[0]?.code).toBe("revocation-unavailable"); + }); + + it("FAIL-CLOSED: a draw with no revocation store configured is refused", async () => { + const h = harness(); + const { intent, mkDraw } = await drawFixture(); + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw: await mkDraw(20) } }), h.ctx); + expect(res.completed).toBe(false); + expect(res.refusals?.[0]?.code).toBe("revocation-unavailable"); + }); + + it("ATOMIC single-use: two draws (one pspTransactionId, two order ids) ⇒ exactly one completes", async () => { + const h = harness(); + const rev = new MemoryRevocationStore(); + const { intent, mkDraw } = await drawFixture(); + const a = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, order: { id: "ORD-A" } as CompletionInput["order"], draw: { intent, draw: await mkDraw(20, "tx_same") } }), { ...h.ctx, revocation: rev }); + const b = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, order: { id: "ORD-B" } as CompletionInput["order"], draw: { intent, draw: await mkDraw(20, "tx_same") } }), { ...h.ctx, revocation: rev }); + expect([a.completed, b.completed].filter(Boolean)).toHaveLength(1); + const loser = a.completed ? b : a; + // Two layers defend single-use: run sequentially, checkDraw's replay guard catches the + // reused pspTransactionId (priorDraws now holds the winner) BEFORE commitDraw; a true + // concurrent race that clears checkDraw is then stopped by the atomic commitDraw + // ("consumed"). Either refusal proves single-use held. + expect(["replay", "consumed"]).toContain(loser.refusals?.[0]?.code); + }); + + it("AGE NON-DELEGABLE: an age-restricted cart via a draw is refused step-up even with ageVerified set", async () => { + const h = harness(); + const rev = new MemoryRevocationStore(); + h.store.write("ORD-1", { ageVerified: true } as never); // even WITH a snapshot… + const { intent, mkDraw } = await drawFixture({ maxAmount: 100 }); + const draw = await mkDraw(20); // 1 wine @ 20 + const res = await completeOrder(h.input([{ productId: "wine", quantity: 1 }], { amount: 20, draw: { intent, draw } }), { ...h.ctx, revocation: rev }); + expect(res.completed).toBe(false); + expect(res.refusals?.[0]?.code).toBe("step-up"); // …a grant NEVER completes an age gate + }); + + it("ATOMIC cumulative cap: an over-total verdict from commitDraw refuses at the seam", async () => { + // The store makes the cap decision atomically (Codex P1). If commitDraw reports the + // committed sum would breach the cap, the seam must refuse — not complete. + const h = harness(); + const { intent, mkDraw } = await drawFixture(); + const capBreaching = { isRevoked: () => false, revoke() {}, revokeSubject() {}, priorDraws() { return []; }, commitDraw() { return { ok: false, reason: "over-total" } as const; } }; + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 20, draw: { intent, draw: await mkDraw(20) } }), { ...h.ctx, revocation: capBreaching }); + expect(res.completed).toBe(false); + expect(res.refusals?.[0]?.code).toBe("over-total"); + expect(h.records.size).toBe(0); + }); + + it("BYPASS: draw.amount ≠ catalog re-priced total is refused (grant never carries the price)", async () => { + const h = harness(); + const rev = new MemoryRevocationStore(); + const { intent, mkDraw } = await drawFixture(); + const draw = await mkDraw(10); // signs amount 10, but 2 widgets re-price to 20 + const res = await completeOrder(h.input([{ productId: "widget", quantity: 2 }], { amount: 10, draw: { intent, draw } }), { ...h.ctx, revocation: rev }); + expect(res.completed).toBe(false); + expect(res.reason).toBe("draw"); + }); +}); diff --git a/packages/credentagent-gate/src/ceremony/completion.ts b/packages/credentagent-gate/src/ceremony/completion.ts index e93afac..0430b0b 100644 --- a/packages/credentagent-gate/src/ceremony/completion.ts +++ b/packages/credentagent-gate/src/ceremony/completion.ts @@ -9,6 +9,9 @@ import type { VerificationStore } from "../types.js"; import type { CartItemRef, CeremonyCatalog, CompletionInput, CompletionResult, GateOutcome } from "./types.js"; import { verifyCartMandate, type CartMandate } from "./cartMandate.js"; import { reconcileCartPayment } from "./reconciliation.js"; +import { checkDraw, type DrawVerifier } from "./mandate.js"; +import type { RevocationStore } from "./revocation.js"; +import { refusal } from "./refusals.js"; // One on-chain (demo-mode) settlement backing a completed order. Kept structural // so the demo's richer SettlementRecord is assignable without the package taking @@ -33,6 +36,9 @@ export interface CompletedRecord { gates: GateOutcome[]; completedAt: string; settlement?: SettlementRecordLike; + /** The authorizing Intent Mandate id, when this order completed via a delegated draw + * (005) — the audit link from an unattended completion back to its grant. */ + delegationId?: string; } export interface CompletedOrderStore { @@ -57,6 +63,14 @@ export interface CompletionContext { * `cartMandate`, completion verifies it (signature + order-id binding + expiry) * before re-pricing. Absent ⇒ the cart-mandate check is skipped (additive). */ signingKey?: string; + /** Optional revocation + committed-draw store (005). REQUIRED when the input carries a + * `draw`: its absence is fail-closed (a draw without a store to check it is refused). + * Consulted fail-closed (a throwing read refuses) and holds the atomic single-use consume. */ + revocation?: RevocationStore; + /** Optional signer-agnostic draw verifier (defaults to ES256/P-256). */ + verifyDraw?: DrawVerifier; + /** Injectable clock for the draw window check (testability). */ + now?: () => number; } export async function completeOrder(input: CompletionInput, ctx: CompletionContext): Promise { @@ -123,6 +137,82 @@ export async function completeOrder(input: CompletionInput, ctx: CompletionConte // is the shared-completion-seam half of CT9; the demo's place-order + MCP // order-completion-tool halves are wired in T014. const ageRestricted = repriced.lines.some((l) => typeof l.minimumAge === "number" && l.minimumAge > 0); + + // ── HNP delegated-draw branch (005 FR-006): additive + fail-closed. Only taken when a + // draw is present; every HP path above/below is byte-unchanged. The gate re-runs the FULL + // bounds check here at the seam — never trusting an upstream rail verify (invariant 1) — so + // a producer reaching completeOrder DIRECTLY with a draw is still fully checked. On success + // it writes a delegationId (the audit link) and SUPPRESSES real settlement. + if (input.draw) { + const { intent, draw } = input.draw; + const store = ctx.revocation; + // Fail-closed: a draw with no store to check it against cannot complete. + if (!store) return { completed: false, reason: "draw", refusals: [refusal("revocation-unavailable")] }; + + // Age is NON-DELEGABLE: an age-restricted cart ALWAYS steps up to a live ceremony and + // never completes from a grant, regardless of any snapshot (invariant 5, spec FR-013). + if (ageRestricted) return { completed: false, reason: "draw", refusals: [refusal("step-up", { cause: "age-restricted" })] }; + + // Revocation + prior draws — fail-closed if the store errors (never fail-open). + let revoked: boolean; + let priorDraws; + try { + revoked = await store.isRevoked(intent.intentId, intent.subject); + priorDraws = await store.priorDraws(intent.intentId); + } catch { + return { completed: false, reason: "draw", refusals: [refusal("revocation-unavailable")] }; + } + if (revoked) return { completed: false, reason: "draw", refusals: [refusal("revoked", { intentId: intent.intentId })] }; + + // The deterministic bounds gates (signature/cap/window/scope/replay/step-up/…). + const verdict = await checkDraw(intent, draw, { + now: ctx.now ? ctx.now() : undefined, + priorDraws, + verify: ctx.verifyDraw, + }); + if (!verdict.ok) return { completed: false, reason: "draw", refusals: verdict.refusals }; + + // Invariants 2+3: the draw's amount must equal the catalog-RE-DERIVED total — the grant + // never carries an authoritative price, and the bound payment can't drift from ground truth. + if (draw.amount !== repriced.total) { + return { completed: false, reason: "draw", refusals: [refusal("over-cap", { pricedAt: repriced.total, amount: draw.amount })] }; + } + + // Atomic consume — keyed per intent (NOT per order: completeOrder's own idempotency is + // order-keyed, so two concurrent redemptions minting two order ids would both pass without + // this). The store makes BOTH the single-use AND the cumulative-cap decision atomically, so + // concurrent draws with distinct pspTransactionIds cannot both slip past checkDraw's + // (non-atomic) over-total pre-check and breach the cap. `consumed` = replayed txid; + // `over-total` = would breach the cumulative cap at commit time. + let commit: import("./revocation.js").CommitResult; + try { + commit = await store.commitDraw(intent.intentId, { amount: draw.amount, pspTransactionId: draw.pspTransactionId }, { totalAmount: intent.totalAmount }); + } catch { + return { completed: false, reason: "draw", refusals: [refusal("revocation-unavailable")] }; + } + if (!commit.ok) { + const detail = commit.reason === "consumed" ? { pspTransactionId: draw.pspTransactionId } : { total: intent.totalAmount }; + return { completed: false, reason: "draw", refusals: [refusal(commit.reason, detail)] }; + } + + // The draw is authorized. Record it with the delegationId audit link and SUPPRESS real + // settlement (the honesty control — a demo-fenced draw never moves real value, spec FR-014). + await ctx.records.write({ + orderId: input.order.id, + mandateId: input.mandateId, + amount: draw.amount, + currency: input.currency, + method: input.method, + instrument: input.instrument, + gates: input.gates, + completedAt: new Date().toISOString(), + delegationId: intent.intentId, + } as Parameters[0]); + if (ctx.cart) await ctx.cart.clear(); + await ctx.verificationStore.clear(input.order.id); + return { completed: true, delegationId: intent.intentId }; + } + if (ageRestricted && (verification as { ageVerified?: boolean } | undefined)?.ageVerified !== true) { return { completed: false, reason: "age" }; } diff --git a/packages/credentagent-gate/src/ceremony/mandate.intent.test.ts b/packages/credentagent-gate/src/ceremony/mandate.intent.test.ts new file mode 100644 index 0000000..5d7d482 --- /dev/null +++ b/packages/credentagent-gate/src/ceremony/mandate.intent.test.ts @@ -0,0 +1,185 @@ +// checkDraw bounds tests — the spike's 13 (spike/intent-mandate/intent-mandate.test.mjs) +// ported to the library surface, per 005 tasks T003. Each fixture mutation asserts +// EXACTLY which gate refuses (typed refusal codes, not first-fail). +import { describe, expect, it } from "vitest"; +import { + canonical, + contentAddressId, + sealIntent, + generateDelegate, + signDraw, + checkDraw, + type IntentBounds, + type Draw, +} from "./mandate.js"; + +const JUL15 = Date.parse("2026-07-15T12:00:00Z"); + +async function fixture() { + const { privateKey, delegate } = await generateDelegate(); + const intent = await sealIntent({ + type: "credentagent.IntentBounds/v0", + naturalLanguageDescription: "Buy Ghost 17 size 10, up to $120, until Jul 31, from approved stores", + merchants: ["utopia-marketplace", "runfast.example"], + skus: ["gtin:00195394069122"], + currency: "USD", + maxAmount: 120, + totalAmount: 120, + stepUpOver: 50, + intentExpiry: "2026-07-31T23:59:59Z", + notBefore: "2026-07-01T00:00:00Z", + delegate, + mayPresent: ["membership:acme-loyalty"], + presence: "delegated", + trust_level: "issuer-verified (demo PKI)", + }); + const draw = await signDraw( + { + type: "credentagent.Draw/v0", + intentId: intent.intentId, + paymentMandateId: "draw_1", + merchant: "runfast.example", + amount: 40, + currency: "USD", + pspTransactionId: "tx_1", + presentments: ["membership:acme-loyalty"], + }, + privateKey, + ); + return { privateKey, delegate, intent, draw }; +} + +const codes = (r: { refusals: { code: string }[] }) => r.refusals.map((x) => x.code).sort(); + +describe("intent bounds — content addressing + canonical form", () => { + it("intentId commits to every field; any edit re-hashes it", async () => { + const { intent } = await fixture(); + expect(intent.intentId.startsWith("int_")).toBe(true); + expect(await contentAddressId(intent)).toBe(intent.intentId); // stable + omits the id field + const tampered = { ...intent, maxAmount: 100000 }; + expect(await contentAddressId(tampered)).not.toBe(intent.intentId); // raising the cap orphans the id + }); + + it("canonical() is order-independent", () => { + expect(canonical({ b: 1, a: [3, { y: 2, x: 1 }] })).toBe(canonical({ a: [3, { x: 1, y: 2 }], b: 1 })); + }); +}); + +describe("checkDraw — the deterministic gates", () => { + it("an in-bounds draw passes all gates", async () => { + const { intent, draw } = await fixture(); + const r = await checkDraw(intent, draw, { now: JUL15 }); + expect(r.ok).toBe(true); + expect(r.refusals).toEqual([]); + }); + + it("over-cap: a draw above maxAmount is refused (co-fires over-total + step-up — the schema finding)", async () => { + const { intent, draw, privateKey } = await fixture(); + const big = await signDraw({ ...draw, amount: 200 }, privateKey); + // With stepUpOver ≤ maxAmount (schema) and maxAmount == totalAmount here, an over-cap + // draw NECESSARILY also trips over-total and step-up — over-cap can't fire alone. + expect(codes(await checkDraw(intent, big, { now: JUL15 }))).toEqual(["over-cap", "over-total", "step-up"]); + }); + + it("over-total: cumulative prior draws + this one exceed totalAmount", async () => { + const { intent, draw } = await fixture(); + const prior = [{ amount: 100, pspTransactionId: "tx_prev" }]; // 100 spent, cap 120, this draw 40 → 140 + expect(codes(await checkDraw(intent, draw, { now: JUL15, priorDraws: prior }))).toEqual(["over-total"]); + }); + + it("window: before notBefore and after intentExpiry are refused", async () => { + const { intent, draw } = await fixture(); + expect(codes(await checkDraw(intent, draw, { now: Date.parse("2026-06-01T00:00:00Z") }))).toEqual(["not-yet-valid"]); + expect(codes(await checkDraw(intent, draw, { now: Date.parse("2026-08-01T00:00:00Z") }))).toEqual(["expired"]); + }); + + it("scope: a draw to a merchant not in the bounds is refused", async () => { + const { intent, draw, privateKey } = await fixture(); + const off = await signDraw({ ...draw, merchant: "sketchy.example" }, privateKey); + expect(codes(await checkDraw(intent, off, { now: JUL15 }))).toEqual(["out-of-scope"]); + }); + + it("replay: reusing a pspTransactionId is refused", async () => { + const { intent, draw } = await fixture(); + const r = await checkDraw(intent, draw, { now: JUL15, priorDraws: [{ amount: 10, pspTransactionId: "tx_1" }] }); + expect(codes(r)).toEqual(["replay"]); + }); + + it("presentment: a credential not in mayPresent is refused (age is never delegable)", async () => { + const { intent, draw, privateKey } = await fixture(); + const age = await signDraw({ ...draw, presentments: ["age:over-21"] }, privateKey); + expect(codes(await checkDraw(intent, age, { now: JUL15 }))).toEqual(["unpermitted-presentment"]); + }); + + it("step-up: a draw over stepUpOver is a needs-human refusal", async () => { + const { intent, draw, privateKey } = await fixture(); + const over = await signDraw({ ...draw, amount: 75 }, privateKey); // > stepUpOver 50, ≤ cap 120 + const r = await checkDraw(intent, over, { now: JUL15 }); + expect(codes(r)).toEqual(["step-up"]); + expect(r.refusals[0]!.retryable).toBe("needs-human"); + }); + + it("signature: tampering a signed draw's amount is refused (K_s covers the canonical draw)", async () => { + const { intent, draw } = await fixture(); + const tampered = { ...draw, amount: 5 }; // edit AFTER signing → signature no longer matches + const r = await checkDraw(intent, tampered, { now: JUL15 }); + expect(r.refusals.some((x) => x.code === "signature")).toBe(true); + }); + + it("signature: a draw signed by a DIFFERENT key (not the delegate) is refused", async () => { + const { intent, draw } = await fixture(); + const attacker = await generateDelegate(); + const forged = await signDraw({ ...draw }, attacker.privateKey); + expect(codes(await checkDraw(intent, forged, { now: JUL15 }))).toEqual(["signature"]); + }); + + it("intent-mismatch: a draw bound to a different intentId is refused", async () => { + const { intent, draw, privateKey } = await fixture(); + const other = await signDraw({ ...draw, intentId: "int_other" }, privateKey); + expect(codes(await checkDraw(intent, other, { now: JUL15 }))).toEqual(["intent-mismatch"]); + }); + + it("bounds-tampered: mutated bounds under a victim's intentId are refused (content-address integrity)", async () => { + const { intent, draw } = await fixture(); + // The attack (Codex P1): keep the victim's intentId, but swap the delegate key to the + // attacker's, raise the cap, and change the merchant — then sign the draw with the + // ATTACKER's key. Signature/cap/scope all pass against the mutated bounds; only the + // content-address self-check catches that these bounds never produced this intentId. + const attacker = await generateDelegate(); + // Inflate every bound so the forged draw would sail through signature/cap/scope/window — + // then the ONLY thing that can refuse it is the content-address integrity check. + const tampered = { ...intent, maxAmount: 100000, totalAmount: 100000, stepUpOver: 100000, merchants: ["attacker.example"], delegate: attacker.delegate }; + const forged = await signDraw({ ...draw, merchant: "attacker.example", amount: 9999 }, attacker.privateKey); + const r = await checkDraw(tampered, forged, { now: JUL15 }); + expect(r.ok).toBe(false); + // load-bearing: bounds-tampered is the SOLE refusal — remove the check and this forged + // draw (attacker's key, inflated cap + scope) would pass every other gate. + expect(codes(r)).toEqual(["bounds-tampered"]); + }); + + it("multiple violations accumulate (typed refusal list, not first-fail)", async () => { + const { intent, draw, privateKey } = await fixture(); + const bad = await signDraw({ ...draw, amount: 200, currency: "EUR", merchant: "sketchy.example" }, privateKey); + expect(codes(await checkDraw(intent, bad, { now: JUL15 }))).toEqual([ + "currency-mismatch", + "out-of-scope", + "over-cap", + "over-total", + "step-up", + ]); + }); + + it("every refusal is merchant-attributed typed data", async () => { + const { intent, draw, privateKey } = await fixture(); + const bad = await signDraw({ ...draw, amount: 200 }, privateKey); + const r = await checkDraw(intent, bad, { now: JUL15 }); + for (const ref of r.refusals) { + expect(ref.enforcer).toBe("merchant"); + expect(["retry", "needs-human", "terminal"]).toContain(ref.retryable); + } + }); +}); + +// Type-level: IntentBounds/Draw are exported, usable shapes. +const _typecheck: (i: IntentBounds, d: Draw) => string = (i, d) => i.intentId + d.pspTransactionId; +void _typecheck; diff --git a/packages/credentagent-gate/src/ceremony/mandate.ts b/packages/credentagent-gate/src/ceremony/mandate.ts index 92c25b5..d599730 100644 --- a/packages/credentagent-gate/src/ceremony/mandate.ts +++ b/packages/credentagent-gate/src/ceremony/mandate.ts @@ -183,3 +183,212 @@ export function runGates(mandate: PasskeyMandate, opts: { loyaltyDiscountPct?: n return results; } + +// ──────────────────────────────────────────────────────────────────────────── +// HNP seams (005, Option B): the Intent Mandate BOUNDS model + the deterministic +// draw gates — productized from spike/intent-mandate/ (13 tests ported alongside). +// The user's ceremony seals BOUNDS (caps / window / scope / delegate key); each +// DRAW (a per-purchase Payment Mandate) is checked in-bounds server-side on every +// completion path. Honesty: the wire crypto here is REAL (ES256 over the canonical +// draw; content-addressed intentId over SHA-256) — what the demo fakes is the PKI +// and the money, never the bounds enforcement, which is deterministic and total. +import { webcrypto } from "node:crypto"; +import { refusal, type Refusal } from "./refusals.js"; + +const subtle = webcrypto.subtle; +type CryptoKey = webcrypto.CryptoKey; +const utf8 = new TextEncoder(); +const b64url = (buf: ArrayBuffer | Uint8Array): string => Buffer.from(buf as Uint8Array).toString("base64url"); + +/** The delegate key K_s — the ONLY key that may sign draws — as a public JWK. */ +export interface DelegateJwk { + kty: "EC"; + crv: "P-256"; + x: string; + y: string; +} + +/** The user-sealed Intent Mandate bounds (intent-bounds-schema-draft.md: AP2 intent + * fields + EUDI SCA TS12 `PaymentTransaction` amounts). Content-addressed: `intentId` + * transitively commits to every other field, delegate key and honesty labels included. */ +export interface IntentBounds { + type: "credentagent.IntentBounds/v0"; + intentId: string; + /** The human-readable mandate the user actually approved (AP2 natural-language field). */ + naturalLanguageDescription?: string; + /** Merchant allowlist; absent/empty ⇒ any suitable merchant (multi-merchant is native under B). */ + merchants?: string[]; + /** SKU/GTIN scope allowlist (checked by the wallet/rail; the seam checks merchants). */ + skus?: string[]; + currency: string; + /** Per-draw cap (TS12 max_amount) — an absolute ceiling, tolerance 0. */ + maxAmount: number; + /** Cumulative cap (TS12 total_amount) across all committed draws. */ + totalAmount: number; + /** Presence-required threshold: a draw above it needs a fresh human tap (step-up ≤ cap). */ + stepUpOver?: number; + intentExpiry?: string; + notBefore?: string; + delegate: DelegateJwk; + /** Credentials the agent MAY present under this grant. Age is NEVER delegable → never listed. */ + mayPresent?: string[]; + /** Honesty axes (constitution VII v1.1.0): when consent happened / how strongly bound. */ + presence: "delegated" | "delegated-demo"; + trust_level: string; + subject?: string; +} + +/** One draw — the per-purchase spend against an intent, signed by the delegate key. */ +export interface Draw { + type: "credentagent.Draw/v0"; + intentId: string; + paymentMandateId: string; + merchant: string; + amount: number; + currency: string; + /** The PSP-issued settlement transaction id — single-use per intent (replay guard). */ + pspTransactionId: string; + presentments?: string[]; + /** b64url ES256 signature by the delegate key over the canonical draw (sans this field). */ + signature?: string; +} + +/** A committed (already-drawn) spend, as the RevocationStore records it. */ +export interface CommittedDraw { + amount: number; + pspTransactionId: string; +} + +/** Canonical JSON (stable, recursive key sort) — the exact bytes hashed + signed. Any + * edit to any field changes these bytes, so a signature/hash covers the whole document. */ +export function canonical(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]"; + const obj = value as Record; + const keys = Object.keys(obj).sort(); + return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonical(obj[k])).join(",") + "}"; +} + +/** intentId = "int_" + b64url(SHA-256(canonical(bounds \ intentId))) — no circularity, + * and it transitively commits to EVERY other field. */ +export async function contentAddressId(bounds: object): Promise { + const { intentId: _omit, ...rest } = bounds as Record; + const digest = await subtle.digest("SHA-256", utf8.encode(canonical(rest))); + return "int_" + b64url(digest); +} + +export async function sealIntent(boundsWithoutId: Omit): Promise { + return { ...boundsWithoutId, intentId: await contentAddressId(boundsWithoutId) } as IntentBounds; +} + +/** Generate a delegate keypair K_s (ES256 / P-256). The bounds carry the PUBLIC JWK. */ +export async function generateDelegate(): Promise<{ privateKey: CryptoKey; delegate: DelegateJwk }> { + const pair = await subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); + const jwk = await subtle.exportKey("jwk", pair.publicKey); + return { privateKey: pair.privateKey, delegate: { kty: "EC", crv: "P-256", x: jwk.x!, y: jwk.y! } }; +} + +/** Sign a draw with the delegate key over its canonical form (any prior signature stripped). */ +export async function signDraw(draw: Draw, privateKey: CryptoKey): Promise { + const { signature: _omit, ...unsigned } = draw; + const sig = await subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, utf8.encode(canonical(unsigned))); + return { ...(unsigned as Draw), signature: b64url(sig) }; +} + +/** Signer-agnostic verification seam: the default verifies ES256/P-256 (the wallet + * server's K_s — the Option-B target); hosts may inject e.g. an HMAC verifier. */ +export type DrawVerifier = (draw: Draw, delegate: DelegateJwk) => Promise; + +export const verifyDrawEs256: DrawVerifier = async (draw, delegate) => { + try { + const { signature, ...unsigned } = draw; + if (typeof signature !== "string") return false; + const key = await subtle.importKey( + "jwk", + { ...delegate, ext: true }, + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["verify"], + ); + return await subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + key, + Buffer.from(signature, "base64url"), + utf8.encode(canonical(unsigned)), + ); + } catch { + return false; + } +}; + +export interface CheckDrawContext { + now?: number; + priorDraws?: CommittedDraw[]; + verify?: DrawVerifier; +} + +export interface DrawVerdict { + ok: boolean; + refusals: Refusal[]; +} + +/** THE DETERMINISTIC GATES: is this draw in-bounds? Pure and total — injected `now` / + * `priorDraws`, never throws, accumulates typed refusals (no first-fail) so the surface + * can act on the full picture. This is defense-in-depth's inner ring: the completion + * seam re-runs it server-side on EVERY path (invariant 1). */ +export async function checkDraw(intent: IntentBounds, draw: Draw, ctx: CheckDrawContext = {}): Promise { + const now = ctx.now ?? Date.now(); + const priorDraws = ctx.priorDraws ?? []; + const verify = ctx.verify ?? verifyDrawEs256; + const refusals: Refusal[] = []; + + // 0. INTEGRITY: the intent's own fields must hash to its intentId. Content-addressing is + // the whole trust root — without recomputing it here, `intentId` is a bare string label, + // and a caller could keep a victim's id while swapping `delegate` / `maxAmount` / `merchants` + // and signing the draw with the substituted key (every check below would then run against + // bounds the user never approved). Recompute and refuse on mismatch. + if ((await contentAddressId(intent)) !== intent.intentId) refusals.push(refusal("bounds-tampered")); + + // 1. binds to THIS intent + if (draw.intentId !== intent.intentId) refusals.push(refusal("intent-mismatch")); + + // 2. signed by the delegate key named in the (content-addressed, integrity-checked) bounds + if (!(await verify(draw, intent.delegate))) refusals.push(refusal("signature")); + + // 3. currency + if (draw.currency !== intent.currency) + refusals.push(refusal("currency-mismatch", { expected: intent.currency, got: draw.currency })); + + // 4. per-draw cap (TS12 max_amount) — absolute ceiling + if (draw.amount > intent.maxAmount) refusals.push(refusal("over-cap", { cap: intent.maxAmount, amount: draw.amount })); + + // 5. cumulative cap (TS12 total_amount) — committed draws + this one + const spent = priorDraws.reduce((s, d) => s + d.amount, 0); + if (spent + draw.amount > intent.totalAmount) + refusals.push(refusal("over-total", { total: intent.totalAmount, wouldBe: spent + draw.amount })); + + // 6. window (notBefore ≤ now ≤ intentExpiry) + if (intent.notBefore && now < Date.parse(intent.notBefore)) + refusals.push(refusal("not-yet-valid", { notBefore: intent.notBefore })); + if (intent.intentExpiry && now > Date.parse(intent.intentExpiry)) + refusals.push(refusal("expired", { intentExpiry: intent.intentExpiry })); + + // 7. scope — merchant allowlist (absent/empty ⇒ any suitable merchant) + if (Array.isArray(intent.merchants) && intent.merchants.length > 0 && !intent.merchants.includes(draw.merchant)) + refusals.push(refusal("out-of-scope", { merchant: draw.merchant })); + + // 8. replay — the PSP transaction id is single-use per intent + if (priorDraws.some((d) => d.pspTransactionId === draw.pspTransactionId)) + refusals.push(refusal("replay", { pspTransactionId: draw.pspTransactionId })); + + // 9. presentments ⊆ mayPresent. Age is NEVER delegable → never in mayPresent (invariant 5). + for (const p of draw.presentments ?? []) { + if (!(intent.mayPresent ?? []).includes(p)) refusals.push(refusal("unpermitted-presentment", { presentment: p })); + } + + // 10. step-up: over the presence-required threshold ⇒ a fresh human tap resumes it. + if (typeof intent.stepUpOver === "number" && draw.amount > intent.stepUpOver) + refusals.push(refusal("step-up", { threshold: intent.stepUpOver })); + + return { ok: refusals.length === 0, refusals }; +} diff --git a/packages/credentagent-gate/src/ceremony/refusals.ts b/packages/credentagent-gate/src/ceremony/refusals.ts new file mode 100644 index 0000000..b3e0196 --- /dev/null +++ b/packages/credentagent-gate/src/ceremony/refusals.ts @@ -0,0 +1,54 @@ +// The shared typed-refusal vocabulary (design §9 + the redemption-choreography draft): +// ONE discriminated shape across the gate seam, the (future) intent rail, and the +// (future) wallet server — every refusal names the failure, WHO refused (`enforcer`), +// and the recovery class (`retryable`) an unattended agent loop branches on. Pure data +// (Principle VI): refusals cross the MCP wire to agents, so no functions, no Errors. +// +// The agent-facing projection is intentionally minimal (`enforcer` + `code`); the +// per-code detail fields exist for the merchant's own logs and the human approve +// surface — do not leak bounds detail to the counterparty (choreography draft, the +// security persona's oracle concern). + +/** Why a draw was refused. Finer-grained than §9's collapsed wire set so the seam can + * log precisely; surfaces MAY coarsen (e.g. `not-yet-valid`/`expired` → "expired"). */ +export type RefusalCode = + | "signature" // draw not signed by the delegate key named in the bounds + | "bounds-tampered" // intent's fields don't hash to its own intentId — bounds mutated after sealing + | "intent-mismatch" // draw binds to a different intentId + | "currency-mismatch" + | "over-cap" // per-draw cap (TS12 max_amount) — absolute ceiling, tolerance 0 + | "over-total" // cumulative cap (TS12 total_amount) across committed draws + | "not-yet-valid" // now < notBefore + | "expired" // now > intentExpiry + | "out-of-scope" // merchant (or, later, SKU) outside the bounds allowlist + | "unpermitted-presentment" // credential not in mayPresent — age is NEVER delegable + | "replay" // pspTransactionId already committed for this intent + | "step-up" // over the presence-required threshold — a human tap resumes it + | "revoked" // the grant (or its subject, via kill-switch) was revoked + | "consumed" // single-use grant already drawn + | "revocation-unavailable"; // the revocation store errored — fail-closed, never open + +/** Who refused. The gate seam always answers as the merchant; the wallet server and + * PSP use their own values so cross-party logs attribute cleanly. */ +export type RefusalEnforcer = "wallet" | "merchant" | "psp"; + +/** The recovery class an unattended loop branches on: + * - "retry": transient (e.g. store unavailable) — retry with backoff, unattended. + * - "needs-human": a live ceremony resumes it (step-up) — surface an approve link. + * - "terminal": do not retry; the draw can never succeed as-is. */ +export type RefusalRetryable = "retry" | "needs-human" | "terminal"; + +export interface Refusal { + code: RefusalCode; + enforcer: RefusalEnforcer; + retryable: RefusalRetryable; + /** Per-code detail for the enforcer's own logs / approve page (not the wire). */ + detail?: Record; +} + +/** The gate-seam constructor: merchant-attributed, retryability derived from the code. */ +export function refusal(code: RefusalCode, detail?: Record): Refusal { + const retryable: RefusalRetryable = + code === "step-up" ? "needs-human" : code === "revocation-unavailable" ? "retry" : "terminal"; + return { code, enforcer: "merchant", retryable, ...(detail ? { detail } : {}) }; +} diff --git a/packages/credentagent-gate/src/ceremony/revocation.test.ts b/packages/credentagent-gate/src/ceremony/revocation.test.ts new file mode 100644 index 0000000..8e952da --- /dev/null +++ b/packages/credentagent-gate/src/ceremony/revocation.test.ts @@ -0,0 +1,45 @@ +// RevocationStore — the fail-closed revocation + atomic single-use/cap ledger (005). +import { describe, it, expect } from "vitest"; +import { MemoryRevocationStore } from "./revocation.js"; + +describe("MemoryRevocationStore", () => { + it("revoke + kill-switch", () => { + const s = new MemoryRevocationStore(); + expect(s.isRevoked("int_1")).toBe(false); + s.revoke("int_1"); + expect(s.isRevoked("int_1")).toBe(true); + expect(s.isRevoked("int_2", "user_a")).toBe(false); + s.revokeSubject("user_a"); + expect(s.isRevoked("int_2", "user_a")).toBe(true); // subject kill-switch, any intent + expect(s.isRevoked("int_2", "user_b")).toBe(false); + }); + + it("single-use: a duplicate pspTransactionId is rejected (consumed)", () => { + const s = new MemoryRevocationStore(); + expect(s.commitDraw("int_1", { amount: 10, pspTransactionId: "tx" }, { totalAmount: 100 })).toEqual({ ok: true }); + expect(s.commitDraw("int_1", { amount: 10, pspTransactionId: "tx" }, { totalAmount: 100 })).toEqual({ ok: false, reason: "consumed" }); + }); + + it("ATOMIC cumulative cap (Codex P1): two DIFFERENT-txid draws that each pass a cap pre-check cannot both commit", () => { + const s = new MemoryRevocationStore(); + // Both draws are $30 against a $40 cumulative cap. checkDraw's non-atomic over-total + // pre-check would let each through when read against empty priorDraws (the concurrent + // race). The commit is the atomic backstop: the first wins, the second is refused + // over-total — the committed sum can never exceed totalAmount. + expect(s.commitDraw("int_1", { amount: 30, pspTransactionId: "a" }, { totalAmount: 40 })).toEqual({ ok: true }); + expect(s.commitDraw("int_1", { amount: 30, pspTransactionId: "b" }, { totalAmount: 40 })).toEqual({ ok: false, reason: "over-total" }); + // the ledger holds exactly the one committed draw + expect(s.priorDraws("int_1")).toEqual([{ amount: 30, pspTransactionId: "a" }]); + }); + + it("cap counts only committed draws, per intent", () => { + const s = new MemoryRevocationStore(); + s.commitDraw("int_1", { amount: 25, pspTransactionId: "a" }, { totalAmount: 40 }); + // a DIFFERENT intent has its own independent ledger + expect(s.commitDraw("int_2", { amount: 40, pspTransactionId: "z" }, { totalAmount: 40 })).toEqual({ ok: true }); + // back on int_1: 25 committed, 20 more would be 45 > 40 → refused + expect(s.commitDraw("int_1", { amount: 20, pspTransactionId: "b" }, { totalAmount: 40 })).toEqual({ ok: false, reason: "over-total" }); + // …but 15 more (40 total) is exactly the cap → allowed + expect(s.commitDraw("int_1", { amount: 15, pspTransactionId: "c" }, { totalAmount: 40 })).toEqual({ ok: true }); + }); +}); diff --git a/packages/credentagent-gate/src/ceremony/revocation.ts b/packages/credentagent-gate/src/ceremony/revocation.ts new file mode 100644 index 0000000..37e75e4 --- /dev/null +++ b/packages/credentagent-gate/src/ceremony/revocation.ts @@ -0,0 +1,63 @@ +// The revocation + committed-draw store behind HNP grants (005 FR-009/FR-010). +// Keyed per intentId — never process-global (invariant 4) — with a per-subject +// kill-switch. Consulted FAIL-CLOSED at the completion seam: a store that errors +// refuses the draw (revocation-unavailable), never allows it. +// +// `commitDraw` is the single-use/replay AND cumulative-cap control, and MUST be atomic: +// it makes BOTH decisions (unused pspTransactionId AND committed-sum + amount ≤ totalAmount) +// in one check-and-append. checkDraw's own `over-total` / `replay` gates are a fast pre-check, +// but they read `priorDraws` and commit separately — so two concurrent draws with different +// psp ids can both pass those gates and, without an atomic cap decision here, both commit and +// breach the cumulative cap. Likewise `completeOrder`'s idempotency is keyed by ORDER id, so +// two redemptions minting two order ids would both pass without the atomic per-intent consume. +// The in-memory default is single-instance only (Node's single-threaded event loop makes the +// check-and-append atomic per tick); multi-instance deploys MUST inject a shared CAS-capable +// store (Redis Lua doing both checks), mirroring VerificationStore. +import type { MaybePromise } from "./types.js"; +import type { CommittedDraw } from "./mandate.js"; + +/** The atomic result: committed, or rejected for a specific, cap-or-single-use reason. */ +export type CommitResult = { ok: true } | { ok: false; reason: "consumed" | "over-total" }; + +export interface RevocationStore { + /** Is this grant (or its subject, via the kill-switch) revoked? */ + isRevoked(intentId: string, subject?: string): MaybePromise; + revoke(intentId: string): MaybePromise; + /** Kill-switch: revoke every grant carrying this subject. */ + revokeSubject(subject: string): MaybePromise; + /** Committed draws for the intent (feeds checkDraw's cumulative + replay pre-checks). */ + priorDraws(intentId: string): MaybePromise; + /** Atomically commit a draw iff (a) its pspTransactionId is unused for this intent AND + * (b) the already-committed sum + this amount does NOT exceed `totalAmount`. Both the + * single-use and the cumulative-cap decision are made here so concurrent draws cannot + * race past either. `consumed` = duplicate txid; `over-total` = would breach the cap. */ + commitDraw(intentId: string, draw: CommittedDraw, opts: { totalAmount: number }): MaybePromise; +} + +export class MemoryRevocationStore implements RevocationStore { + private readonly revoked = new Set(); + private readonly revokedSubjects = new Set(); + private readonly draws = new Map(); + + isRevoked(intentId: string, subject?: string): boolean { + return this.revoked.has(intentId) || (subject !== undefined && this.revokedSubjects.has(subject)); + } + revoke(intentId: string): void { + this.revoked.add(intentId); + } + revokeSubject(subject: string): void { + this.revokedSubjects.add(subject); + } + priorDraws(intentId: string): CommittedDraw[] { + return this.draws.get(intentId) ?? []; + } + commitDraw(intentId: string, draw: CommittedDraw, opts: { totalAmount: number }): CommitResult { + const list = this.draws.get(intentId) ?? []; + if (list.some((d) => d.pspTransactionId === draw.pspTransactionId)) return { ok: false, reason: "consumed" }; + const spent = list.reduce((s, d) => s + d.amount, 0); + if (spent + draw.amount > opts.totalAmount) return { ok: false, reason: "over-total" }; + list.push(draw); + this.draws.set(intentId, list); + return { ok: true }; + } +} diff --git a/packages/credentagent-gate/src/ceremony/types.ts b/packages/credentagent-gate/src/ceremony/types.ts index b5098b4..6f666d4 100644 --- a/packages/credentagent-gate/src/ceremony/types.ts +++ b/packages/credentagent-gate/src/ceremony/types.ts @@ -99,6 +99,12 @@ export interface CompletionInput { * re-pricing and refuses a tampered/replayed/expired cart — additive, fail-closed * defense-in-depth; the catalog stays the price authority either way. */ cartMandate?: CartMandate; + /** Optional HNP delegated draw (005): a user-sealed Intent Mandate + the delegate-signed + * draw redeeming it. When present, completion re-runs the full bounds + revocation + + * atomic single-use checks server-side (additive, fail-closed) — never trusting an + * upstream verify (invariant 1). Absent ⇒ the draw branch is skipped entirely (HP paths + * byte-unchanged). */ + draw?: { intent: import("./mandate.js").IntentBounds; draw: import("./mandate.js").Draw }; } export interface CompletionResult { @@ -108,9 +114,13 @@ export interface CompletionResult { /** Why a non-completion happened — a failed ceremony ("gates"), a tampered/replayed/ * expired Cart Mandate ("cart-mandate"), a tampered token re-priced against the * catalog ("reprice"), a signed Cart Mandate and signed Payment Mandate that - * disagree on order/amount/currency ("reconcile"), or an age-restricted order with - * no proven per-order age claim ("age"). */ - reason?: "gates" | "cart-mandate" | "reprice" | "reconcile" | "age"; + * disagree on order/amount/currency ("reconcile"), an age-restricted order with + * no proven per-order age claim ("age"), or a refused delegated draw ("draw"). */ + reason?: "gates" | "cart-mandate" | "reprice" | "reconcile" | "age" | "draw"; + /** For a refused delegated draw, the typed refusals (why + who + recovery class). */ + refusals?: import("./refusals.js").Refusal[]; + /** For a completed delegated draw, the authorizing grant id (the audit link). */ + delegationId?: string; } /** diff --git a/packages/credentagent-gate/src/delegated.test.ts b/packages/credentagent-gate/src/delegated.test.ts new file mode 100644 index 0000000..45f151d --- /dev/null +++ b/packages/credentagent-gate/src/delegated.test.ts @@ -0,0 +1,90 @@ +// The DelegatedGate facade must produce the same verdicts as the raw seams, through a +// Stripe-grade surface. These pin that the ceremony is wired correctly (catalog re-price, +// single-use, scope, age-non-delegable, revocation, per-grant isolation) — each +// assertion control-dependent. +import { describe, it, expect } from "vitest"; +import { DelegatedGate } from "./delegated.js"; + +const catalog = { coffee: 18, wine: { price: 20, minAge: 21 } }; + +async function grantFor(gate = new DelegatedGate({ catalog }), perOrder = 30, total = 100) { + return gate.preApprove({ merchant: "blue-bottle", perOrder, total, description: "coffee, up to $30/order" }); +} + +describe("DelegatedGate — Stripe-grade facade over the delegated seams", () => { + it("an in-bounds spend completes: ok, catalog-priced amount, delegationId, no throw", async () => { + const grant = await grantFor(); + const res = await grant.spend({ paymentId: "c1", item: "coffee" }); + expect(res.ok).toBe(true); + expect(res.amount).toBe(18); // priced by the gate, not passed in + expect(res.delegationId).toBe(grant.id); + }); + + it("reusing a paymentId is a double-spend → refused replay", async () => { + const grant = await grantFor(); + await grant.spend({ paymentId: "c1", item: "coffee" }); + const again = await grant.spend({ paymentId: "c1", item: "coffee" }); + expect(again).toMatchObject({ ok: false, reason: "replay" }); + }); + + it("over the per-order cap → refused over-cap (3 × $18 = $54 > $30)", async () => { + const grant = await grantFor(); + const res = await grant.spend({ paymentId: "c2", item: "coffee", quantity: 3 }); + expect(res).toMatchObject({ ok: false, reason: "over-cap", amount: 54 }); + }); + + it("a store the grant never approved → refused out-of-scope", async () => { + const grant = await grantFor(); + const res = await grant.spend({ paymentId: "c3", item: "coffee", merchant: "starbucks" }); + expect(res).toMatchObject({ ok: false, reason: "out-of-scope" }); + }); + + it("age-restricted goods are non-delegable → refused step-up (needs-human) even under the cap", async () => { + const grant = await grantFor(); + const res = await grant.spend({ paymentId: "c4", item: "wine" }); // $20 < $30 cap, still refused + expect(res).toMatchObject({ ok: false, reason: "step-up", retryable: "needs-human" }); + }); + + it("revoke() makes the next spend die → refused revoked", async () => { + const grant = await grantFor(); + await grant.revoke(); + const res = await grant.spend({ paymentId: "c5", item: "coffee" }); + expect(res).toMatchObject({ ok: false, reason: "revoked" }); + }); + + it("draws down the cumulative cap: remaining falls per approved spend, and over-total is refused", async () => { + const grant = await grantFor(new DelegatedGate({ catalog }), 30, 40); // perOrder 30, total 40 + const a = await grant.spend({ paymentId: "c1", item: "coffee" }); // $18 + expect(a).toMatchObject({ ok: true, remaining: 22 }); // 40 − 18 + const b = await grant.spend({ paymentId: "c2", item: "coffee" }); // $18 more + expect(b).toMatchObject({ ok: true, remaining: 4 }); // 40 − 36 + const c = await grant.spend({ paymentId: "c3", item: "coffee" }); // would be 54 > 40 + expect(c).toMatchObject({ ok: false, reason: "over-total", remaining: 4 }); // refused; headroom unchanged + }); + + // ── Regressions for the two review-found bugs ──────────────────────────────── + + it("PER-GRANT ISOLATION: a second grant on the SAME gate is not bypassed by the first's records", async () => { + const gate = new DelegatedGate({ catalog }); + const a = await grantFor(gate); + const b = await grantFor(gate); + await a.spend({ paymentId: "c1", item: "coffee" }); // A succeeds, writes a record (ORD id A-1) + await b.revoke(); + // B is revoked; its spend must be refused, NOT echo A's completion via a colliding order id. + const res = await b.spend({ paymentId: "c1", item: "coffee" }); + expect(res).toMatchObject({ ok: false, reason: "revoked" }); + expect(res.delegationId).toBeUndefined(); // (a delegationId here would be the idempotency-bypass fingerprint) + }); + + it("an unknown item id is a programming error → throws a helpful message (not a silent refusal)", async () => { + const grant = await grantFor(); + await expect(grant.spend({ paymentId: "cX", item: "tea" })).rejects.toThrow(/unknown catalog item "tea"/); + }); + + it("the honesty axis is carried in the types and readable through the facade (constitution VII)", async () => { + const grant = await grantFor(); + expect(grant.presence).toBe("delegated-demo"); + expect(grant.trustLevel).toBe("server-issued-demo"); // weaker than presence-only-demo; would fail if set to a real value + expect(grant.id.startsWith("int_")).toBe(true); + }); +}); diff --git a/packages/credentagent-gate/src/delegated.ts b/packages/credentagent-gate/src/delegated.ts new file mode 100644 index 0000000..b165ab5 --- /dev/null +++ b/packages/credentagent-gate/src/delegated.ts @@ -0,0 +1,216 @@ +// A Stripe-grade, configure-once facade for delegated grants (HNP, 005). +// +// The seams (sealIntent / signDraw / completeOrder / RevocationStore) are the +// security-critical primitives; this bundles them so a caller writes +// preApprove() / spend() / revoke() instead of wiring a catalog, stores, key +// generation and a 7-field completeOrder call by hand (Principle I — the bar is +// `new Stripe(key)` → `stripe.charges.create({...})`). +// +// The API is the stable surface; the implementation is demo-fenced. TODAY the +// delegate key is generated HERE (the server, not the user's phone), so grants +// carry presence "delegated-demo" + trust_level "server-issued-demo" (readable via +// grant.presence / grant.trustLevel) and no real value settles (constitution VII). +// The wallet-server increment swaps the internals (the key is minted in the user's +// wallet during a live ceremony) WITHOUT changing this surface. + +import type { CeremonyCatalog, CeremonyOrder } from "./ceremony/types.js"; +import { MemoryVerificationStore } from "./store.js"; +import { MemoryRevocationStore, type RevocationStore } from "./ceremony/revocation.js"; +import { sealIntent, generateDelegate, signDraw, type IntentBounds } from "./ceremony/mandate.js"; +import { completeOrder, type CompletedRecord, type CompletionContext } from "./ceremony/completion.js"; +import type { RefusalCode, RefusalRetryable } from "./ceremony/refusals.js"; + +/** The delegate private key type, without naming the DOM `CryptoKey` global. */ +type DelegateKey = Awaited>["privateKey"]; + +/** A catalog entry: a bare price, or a price plus an age restriction. */ +export type CatalogEntry = number | { price: number; minAge?: number }; + +export interface DelegatedGateOptions { + /** Your priced catalog: item id → price, or → { price, minAge }. */ + catalog: Record; + /** Shared revocation + single-use ledger (defaults to in-memory, single-process). */ + revocation?: RevocationStore; +} + +export interface PreApproveOptions { + /** The one merchant this grant may spend at. */ + merchant: string; + /** Per-purchase ceiling (an absolute cap). */ + perOrder: number; + /** Cumulative LIFETIME cap across every draw — it does NOT reset (there is no time + * window in v0.1). Once total spend reaches it, the grant is spent out. */ + total: number; + /** A human sentence describing the grant (shown in your UI). */ + description?: string; + /** Who delegated — informational in v0.1 (an audit key; not yet an enforced identity). */ + subject?: string; +} + +export interface Purchase { + /** The payment's idempotency key. Reuse one and the gate sees a double-spend. */ + paymentId: string; + item: string; + quantity?: number; + /** Spend at a merchant other than the approved one (to exercise scope). */ + merchant?: string; +} + +export interface SpendResult { + /** Did the gate complete the purchase? */ + ok: boolean; + /** The amount the gate priced from the catalog (never trusted from the caller). */ + amount: number; + /** Headroom left on the grant's cumulative cap AFTER this spend (a completed spend + * draws it down; a refused one leaves it unchanged). Reaches 0 when spent out. */ + remaining: number; + /** Why it was refused — present when `!ok` (e.g. "over-cap", "replay", "revoked"). */ + reason?: RefusalCode; + /** How to recover from a refusal — the bit an unattended loop branches on: + * "needs-human" (surface an approve link), "retry" (transient), "terminal". */ + retryable?: RefusalRetryable; + /** The authorizing grant id — present when `ok` (the audit link on the record). */ + delegationId?: string; +} + +const priceOf = (e: CatalogEntry) => (typeof e === "number" ? e : e.price); +const minAgeOf = (e: CatalogEntry) => (typeof e === "number" ? undefined : e.minAge); + +function buildCatalog(items: Record): CeremonyCatalog { + return { + // Must honor the passed orderId — completeOrder re-prices under the SAME id, and its + // idempotency is keyed by it, so a duplicate id would echo a prior completion instead + // of running the per-draw checks. An unknown item is a programming error, not a gate + // decision, so it throws (fail fast) rather than silently refusing. + createOrder(refs, orderId): CeremonyOrder { + const lines = refs.map(({ productId, quantity }) => { + const entry = items[productId]; + if (entry === undefined) { + throw new Error(`[credentagent] unknown catalog item "${productId}". Known items: ${Object.keys(items).join(", ")}.`); + } + const unitPrice = priceOf(entry); + const minimumAge = minAgeOf(entry); + return { id: productId, unitPrice, quantity, lineTotal: unitPrice * quantity, currency: "USD", ...(minimumAge ? { minimumAge } : {}) }; + }); + const total = lines.reduce((sum, l) => sum + l.lineTotal, 0); + return { id: orderId, lines, itemCount: refs.length, subtotal: total, discount: 0, total, currency: "USD" }; + }, + }; +} + +/** + * The configure-once gate: give it a priced catalog, get `preApprove()`. It holds + * the revocation ledger and per-order stores so callers never wire them. Mint as many + * grants as you like from one gate — each is isolated. + */ +export class DelegatedGate { + private readonly catalog: CeremonyCatalog; + private readonly ctx: CompletionContext; + + constructor(opts: DelegatedGateOptions) { + this.catalog = buildCatalog(opts.catalog); + const records = new Map(); + this.ctx = { + catalog: this.catalog, + revocation: opts.revocation ?? new MemoryRevocationStore(), + // Delegated draws carry no live-verification state; the shared completion path + // still reads/clears this seam, so a fresh per-order store satisfies it. + verificationStore: new MemoryVerificationStore(), + records: { read: (oid) => records.get(oid), write: (r) => void records.set(r.orderId, r) }, + }; + } + + /** Mint ONE grant and hand it back for your agent to hold. */ + async preApprove(opts: PreApproveOptions): Promise { + const { privateKey, delegate } = await generateDelegate(); + const grant = await sealIntent({ + type: "credentagent.IntentBounds/v0", + naturalLanguageDescription: opts.description, + merchants: [opts.merchant], + currency: "USD", + maxAmount: opts.perOrder, + totalAmount: opts.total, + subject: opts.subject, + delegate, + presence: "delegated-demo", + trust_level: "server-issued-demo", + }); + return new DelegatedGrant(grant, privateKey, this.catalog, this.ctx); + } +} + +/** + * A minted grant your agent holds. `spend()` signs one draw and runs it through the + * gate; `revoke()` flips the ledger so the next spend dies. + */ +export class DelegatedGrant { + private seq = 0; + + constructor( + private readonly grant: IntentBounds, + private readonly key: DelegateKey, + private readonly catalog: CeremonyCatalog, + private readonly ctx: CompletionContext, + ) {} + + /** The grant's content-addressed id (the delegationId written on each draw). */ + get id(): string { + return this.grant.intentId; + } + + /** When consent happened — "delegated-demo" in v0.1 (constitution VII honesty axis). */ + get presence(): string { + return this.grant.presence; + } + + /** How strongly the authorization is bound — "server-issued-demo" in v0.1 (demo-fenced). */ + get trustLevel(): string { + return this.grant.trust_level; + } + + /** The human sentence this grant was described with, if any. */ + get description(): string | undefined { + return this.grant.naturalLanguageDescription; + } + + /** + * Spend against the grant. Returns `{ ok, amount, reason?, retryable?, delegationId? }` + * for any GATE decision — it never throws to signal a refusal. (It does throw on a + * programming error, e.g. an item id not in the catalog.) + */ + async spend({ paymentId, item, quantity = 1, merchant }: Purchase): Promise { + // Order id namespaced by the grant so two grants on one gate never collide (their + // shared idempotency store must not let grant B read grant A's completion — invariant 4). + const orderId = `${this.grant.intentId}-${++this.seq}`; + const order = this.catalog.createOrder([{ productId: item, quantity }], orderId); + const draw = await signDraw( + { + type: "credentagent.Draw/v0", + intentId: this.grant.intentId, + paymentMandateId: paymentId, + merchant: merchant ?? this.grant.merchants![0], + amount: order.total, + currency: "USD", + pspTransactionId: paymentId, + }, + this.key, + ); + const res = await completeOrder( + { order, mandateId: paymentId, amount: order.total, currency: "USD", method: "delegated", gates: [], draw: { intent: this.grant, draw } }, + this.ctx, + ); + // Headroom AFTER this spend: the store's committed draws now include this one iff it + // completed, so `total − committed` reflects the draw-down (or is unchanged on refusal). + const committed = await this.ctx.revocation!.priorDraws(this.grant.intentId); + const remaining = this.grant.totalAmount - committed.reduce((sum, d) => sum + d.amount, 0); + if (res.completed) return { ok: true, amount: order.total, remaining, delegationId: res.delegationId }; + const refusal = res.refusals?.[0]; + return { ok: false, amount: order.total, remaining, reason: refusal?.code, retryable: refusal?.retryable }; + } + + /** Revoke the grant — the very next spend is refused, fail-closed. Async so a remote + * revocation store (the wallet-custody increment) can be awaited before the next spend. */ + async revoke(): Promise { + await this.ctx.revocation!.revoke(this.grant.intentId); + } +} diff --git a/packages/credentagent-gate/src/index.ts b/packages/credentagent-gate/src/index.ts index fc2dee5..826b971 100644 --- a/packages/credentagent-gate/src/index.ts +++ b/packages/credentagent-gate/src/index.ts @@ -69,6 +69,36 @@ export type { ClearableCart, SettlementRecordLike, } from "./ceremony/completion.js"; +// ── HNP delegated-draw seams (005, Option B) — the Intent Mandate bounds model, the +// deterministic draw gates, the typed refusals, and the revocation/single-use store. The +// completeOrder draw branch re-runs checkDraw + revocation + atomic consume server-side. +export { + canonical, + contentAddressId, + sealIntent, + generateDelegate, + signDraw, + checkDraw, + verifyDrawEs256, +} from "./ceremony/mandate.js"; +export type { + IntentBounds, + Draw, + DelegateJwk, + CommittedDraw, + DrawVerifier, + DrawVerdict, + CheckDrawContext, +} from "./ceremony/mandate.js"; +export { MemoryRevocationStore } from "./ceremony/revocation.js"; +export type { RevocationStore } from "./ceremony/revocation.js"; +export { refusal } from "./ceremony/refusals.js"; +export type { Refusal, RefusalCode, RefusalEnforcer, RefusalRetryable } from "./ceremony/refusals.js"; +// The Stripe-grade facade over the delegated-draw seams: configure a gate with a priced +// catalog, preApprove() once, spend()/revoke() — the ceremony (keys, signing, stores, +// completeOrder) is bundled. Demo-fenced today; stable surface for the wallet-server increment. +export { DelegatedGate, DelegatedGrant } from "./delegated.js"; +export type { DelegatedGateOptions, PreApproveOptions, Purchase, SpendResult, CatalogEntry } from "./delegated.js"; export type { CeremonyOrder, CeremonyOrderLine, diff --git a/specs/005-human-not-present/constitution-amendment-draft.md b/specs/005-human-not-present/constitution-amendment-draft.md index 1154d46..62b1b75 100644 --- a/specs/005-human-not-present/constitution-amendment-draft.md +++ b/specs/005-human-not-present/constitution-amendment-draft.md @@ -1,8 +1,8 @@ -# DRAFT — Constitution amendment for HNP (Decision 13) +# Constitution amendment for HNP (Decision 13) — APPLIED 1.1.0 (2026-07-08) -**Status**: proposal only — the constitution itself is untouched. Ratifying = maintainer applies these -edits to `.specify/memory/constitution.md` with the version bump. Required before `/speckit-implement` -of 005 (spec.md, Dependencies). +**Status**: APPLIED to `.specify/memory/constitution.md` as v1.1.0 on 2026-07-08 (maintainer ratified +Decision 13 alongside Group-A/Option-B; this file is the historical proposal). The `/speckit-implement` +prerequisite for 005 is satisfied. **Version**: 1.0.0 → **1.1.0** (MINOR: materially expanded guidance on II, III, VII; no principle removed or redefined incompatibly — every existing rule remains true for human-present flows). diff --git a/specs/005-human-not-present/plan.md b/specs/005-human-not-present/plan.md new file mode 100644 index 0000000..c4dd773 --- /dev/null +++ b/specs/005-human-not-present/plan.md @@ -0,0 +1,156 @@ +# Implementation Plan: HNP First Increment — the shared gate seams (Option B) + +**Branch**: `005-human-not-present` | **Date**: 2026-07-07 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification `specs/005-human-not-present/spec.md`, the ratified +[`sequencing-fork-memo.md`](sequencing-fork-memo.md) (**Option B — wallet-custody, seams-first**), and +[`connector-architecture-design.md`](connector-architecture-design.md) §9–§11. Prototype: +[`spike/intent-mandate/`](../../spike/intent-mandate/) (ES256 `K_s` + `checkDraw` + 13 tests). + +## Summary + +Ship the **first increment of HNP under Option B**: the four **signer-agnostic shared gate seams** in +`credentagent-gate` that both the GDC demo and the eventual wallet server depend on — built once, in service of +the wallet-custody model, **without** the merchant-side minting rail on top. + +1. **Draw-verification envelope** (`checkDraw`) — a **pure, total** function that verifies a proposed + **Payment Mandate draw** against an **Intent Mandate**'s canonical bounds: signature over the bounds → + window → scope-contains → re-priced total ≤ cap → step-up threshold → single-use → returns a **typed refusal + list** (never throws, never first-fails). Signer-agnostic: it verifies a signature over `canonical(bounds)` — + the spike's **ES256 `K_s`** (the B target) via an injected verifier, HMAC still possible. +2. **`completeOrder` draw branch** — an **additive, fail-closed** branch on the shared completion seam that + **re-runs** `checkDraw` + revocation + an **atomic single-use consume** server-side (never trusting an + upstream verify), writes a `delegationId` onto the completion record, and **suppresses real settlement**. +3. **Revocation store** — an injectable `RevocationStore` seam (default in-memory) keyed per `intentId` with a + per-subject kill-switch; **fail-closed** (unreachable ⇒ refuse) and TOCTOU-safe (re-checked at the seam). +4. **Typed-refusal vocabulary** (§9) — the shared refusal enum (`over-cap`, `out-of-scope`, `expired`, + `bad-signature`, `consumed`, `step-up-required`, `revoked`, `replay`) with `enforcer` / `retryable` tags, + used identically on every surface. + +This **productizes** `spike/intent-mandate/` (already B-shaped: asymmetric `K_s`, content-addressed `intentId`, +`checkDraw`) into the library, and reuses existing primitives (`mandate.ts` AP2 shapes, `VerificationStore` +pattern, the shared `completeOrder`, `jose`). + +**Out of scope this increment (later, per the memo's build order):** the settlement verifier (UPay-style, +increment 2), the **wallet server** (`credentagent-wallet`, Kotlin/JVM — increment 3, sibling repo), the thin +`credentagent-agent` (increment 4), and the **HTTP intent rail / approve page** (arrives with the wallet +connector). The **merchant-side server-HMAC minting rail is dropped** under Option B. + +## Technical Context + +**Language/Version**: TypeScript, Node ≥ 20, ESM (the `@openmobilehub/credentagent-gate` workspace). + +**Primary Dependencies**: existing only — `jose` (ECDSA P-256 / ES256 verify + JWK, already used by the +OpenID4VP rails), the gate's own `mandate.ts`, `store.ts` (`VerificationStore`), `completion.ts` +(`completeOrder`). **No new runtime dependency.** + +**Crypto / identity**: the delegate key **`K_s`** is **ES256 (P-256)** — the spike's model and the B target +(the wallet server holds `K_s`; the signed bounds name its public key). Verification is **signer-agnostic**: +`checkDraw` takes an injected `verifyBounds(sig, canonicalBounds) → bool` so the HMAC path remains expressible. +Identity is **content-addressed**: `intentId = "int_" + b64url(SHA-256(canonical(bounds \ intentId)))`; the +canonical encoding is deterministic (sorted keys, no floats-as-strings drift). + +**Storage**: injectable `RevocationStore` (default in-memory `Map`), mirroring `VerificationStore` — inject a +shared store for multi-instance. **Fail-closed**: a store read that throws refuses the draw (never opens). + +**Testing**: `vitest` (gate package). Bypass tests per bound (over-cap · out-of-scope · expired · bad-signature +· consumed · step-up · revoked) submitted **directly to `completeOrder` / place-order**, not only the rail +(FR US2 scenario 8); a **TOCTOU** test (revoke lands between verify and complete → seam still refuses); an +**atomic single-use** test (two concurrent draws → exactly one completes); **age-always-step-up** (an +age-restricted cart never completes from a grant). **Each bypass test MUST go red when its control is removed.** + +**Target Platform**: Node (library). **Project Type**: Library — npm workspace `credentagent-gate`. + +**Performance Goals**: `checkDraw` is a pure O(lines) function; one signature verify + O(1) revocation/consume +store ops per draw. Zero cost on the existing HP paths (the branch is only taken when a draw is present). + +**Constraints**: (1) **Additive + fail-closed** — HP paths unchanged; the draw branch only activates on a draw. +(2) **Re-derive amounts from the catalog** — never a price carried in the grant (Inv #2). (3) **Scope state per +`intentId`/order** — never process-global (Inv #4). (4) **Explicit positive bound checks** — over-cap/scope are +real controls; age-restricted **always** steps up (Inv #5, "delegate actions, not identity"). (5) **No +settlement** — `ctx.settle` suppressed for the demo-fenced draw; honesty label per `connector-arch` §11. +(6) DCO sign-off. (7) **Signer-agnostic envelope** — no hard dependency on HMAC or ES256 in `checkDraw` itself. + +**Scale/Scope**: Small–medium — extend `mandate.ts` with the Intent/Payment-draw types + `checkDraw` +(ported from the spike), one `RevocationStore` seam, one additive `completeOrder` branch, one shared refusal +module, and their bypass-test files. No HTTP surface, no new backend. + +## Constitution Check + +*GATE: Must pass before Phase 0. Re-checked after design.* Instantiated against the CredentAgent SDK Constitution +(Principles I–VII + Security). **⚠️ Prerequisite:** Decision-13 requires a **MINOR amendment to Principles II, +III, VII** ([`constitution-amendment-draft.md`](constitution-amendment-draft.md)) — a separate +`/speckit-constitution` step **before `/speckit-implement`**. The rows below assume that amendment lands. + +| Gate | Assessment | +| :-- | :-- | +| **I. Stripe-grade, MCP-idiomatic API** | ✅ One typed `IntentMandate` + a pure `checkDraw`; injectable `RevocationStore` mirrors the existing `VerificationStore` seam. No callback grab-bags. | +| **II. Three execution contexts sacred** | ⚠️→✅ *with amendment* — HNP adds a **fourth, no-live-human** completion path; the amendment names it explicitly rather than bending "three contexts". | +| **III. Consolidated checkout flow** | ⚠️→✅ *with amendment* — the draw redeems **through the same `completeOrder`**; the amendment records the additive branch as in-policy, not a second flow. | +| **IV. One ordered policy array / amount server-derived** | ✅ Reinforced — the draw re-prices from the catalog and derives the payment amount from the re-priced total; the grant carries bounds, never a price. | +| **V. Extensible to any credential** | ✅ The Intent Mandate bounds a **scope** of effects; `payment` is one — the same envelope bounds any delegable action effect. Age/membership explicitly **non-delegable**. | +| **VI. structuredContent is data** | ✅ Refusals are typed data (`enforcer`/`retryable`), not prose. | +| **VII. Honesty in types; prefer simplicity** | ⚠️→✅ *with amendment* — the honesty axis moves from `server-issued-demo` to **`delegated` presence + `issuer-verified (demo PKI)`** (B); the amendment records the new axis. Simplicity: reuse `mandate.ts`, the store pattern, `completeOrder`; defer the rail + wallet server. | +| **Security — enforce on every completion path (Inv #1)** | ✅ **The gate this increment defends.** The draw branch re-checks at `completeOrder`; the bypass test submits a bad draw **directly to the seam** and must be refused. | +| **Security — never trust the token; re-derive (Inv #2)** | ✅ Re-price from catalog; the grant's bounds are re-verified, never a balance/price trusted from the blob. | +| **Security — per-subject/per-intent state (Inv #4)** | ✅ Revocation + single-use consume key off `intentId`/subject; no process-global state. | +| **Security — positive claims / step-up (Inv #5)** | ✅ Age-restricted ⇒ **always** step-up; over-cap/scope are positive, explicit refusals. | +| **Security — origin/replay (Inv #6)** | ⚠️ **Partial (named)** — v0.1 seams enforce disclosure+binding+bounds, not issuer/device trust; fenced per honesty label until the wallet-server increment wires DeviceKey PoP. | +| **Dev workflow & quality gates** | ✅ Plan cites real code + the spike; bypass tests required and must fail red when the control is removed; `npm run build`+`test` green before "done"; DCO. | + +**Result: PASS conditional on the Decision-13 amendment** (Principles II/III/VII). Invariant 6 is **partial by +design** and honesty-fenced. Residual items → Complexity Tracking. + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-human-not-present/ +├── plan.md # This file +├── spec.md # Feature spec (+ Option-B reconciliation callout) +├── sequencing-fork-memo.md # RATIFIED: Option B, seams-first +├── connector-architecture-design.md # §9 refusals · §10 pivot · §11 honesty labels +├── intent-bounds-schema-draft.md # AP2 + EUDI TS12 bounds fields +├── redemption-choreography-draft.md # the six-call redeem sequence +├── constitution-amendment-draft.md # Decision-13 (Principles II/III/VII) — prerequisite +├── checklists/requirements.md # Spec quality checklist +└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here) +``` + +### Source Code (repository root) + +```text +packages/credentagent-gate/src/ceremony/ +├── mandate.ts # EXISTING (AP2 mandate + deterministic gates) — EXTEND: +│ # IntentMandate + PaymentMandate(draw) types; canonical(bounds); +│ # contentAddressId(); checkDraw(intent, draw, ctx) → Refusal[] +│ # (pure/total; signer-agnostic verifyBounds seam). Ported from spike/intent-mandate. +├── refusals.ts # NEW — the §9 typed-refusal vocabulary (enum + enforcer/retryable tags), +│ # shared by checkDraw, the completeOrder branch, and (later) the rail. +├── revocation.ts # NEW — RevocationStore seam (per-intentId + per-subject kill-switch); +│ # MemoryRevocationStore default; fail-closed; mirrors store.ts/VerificationStore. +├── completion.ts # EXISTING completeOrder — ADD an additive, fail-closed DRAW branch: +│ # re-run checkDraw + revocation + ATOMIC single-use consume; write delegationId; +│ # suppress ctx.settle. HP paths untouched. +├── mandate.test.ts # EXTEND / NEW — checkDraw bounds tests (port the spike's 13 + the FR US2 scenarios) +└── completion.test.ts # EXTEND — draw-branch BYPASS tests: bad draw direct-to-completeOrder refused; + # TOCTOU revoke; atomic single-use; age-always-step-up. Each red-when-control-removed. +``` + +**Structure Decision**: The seams land entirely in `credentagent-gate`, extending the existing `mandate.ts` +(which already holds the AP2 mandate + deterministic gates) rather than adding a rail directory — because this +increment ships **no HTTP surface**. The Intent/Payment-draw types + `checkDraw` port directly from +`spike/intent-mandate/` (already ES256/`K_s`-shaped); `RevocationStore` mirrors the `VerificationStore` seam so +multi-instance deploys inject a shared store; the `completeOrder` draw branch is additive and fail-closed so +every existing HP path is byte-unchanged. The HTTP **intent rail** (`ceremony/intent/` with the +`dcql/request/verify/page/routes` split) and the **wallet server** are deliberately deferred to the next +increments per the ratified build order. + +## Complexity Tracking + +| Item | Why it's allowed / how it's fenced | +| :-- | :-- | +| **Constitution Principles II/III/VII need a MINOR amendment** | Tracked as Decision-13; a separate `/speckit-constitution` step gates `/speckit-implement`. The plan does not proceed to implement until it lands. | +| **Invariant 6 only partially met** | By design for v0.1 seams (disclosure/binding/bounds, not issuer/device trust). Named + honesty-fenced (`connector-arch` §11); closed in the wallet-server increment (DeviceKey PoP). | +| **Signer-agnostic envelope (extra seam)** | Small cost, deliberate: keeps `checkDraw` reusable by the wallet server's ES256 `K_s` and the HMAC path without a rewrite. | diff --git a/specs/005-human-not-present/sequencing-fork-memo.md b/specs/005-human-not-present/sequencing-fork-memo.md index 45226ba..ce8aa62 100644 --- a/specs/005-human-not-present/sequencing-fork-memo.md +++ b/specs/005-human-not-present/sequencing-fork-memo.md @@ -1,6 +1,6 @@ # 005 sequencing fork — decision memo (2026-07-03) -**For the maintainer to ratify.** Resolves the "Open maintainer decision" left in +**RATIFIED by the maintainer 2026-07-08: Option B (wallet-custody directly, seams-first).** Resolves the "Open maintainer decision" left in `connector-architecture-design.md` §10, now that the on-device spike is complete (Runs 1–5, `on-device-spike-runbook.md`) **including a green settlement**. This memo does not change any load-bearing concept in `spec.md` — only *what ships first*. diff --git a/specs/005-human-not-present/spec.md b/specs/005-human-not-present/spec.md index 65b5b1d..f5e15e5 100644 --- a/specs/005-human-not-present/spec.md +++ b/specs/005-human-not-present/spec.md @@ -18,22 +18,21 @@ a 13-item decision menu). --- -> ## ⚠️ Decisions baked in from the research — confirm or override before `/speckit-plan` +> ## ✅ Decisions baked in from the research — Group-A + sequencing RATIFIED 2026-07-08 > > Drafted **autonomously while the maintainer was asleep** from the research's **recommended** answers, then > **hardened by a 4-lens adversarial review** (invariants / honesty / faithfulness / real-code feasibility) that -> read the actual library code. They are recommendations, **not settled**. The Group-A calls (1–3) were -> discussed with the maintainer on 2026-07-01 and a **tentative direction** was noted (D3: WebAuthn + -> on-device DPC via Multipaz as the v0.2 user-signing ceremonies) — **tentative pending the maintainer's -> fuller understanding of the key architecture; do not treat as confirmed**. Confirm/override each before -> `/speckit-plan`; Decision 13 (the constitution amendment) remains a separate prerequisite step for -> `/speckit-implement`. +> read the actual library code. The Group-A calls (1–3) were discussed with the maintainer on 2026-07-01 +> (wallet/key-architecture deep-dives) and **RATIFIED on 2026-07-08**, D3 including the v0.2 user-signing +> direction (WebAuthn + on-device DPC via Multipaz). The sequencing fork is likewise **ratified: Option B** +> (`sequencing-fork-memo.md`). Decisions 4–12 stand as baked-in. Decision 13 (the constitution amendment) is +> the remaining prerequisite for `/speckit-implement` — executed as the amendment in `.specify/memory/constitution.md`. > > | # | Decision | Baked-in choice (post-review) | > | :-- | :-- | :-- | -> | 1 | Model the Intent Mandate now? | **Yes** — typed `ap2.IntentMandate` + a deterministic bounds-check gate. *Tentative (discussed 2026-07-01; not settled)* | -> | 2 | Represent the absent human honestly? | **New orthogonal `presence` axis** (`live \| delegated-demo \| delegated`) AND a new weaker authorization value **`server-issued-demo`** — do NOT reuse `presence-only-demo` for HNP. *Tentative (discussed 2026-07-01; not settled)* | -> | 3 | What signs the v0.1 grant? | **Server-HMAC** (reuse `signingKey`); proves *issuance only*. In v0.1 the **server**, not the user, composes + signs the bounds. `alg` is reserved (no working swap exists yet). *Tentative v0.2 direction (discussed 2026-07-01; not settled): two user-signing ceremonies — WebAuthn (passkey over hash-of-bounds) + on-device DPC via Multipaz (DC API / OpenID4VP)* | +> | 1 | Model the Intent Mandate now? | **Yes** — typed `ap2.IntentMandate` + a deterministic bounds-check gate. **Ratified 2026-07-08** | +> | 2 | Represent the absent human honestly? | **New orthogonal `presence` axis** (`live \| delegated-demo \| delegated`) AND a new weaker authorization value **`server-issued-demo`** — do NOT reuse `presence-only-demo` for HNP. **Ratified 2026-07-08** | +> | 3 | What signs the v0.1 grant? | **Server-HMAC** (reuse `signingKey`); proves *issuance only*. In v0.1 the **server**, not the user, composes + signs the bounds. `alg` is reserved (no working swap exists yet). **Ratified 2026-07-08**, incl. the v0.2 direction: two user-signing ceremonies — WebAuthn (passkey over hash-of-bounds) + on-device DPC via Multipaz (DC API / OpenID4VP)* | > | 4 | Which effects get delegation? | **Action effects only**; age/membership **not delegable**; age-restricted ⇒ **always step up** | > | 5 | Single-use vs reusable? | **Single-use** grant, enforced by an **atomic** per-grant consume | > | 6 | Caps? | **Per-action cap only**, an **absolute ceiling (tolerance = 0)**; cumulative/velocity out | @@ -239,7 +238,7 @@ user-authorization field; assert the `completeOrder` HNP branch never calls `ctx field is **reserved**: v0.1 fixes it to the HMAC suite; a future user/agent-key-signed variant (ES256 / KB-JWT) requires **widening the `alg` union AND adding a verify-dispatch that does not exist today** (this branch has only `mandate.ts` with `alg: "MOCK-DEV-SIGNER"`). Do not present `alg` as a working drop-in. - **Tentative direction (discussed 2026-07-01; not settled): v0.2 would ship two user-signing ceremonies for + **Ratified direction (2026-07-08): v0.2 ships two user-signing ceremonies for the intent bounds** — (a) **WebAuthn**: the passkey assertion's challenge = hash(intent bounds, including the agent key), mirroring the `passkey/` rail; (b) **on-device DPC (Multipaz)**: a device-stored Digital Payment Credential presented via @@ -372,7 +371,7 @@ user-authorization field; assert the `completeOrder` HNP branch never calls `ctx - **Holder binding + per-draw proof-of-possession** (the grant is a bearer token in v0.1) — the v0.2/v0.3 line where invariant 6 becomes fully upheld. - **User/agent-key signing** — the v0.2 bridge (widening `alg` + a two-format verify-dispatch). - **Tentative direction (2026-07-01; not settled): two user-signing rails** — (a) **WebAuthn** passkey assertion over hash(bounds) and + **Ratified direction (2026-07-08): two user-signing rails** — (a) **WebAuthn** passkey assertion over hash(bounds) and (b) an **on-device DPC (Multipaz)** presented via the DC API / OpenID4VP with the bounds in the transcript. **Issuer-verified credentials** — the v0.3 destination where an HNP grant becomes a *real* control (the DPC rail is the natural bridge: device-bound today, issuer-verifiable once the trust anchor lands — but **not diff --git a/specs/005-human-not-present/tasks.md b/specs/005-human-not-present/tasks.md new file mode 100644 index 0000000..cf458dc --- /dev/null +++ b/specs/005-human-not-present/tasks.md @@ -0,0 +1,68 @@ +# Tasks: HNP First Increment — the shared gate seams (Option B) + +**Input**: [plan.md](plan.md) (ratified 2026-07-08) · [spec.md](spec.md) (Group-A ratified) · +constitution **v1.1.0** (Decision-13 amendment applied — the `/speckit-implement` gate is OPEN) + +**Discipline** (constitution · Development Workflow): TDD; every security-bypass test MUST go red when its +control is removed (spot-verified during implementation); DCO on every commit; build + full suite green +before "done". + +## Phase A — Typed refusals (the shared vocabulary) + +- [ ] **T001** `src/ceremony/refusals.ts` (NEW): the §9/choreography-draft discriminated refusal — + `code` union (`signature · intent-mismatch · currency-mismatch · over-cap · over-total · not-yet-valid · + expired · out-of-scope · unpermitted-presentment · replay · step-up · revoked · consumed · + revocation-unavailable`), `enforcer: "wallet" | "merchant" | "psp"` (attribution), `retryable: "retry" | + "needs-human" | "terminal"` (the bit an unattended loop branches on), per-reason detail fields. Pure data. + +## Phase B — Intent bounds + checkDraw (port `spike/intent-mandate/`) + +- [ ] **T002** Extend `src/ceremony/mandate.ts`: `IntentBounds` + `Draw` types (typed `ap2.IntentMandate` + bounds per `intent-bounds-schema-draft.md`), `canonical()` (stable recursive key sort), + `contentAddressId()` / `sealIntent()` (`int_` + b64url(SHA-256(canonical(bounds \ intentId)))), + ES256 (P-256, WebCrypto) draw signing/verification with a **signer-agnostic injectable verifier**, + `checkDraw(intent, draw, ctx) → { ok, refusals }` — pure, total, never throws, accumulates (no first-fail). +- [ ] **T003** `src/ceremony/mandate.intent.test.ts` (NEW): port the spike's 13 tests to vitest/TS — + content-addressing commits to every field · canonical order-independence · in-bounds passes · over-cap + (documents the co-fire finding) · over-total · window both edges · out-of-scope merchant · replay · + unpermitted-presentment (age never delegable) · step-up is `needs-human` · tamper-after-sign refused · + wrong-key refused · refusals accumulate. + +## Phase C — RevocationStore seam + +- [ ] **T004** `src/ceremony/revocation.ts` (NEW): `RevocationStore` interface — `isRevoked(intentId, + subject?)`, `revoke(intentId)`, `revokeSubject(subject)` (kill-switch), `priorDraws(intentId)`, + **`commitDraw(intentId, draw)` atomic check-and-append** (returns false on duplicate + `pspTransactionId` — the single-use control; per-order idempotency does NOT cover two orders drawing one + intent). `MemoryRevocationStore` default (mirrors `MemoryVerificationStore`); multi-instance deploys + inject Redis/CAS. +- [ ] **T005** Revocation tests (in T007's file or standalone): revoke → next draw refused · + kill-switch by subject · commitDraw atomicity (same pspTransactionId twice → second false). + +## Phase D — `completeOrder` draw branch (additive, fail-closed) + +- [ ] **T006** Extend `src/ceremony/types.ts` + `completion.ts`: `CompletionInput.draw?: { intent, draw }`; + `CompletionContext.revocation?: RevocationStore`, `now?()`; `CompletedRecord.delegationId?`; + `CompletionResult.refusals?`. Branch order (only when `input.draw` present, HP paths byte-unchanged): + missing store ⇒ refuse fail-closed → `isRevoked` (throw ⇒ `revocation-unavailable`, fail-closed) → + `checkDraw` with store-sourced `priorDraws` → existing catalog re-price stays authoritative → + **draw.amount must equal the re-priced total** (invariants 2+3) → **age-restricted ⇒ always `step-up`** + (never completes from a grant; invariant 5) → **atomic `commitDraw`** (false ⇒ `consumed`/`replay`) → + write record with `delegationId = intentId` → **suppress `ctx.settle`** for the demo-fenced draw. +- [ ] **T007** `completion.test.ts` EXTEND — bypass tests, each control-dependent (spot-verify red): + in-bounds draw completes: `delegationId` written, settle spy NOT called · over-cap draw direct to + `completeOrder` refused, nothing recorded · tampered signature refused · revoked refused (incl. TOCTOU: + revocation lands after a passing pre-check → seam still refuses) · store-throws ⇒ fail-closed refuse · + two draws, one `pspTransactionId`, two order ids ⇒ exactly one completes · age-restricted cart via draw ⇒ + refused `step-up` even with `ageVerified` in the verification store · amount-mismatch (draw.amount ≠ + re-priced total) refused. + +## Phase E — Surface + docs + +- [ ] **T008** Export the new types/functions from `src/index.ts`; gate README gains a short + "Delegated draws (HNP seams, v0-preview)" section with the honesty labels (presence axis per + constitution VII v1.1.0). +- [ ] **T009** Full suite green (`npm run build` + `npm test`); red-verification spot checks recorded in + the PR body; STATUS.md updated; draft PR referencing this tasks.md. + +**Out of scope** (per plan): HTTP intent rail, wallet server, settlement verifier, `credentagent-agent`.