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_id equals the checkout mandate’s checkout_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 for delegate_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.jwk verifies L2, an L2 open-mandate cnf.jwk (+ kid) verifies L3, and L3 carries no cnf. 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.


This site uses Just the Docs, a documentation theme for Jekyll.