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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions packages/ap2/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,93 @@
# @docknetwork/ap2

## 0.5.0

### Minor Changes

- eb7b2bd: `verifyClosedPaymentMandate`'s `openCheckoutMandatePresentation` handling now
fully resolves that presentation (schema validation and its own `exp` check,
in addition to the issuer signature check it already did) before using it to
compute `referenceVerified`. Previously it only checked the raw envelope
signature, so an expired or otherwise schema-invalid Open Checkout Mandate
presentation could still produce `referenceVerified: true` — the exact class
of gap this package has been closing elsewhere (verification that exists at
one layer but isn't enforced end-to-end). An invalid `openCheckoutMandatePresentation`
now fails the whole `verifyClosedPaymentMandate` call rather than silently
computing a reference match from it.

- 29dea58: `verifyClosedCheckoutMandate` and `verifyClosedPaymentMandate` now require
`openMandatePresentation` and always derive the envelope's expected signing
key from the referenced Open Mandate's own `cnf.jwk`. The `publicKey`/
`holderJwk` parameters are removed entirely — there is no longer a way to
verify a Closed Mandate against a caller-supplied key. Previously, a caller
that verified a signature against a self-supplied key had no guarantee that
key was ever the one the Open Mandate actually endorsed as its holder — a
Closed Mandate signed by an unauthorized key would still "verify" as long as
the caller supplied that same (wrong) key. Since this package has no
production usage yet, that insecure fallback is removed rather than kept
for backwards compatibility: calling either function without
`openMandatePresentation` now returns `{ verified: false, error }` instead
of silently trusting an unverified key.

Both functions also now require `userPublicKey` — the User's own key,
resolved independently by the caller (e.g. via DID resolution or a wallet
registry) — and verify it against the referenced Open Mandate's own issuer
signature. Without this, deriving the verification key from `cnf.jwk` only
proves the Closed Mandate matches whatever the Open Mandate says, not that
the Open Mandate itself came from a real, authorized User: an attacker could
previously fabricate an entire Open + Closed Mandate chain naming their own
key in `cnf.jwk`, and every internal check (digest consistency, envelope
signature, `sd_hash`) would pass, since they're all self-referential to the
forged chain. `resolveOpenCheckoutMandateContent`/
`resolveOpenPaymentMandateContent` gained a matching optional
`userPublicKey` parameter (returning `issuerVerified: true` when supplied and
valid) for callers using them directly; omitting it there still only checks
internal self-consistency, as before.

Added `resolveOpenCheckoutMandateContent`, mirroring
`resolveOpenPaymentMandateContent` for Open Checkout Mandates, which the fix
above uses internally and which callers can also use directly (e.g. to
enforce checkout constraints or re-verify `cnf.jwk` before closing a mandate,
the way `issueClosedPaymentMandate`-style flows already do for payments).

`verifyClosedPaymentMandate` also accepts a new optional
`openCheckoutMandatePresentation` parameter. When supplied alongside
`openMandatePresentation`, its issuer signature is verified against the same
`userPublicKey`, then the Open Payment Mandate's
`payment.reference.conditional_transaction_id` constraint is checked against
a fresh `sd_hash` of that Open Checkout Mandate presentation, returning
`referenceVerified` in the result. This was previously left entirely to the
caller (see the AP2 spec's Reference constraint) even though the package
already required both presentations for its other checks.

Both `verifyClosedCheckoutMandate` and `verifyClosedPaymentMandate` now also
check the envelope's `typ` claim (`"kb+sd-jwt"`), rejecting an Open Mandate
presentation (`typ: "dc+sd-jwt"`) or any other envelope type fed in where a
Closed Mandate is expected. Other checks (schema shape, `aud`,
`checkout_hash`) would likely catch a swapped-in envelope in practice, but
this closes the gap explicitly rather than relying on that.

Both functions also now require `expectedNonce`, checked against the
envelope's `nonce` claim. Closed Mandate envelopes carry no `exp` of their
own -- their only expiry comes transitively from the referenced Open
Mandate, which is typically valid for an entire shopping session -- and
`nonce` was previously accepted at signing time but never checked at
verification time, so a validly-signed Closed Mandate presentation could be
replayed for a different transaction indefinitely. `expectedNonce` should be
the single-use value the merchant/credential-provider itself generated and
tracks for this specific transaction.

Added an example, `examples/verify-mandate-with-did-user-key.mjs` (run via
`yarn example:did-mandate`), showing how to resolve `userPublicKey` from a
User's DID document and use it in the verification flow above.

Documented (README + JSDoc on `buildOpenCheckoutMandate`/
`buildOpenPaymentMandate`) that AP2 v0.2 defines no Mandate revocation
mechanism at all -- it's explicitly out of scope per spec -- and that `exp`
is the only verifier-enforceable lifecycle control, RECOMMENDED but
schema-optional. No behavior changed; this closes a documentation gap
identified while reviewing the spec, not a code gap.

## 0.4.2

### Patch Changes
Expand Down
80 changes: 75 additions & 5 deletions packages/ap2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,23 @@ const jwt = await signReceipt(receipt, {
signer: keypair,
type: 'payment',
});
// Verify the mandate first (signature, cnf key-binding, checkout binding, aud, expiry).
// Verify the mandate first (signature, cnf key-binding, checkout binding, aud, expiry, nonce).
// openMandatePresentation is required -- it's how the trusted verification
// key is derived from the Open Mandate's own cnf.jwk, rather than trusted
// from a caller-supplied key. userPublicKey is also required: it's the
// User's own key (resolved independently, e.g. via DID resolution or a
// wallet registry), verified against the Open Mandate's issuer signature --
// without it, the cnf.jwk delegation inside a self-consistent but entirely
// forged Open + Closed Mandate chain would go unnoticed. expectedNonce is
// required too: Closed Mandate envelopes carry no exp of their own, so
// nonce -- checked against the single-use value the merchant generated for
// this transaction -- is what stops a validly-signed presentation from
// being replayed for a different one.
const mandateVerification = verifyClosedPaymentMandate(
closedMandatePresentation,
{ /* publicKey or holderJwk, checkoutJwt, openMandatePresentation, ... */ },
{
openMandatePresentation, userPublicKey, expectedNonce, checkoutJwt, openCheckoutMandatePresentation,
},
);

const result = verifyPaymentReceipt(jwt, {
Expand Down Expand Up @@ -145,10 +158,21 @@ const closedCheckoutPresentation = await signClosedCheckoutMandate(closedCheckou
openMandatePresentation: openCheckoutPresentation,
});

// 3. The Merchant/Credential Provider verifies it.
// 3. The Merchant/Credential Provider verifies it. The verification key is
// always derived from the Open Mandate's own cnf.jwk -- there is no
// caller-supplied-key option, since a caller-supplied key that happens to
// verify a signature proves nothing about *whose* key it was authorized to be.
// userPublicKey (the User's own key, resolved independently -- e.g. via DID
// resolution or a wallet registry) is required too, and is checked against
// the Open Mandate's own issuer signature: without it, cnf.jwk could name
// anyone, since nothing would confirm the Open Mandate actually came from
// the User it claims to. expectedNonce -- the same single-use value the
// merchant generated in step 2 -- is required as well, since a Closed
// Mandate envelope has no exp of its own to bound how long it stays replayable.
const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, {
holderJwk: agentPublicJwk, // or publicKey
openMandatePresentation: openCheckoutPresentation,
userPublicKey,
expectedNonce: 'merchant-supplied-nonce',
});
if (!result.verified) throw result.error;
```
Expand Down Expand Up @@ -191,6 +215,43 @@ placeholder string in the docs rather than a deliberate alternate meaning
confirmed against a reference implementation or the normative "Delegate
SD-JWT" individual draft this spec depends on for chain verification.

## Mandate lifecycle and revocation

**AP2 v0.2 defines no revocation mechanism for Mandates, and this package
does not invent one.** The spec's only lifecycle-management guidance
(§ Mandate Management) reads: "In the case of external Trusted Surfaces it
could make sense to allow for management of delegated Mandates but that is
outside the scope of this specification." Managing (and revoking) a granted
Open Mandate is left entirely to the Shopping Agent / wallet UI layer as a
local, User-facing concern — there is no protocol-level status check, status
list, or `credentialStatus`-style claim a verifier can query, and no way for
a Merchant or Credential Provider to learn that a User revoked a mandate
after the fact.

`exp` is therefore the *only* verifier-enforceable way a mandate's lifetime
ends, and it's schema-optional — `buildOpenCheckoutMandate`/
`buildOpenPaymentMandate` both accept content with no `exp` at all, which
this package then treats as never expiring (see "accepts a mandate with no
exp" in the test suite). The spec (§ Autonomous) only RECOMMENDS setting
`exp` "to the smallest value that will allow the Shopping Agent to complete
the assigned task" — it does not require it, and this package does not
enforce it either, since doing so would exceed what the spec actually
mandates. **If your integration needs a shorter effective lifetime or a way
to invalidate a mandate mid-flight, set `exp` deliberately tight yourself**
(re-issuing Open Mandates as needed) and/or track revocation state out of
band on your own side (e.g. a Merchant's own record of voided orders, or a
Credential Provider invalidating the payment *token* it separately issued —
the one place the spec does gesture at invalidation, under § Security
Considerations' Double Spend discussion, and only as a `MAY`).

Adding a proprietary status/revocation field would not help: it would be
invisible to any AP2-conformant counterparty that isn't running this exact
package, and the spec's Constraint mechanism explicitly requires unknown
constraint types to fail evaluation (§ Agent Authorization: "Any unknown
Constraints MUST be treated as failing evaluation"), so a custom
constraint-based revocation scheme would break interop with stock verifiers
rather than add safety.

## Verification guarantees

Receipt verification:
Expand Down Expand Up @@ -223,7 +284,7 @@ digests, key binding, delegation, audience, nonce, dates, constraints,
`checkout_hash`, and payment transaction linkage — then pass that result as
`mandateVerification` alongside `mandatePresentation`.

## Example
## Examples

Run the complete payment receipt issue-and-verify example:

Expand All @@ -232,3 +293,12 @@ yarn example:receipt
```

See [`examples/issue-and-verify-receipt.mjs`](examples/issue-and-verify-receipt.mjs).

Run the Closed Checkout Mandate verification example, showing how to resolve
`userPublicKey` from a User's DID:

```bash
yarn example:did-mandate
```

See [`examples/verify-mandate-with-did-user-key.mjs`](examples/verify-mandate-with-did-user-key.mjs).
116 changes: 116 additions & 0 deletions packages/ap2/examples/verify-mandate-with-did-user-key.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {
buildOpenCheckoutMandate,
signOpenCheckoutMandate,
buildClosedCheckoutMandate,
signClosedCheckoutMandate,
verifyClosedCheckoutMandate,
computeCheckoutHash,
} from '@docknetwork/ap2';
import { Secp256r1Keypair } from '@docknetwork/crypto-utils/keypairs';
import { jwkToSecp256r1PublicKey, secp256r1PublicKeyToJwk } from '@docknetwork/crypto-utils/vc';

// verifyClosedCheckoutMandate/verifyClosedPaymentMandate require a
// `userPublicKey` -- the User's own key, verified against the Open Mandate's
// issuer signature, so that the cnf.jwk delegation inside it can be trusted
// as actually coming from that User rather than an entirely self-forged
// chain. This package deliberately has no opinion on *how* you get that key:
// it's an external trust decision, not something derivable from the mandate
// presentation itself. This example shows the shape of that lookup when the
// User is identified by a DID.

// Stands in for a real DID resolver (e.g. a universal resolver, `did:web`
// over HTTPS, or `@docknetwork/credential-sdk`'s DID resolution module).
// What matters for this example is only the shape of what comes back: a DID
// Document whose `verificationMethod` entries carry a `publicKeyJwk`.
async function resolveDid(did, registry) {
const didDocument = registry.get(did);
if (!didDocument) {
throw new Error(`Unable to resolve "${did}"`);
}
return didDocument;
}

function userPublicKeyFromDidDocument(didDocument) {
const verificationMethod = didDocument.verificationMethod?.[0];
if (verificationMethod?.publicKeyJwk == null) {
throw new Error('DID document has no usable publicKeyJwk verification method');
}
return jwkToSecp256r1PublicKey(verificationMethod.publicKeyJwk);
}

// --- Set up the User's DID and a registry standing in for a resolver ---

const userKeypair = Secp256r1Keypair.random();
const userDid = 'did:example:user-123';
const didRegistry = new Map([
[userDid, {
id: userDid,
verificationMethod: [
{
id: `${userDid}#key-1`,
type: 'JsonWebKey2020',
controller: userDid,
publicKeyJwk: secp256r1PublicKeyToJwk(userKeypair.publicKey()),
},
],
}],
]);

// --- The rest of the flow is unaffected by where userPublicKey came from ---

const agentKeypair = Secp256r1Keypair.random();
const merchant = { id: 'merchant_1', name: 'Demo Merchant', website: 'https://demo-merchant.example' };

const openCheckoutContent = buildOpenCheckoutMandate({
vct: 'mandate.checkout.open.1',
constraints: [
{
type: 'checkout.line_items',
items: [{ id: 'line_1', quantity: 1, acceptable_items: [{ id: 'SKU-1', title: 'Widget' }] }],
},
{ type: 'checkout.allowed_merchants', allowed: [merchant] },
],
cnf: { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) },
});
// Signed by the User's own key -- in a real Trusted Surface / wallet flow,
// this happens on the User's device, tied to the same key their DID resolves to.
const openCheckoutPresentation = await signOpenCheckoutMandate(openCheckoutContent, {
signer: userKeypair,
});

const checkoutJwt = 'eyJhbGciOiJFUzI1NiJ9.eyJvcmRlcl9pZCI6Im9yZGVyLTEifQ.sig';
const closedCheckoutContent = buildClosedCheckoutMandate({
vct: 'mandate.checkout.1',
checkout_jwt: checkoutJwt,
checkout_hash: computeCheckoutHash(checkoutJwt),
});
const nonce = 'merchant-supplied-nonce-1';
const closedCheckoutPresentation = await signClosedCheckoutMandate(closedCheckoutContent, {
signer: agentKeypair,
nonce,
openMandatePresentation: openCheckoutPresentation,
});

// --- The Merchant/Credential Provider verifies it ---

// In production: the Merchant already knows (from earlier in the flow, e.g.
// an authentication step) which User this checkout belongs to, and resolves
// their DID to get a trusted key -- it does not extract a DID from the
// mandate presentation itself, since nothing in the presentation is trusted
// until after this resolution happens.
const didDocument = await resolveDid(userDid, didRegistry);
const userPublicKey = userPublicKeyFromDidDocument(didDocument);

const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, {
openMandatePresentation: openCheckoutPresentation,
userPublicKey,
expectedNonce: nonce,
});

if (!result.verified) {
throw result.error;
}

console.log('Resolved User DID:', userDid);
console.log('Verified Closed Checkout Mandate content:', result.content);
console.log('Open Mandate issuer verified:', result.openMandateIssuerVerified);
3 changes: 2 additions & 1 deletion packages/ap2/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docknetwork/ap2",
"version": "0.4.2",
"version": "0.5.0",
"description": "Basic AP2 support",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -61,6 +61,7 @@
"build": "yarn build:js && yarn build:types",
"generate-schemas": "node scripts/generate-schemas.mjs",
"example:receipt": "yarn build && node examples/issue-and-verify-receipt.mjs",
"example:did-mandate": "yarn build && node examples/verify-mandate-with-did-user-key.mjs",
"test": "jest --runInBand",
"lint": "eslint \"src/**/*.js\"",
"prepublishOnly": "yarn build"
Expand Down
Loading
Loading