Verifiable Intent Plugin
TrustWeave implementation of Verifiable Intent (VI) — the Mastercard/Google open specification
(agent-intent/verifiable-intent, draft v0.1)
for cryptographically proving what a user authorized an AI agent to do in agentic commerce.
Module: org.trustweave:credentials-plugins-verifiable-intent
(credentials/plugins/verifiable-intent).
Overview
OAuth proves identity and grants broad scopes, but it cannot answer the question agent-driven commerce needs: exactly which action, within exactly which constraints, did the user approve? Verifiable Intent answers that with a layered SD-JWT credential chain, signed end to end with ES256:
1
2
3
4
L1 issuer credential binds the user's key (cnf.jwk, ~1 year) typ: sd+jwt
└─ L2 user mandate constraints + agent key (cnf.jwk, hours–days) typ: kb-sd-jwt[+kb]
└─ L3a payment agent's payment action (→ network, ~minutes) typ: kb-sd-jwt
L3b checkout agent's checkout action (→ merchant, ~minutes) typ: kb-sd-jwt
Each layer’s sd_hash cryptographically binds the previous layer. Selective disclosure routes only
the relevant claims to each party — the payment network sees the payment side, the merchant sees the
checkout side — and the payment network enforces the constraints, not the agent itself.
Modes
- Immediate — two layers (L1 + a finalized L2). The user confirmed concrete values; the payment
mandate’s
transaction_idequals the checkout mandate’scheckout_hash. - Autonomous — three layers (L1 + a constrained/open L2 + agent-signed L3a/L3b). The user sets machine-enforceable constraints and delegates to an agent key; the agent fills in finalized values at transaction time within those bounds.
The mode is inferred from the L2 mandate vct (open vs. final), never from caller arguments.
How it composes onto TrustWeave
VI’s primitives are exactly what TrustWeave already provides — SD-JWT VC, selective disclosure,
cnf key binding, KB-JWT, and ES256 keys (Algorithm.P256 + EcdsaSignatureCodec in kms-core).
This plugin adds the VI-specific pieces the existing SD-JWT proof engine does not have:
- SD-JWT array-element disclosures (
{"...": digest}) — load-bearing fordelegate_payload. - Cross-layer
sd_hash— an L3’s hash binds the routed L2 presentation it received, not its own credential. - Embedded-JWK key resolution — L1
cnf.jwkverifies L2, an L2 open-mandatecnf.jwk(+kid) verifies L3, and L3 carries nocnf. No DID resolution is involved. - The mandate/constraint model and an enforcement engine.
Mapped onto the SaaS surface: an issuer mints L1, a wallet holds L1 and signs L2/L3, and a verifier gateway runs the chain verification + constraint enforcement.
Verifying a chain (verifier gateway)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.trustweave.credential.vi.VerifiableIntent
val result = VerifiableIntent.verifyChain(
l1 = l1Compact,
l2 = l2Compact,
issuerJwk = issuerPublicJwk, // issuer EC P-256 public key (JWK)
l3Payment = l3aCompact,
l2RoutedForPayment = routedL2ForNetwork,
expectedL2Aud = expectedAgentAudience,
expectedL2Nonce = expectedDelegationNonce,
expectedL3PaymentAud = expectedNetworkAudience,
expectedL3PaymentNonce = expectedPaymentNonce,
)
if (result.valid) {
// signatures, cross-layer sd_hash, key binding, reference binding and
// all constraints checked — the agent acted within the mandate.
}
The expectations above must come from verifier-controlled request state. Use a fresh nonce and
atomically consume it when accepting a payment; comparing a nonce alone does not prevent reuse.
For a presented checkout token, also supply expectedL3CheckoutAud and expectedL3CheckoutNonce.
Mode is inferred automatically — for an immediate chain pass only l1 + l2.
Issuing a chain (KMS-backed)
Signing flows through KmsEs256Signer, backed by any P-256 key in a TrustWeave
KMS:
1
2
3
4
5
6
7
import org.trustweave.credential.vi.crypto.KmsEs256Signer
import org.trustweave.credential.vi.issuance.*
val signer = KmsEs256Signer(kms, keyId)
val l1 = ViIssuer.createLayer1(issuerCredential, signer, issuerKid = "issuer-key-1")
val l2 = ViUser.createLayer2Autonomous(l1, checkoutMandate, paymentMandate, /* ... */ signer, kid)
val l3a = ViAgent.createLayer3Payment(finalPayment, l2.baseJwt, listOf(l2.paymentDiscB64!!), /* ... */)
Constraint types
Open (autonomous) L2 mandates carry machine-enforceable constraints (integer minor units, no decimal ambiguity):
| Type | Enforces |
|---|---|
mandate.checkout.allowed_merchants |
Merchant allowlist |
mandate.checkout.line_items |
Not implemented; autonomous checkout fails closed |
mandate.payment.allowed_payees |
Payee allowlist |
mandate.payment.amount_range |
Per-transaction min/max + currency |
mandate.payment.budget |
Requires external state; open mandates fail closed |
mandate.payment.recurrence / agent_recurrence |
Requires external state; open mandates fail closed |
mandate.payment.reference |
Binds the payment mandate to the checkout disclosure |
Unknown constraint types are rejected in open mandates (an unevaluable constraint would leave agent
authority unbounded) or under STRICT strictness; otherwise skipped under PERMISSIVE.
Allowlist resolution caveat
allowed_payees and allowed_merchants entries may be inline objects or SD-references
({"...": digest}) whose value is disclosed separately. References are resolved against the
disclosures the verifier holds. If the target matches no resolvable entry and some entries could
not be resolved, the allowlist cannot be fully evaluated — an agent could otherwise disable the
constraint just by withholding a disclosure. In that case:
- open mandate — fails closed, because unbounded authority plus an unevaluable allowlist leaves the payee/merchant unconstrained;
- bounded mandate — passes, on the assumption that the mandate constrains the counterparty by other means.
If your issuance profile emits bounded mandates whose payee is pinned only by the allowlist, confirm that the disclosures needed to resolve it are always presented, or require an open mandate.
Status and scope
Draft, tracking VI spec v0.1 — Experimental (see the Module maturity matrix). Verified two ways:
- Cross-stack interop — verifies tokens minted by the reference Python implementation against a committed known-answer fixture.
- Round trip — mints L1/L2/L3 through the in-memory KMS +
KmsEs256Signer, then verifies (autonomous, immediate, and a negative per-transaction amount case).
Run the suite: ./gradlew :credentials:plugins:verifiable-intent:test
Deliberate scope boundaries: multi-pair L2 (one mandate authorizing several purchases),
line_items deep matching, returning the core Result<T> type, and plugin/SPI discoverability.
See the module README
for details.
Related documentation
- Supported plugins — full plugin catalog
- Proofs and proof engines
- SIOPv2 plugin — another SD-JWT-adjacent exchange plugin