diff --git a/Dockerfile b/Dockerfile
index 76dee2bb..6fb585ab 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -10,7 +10,6 @@ RUN dnf install -y \
m4 \
make \
pkgconf-pkg-config \
- wget \
xz \
&& dnf clean all
@@ -38,8 +37,10 @@ RUN git clone https://github.com/bitcoin-core/secp256k1.git \
&& make -j"$(nproc)" \
&& make install
-RUN wget -O gmp.tar.xz https://ftp.gnu.org/gnu/gmp/gmp-6.3.0.tar.xz \
- && tar -xf gmp.tar.xz \
+COPY vendor/gmp/gmp-6.3.0.tar.xz vendor/gmp/SHA256SUMS ./
+
+RUN sha256sum --check --strict SHA256SUMS \
+ && tar -xf gmp-6.3.0.tar.xz \
&& cd gmp-6.3.0 \
&& ./configure --enable-shared --disable-static --enable-fat \
&& make -j"$(nproc)" \
@@ -49,6 +50,8 @@ FROM registry.access.redhat.com/ubi9/openjdk-25:1.24
ARG TKEEPER_VERSION=dev
ARG TKEEPER_JAR=build/docker/tkeeper.jar
+ARG TKEEPER_TARGET=all
+ARG TKEEPER_ECC=true
LABEL maintainer="TKeeper Labs" \
app.name="tkeeper" \
@@ -63,6 +66,8 @@ ENV JAVA_OPTS_APPEND="\
-Dcom.sun.management.jmxremote=false \
-Djdk.serialFilter=!* \
-Djdk.tls.client.protocols=TLSv1.3,TLSv1.2 \
+ -Djava.library.path=/usr/local/lib:/usr/lib64:/lib64:/usr/lib:/lib \
+ -Dmsgpack.universal-buffer=true \
-XX:+UseCompactObjectHeaders \
--enable-native-access=ALL-UNNAMED"
@@ -70,6 +75,7 @@ COPY --from=native-build /usr/local/lib/libgmp.so* /usr/local/lib/
COPY --from=native-build /usr/local/lib/libsecp256k1.so* /usr/local/lib/
COPY --from=native-build /usr/local/lib/libsodium.so* /usr/local/lib/
COPY --chown=185:0 ${TKEEPER_JAR} /deployments/tkeeper.jar
+COPY --chown=185:0 tools/NativeJarSmoke.java /tmp/NativeJarSmoke.java
RUN printf '%s\n' \
'public class NativeSmoke {' \
@@ -83,7 +89,12 @@ RUN printf '%s\n' \
&& java \
-Djava.library.path=/usr/local/lib \
/tmp/NativeSmoke.java \
- && rm /tmp/NativeSmoke.java
+ && java \
+ --enable-native-access=ALL-UNNAMED \
+ -Djava.library.path=/usr/local/lib:/usr/lib64:/lib64:/usr/lib:/lib \
+ -cp /deployments/tkeeper.jar \
+ /tmp/NativeJarSmoke.java "${TKEEPER_TARGET}" "${TKEEPER_ECC}" \
+ && rm /tmp/NativeSmoke.java /tmp/NativeJarSmoke.java
ENV JAVA_APP_JAR="/deployments/tkeeper.jar"
diff --git a/README.md b/README.md
index 8aa62245..8f54f86f 100644
--- a/README.md
+++ b/README.md
@@ -1,232 +1,62 @@

-
-
# TKeeper
-[TKeeper Labs](https://tkeeper.org) • [exploit.org](https://exploit.org) • [Documentation](docs/README.md) • [OpenAPI](openapi.yaml)
-
-
-
-TKeeper is a governed cryptographic identity layer for machines, agents, services, and workflows.
-
-A TKeeper identity combines:
-
-- cryptographic key material, held locally or split across peers
-- an authority manifest that defines which typed actions the identity can authorize
-- controls for callers, policy, approvals, quorum, lifecycle, and audit
-
-While traditional access control decides whether a caller may reach an API, TKeeper also decides whether the selected identity may authorize the exact requested action.
-
-For typed authorities, the enforcement contract is simple: \
-**If TKeeper can't understand and approve the action, it won't produce signature.**
-
-## How it works
-
-```text
-Machine / Agent TKeeper Backend
-─────────────── ─────── ───────
-
-Requests an action ────> Understands the exact intent
- Enforces authority and policy
- Collects approval / quorum
- Produces signature ────> Verifies the signature
- Executes the action
-```
-
-TKeeper produces a signature/transaction/certificate only after the request passes the identity's controls. The downstream system must enforce signature validation before executing the effect.
-
-## Use cases
-
-For each use case you can build TKeeper which its own feature modules.
-
-| Use case | What the identity governs |
-|------------------|---------------------------------------------------------------|
-| AI agents | typed tool and production actions, spending, signed decisions |
-| Crypto assets | exact EVM and Bitcoin transactions, treasury workflows |
-| Certificates | X.509 issuance and workload identity operations |
-| Internal systems | typed commands, privileged automation, break-glass flows |
-
-See [Use Cases](docs/use-cases/README.md).
-
-### Quick example
-
-An AI support agent can refund small orders automatically. Larger refunds require human approval.
-
-```yaml
-id: refund-order
-type: custom
-
-config:
- fields:
- orderId: { type: string }
- amount: { type: bigint }
- expiresAt: { type: time }
-
-policy:
- fallback: DENY
-
- approvers:
- support-lead:
- algorithm: ED25519
- publicKey64: "..."
-
- allow:
- - id: automatic-refund
- where:
- - "bigint.lte(amount, '100')"
- - "!time.after(time.now(), expiresAt)"
-
- - id: reviewed-refund
- where:
- - "bigint.gt(amount, '100')"
- - "bigint.lte(amount, '1000')"
- - "!time.after(time.now(), expiresAt)"
- approvals:
- threshold: 1
- approvers: [support-lead]
-```
-
-Now when AI agent asks TKeeper to sign a refund request
-- If refund amount is less or equal `100`, it will approve request and produce signature, that backend must verify before execution.
-- If refund amount is in `100.1000`, TKeeper will require [human approval](docs/security-model/four-eye-control.md) to produce proof.
-- If refund amount is more than `1000`, TKeeper will decline the request.
-
-```json
-{
- "keyId": "support-agent",
- "command": {
- "type": "custom",
- "authorityId": "refund-order",
- "artifact": {
- "scheme": "ECDSA",
- "hash": "SHA256",
- "typed": {
- "orderId": "ord_123",
- "amount": "",
- "expiresAt": "2030-01-02T03:09:05Z"
- }
- }
- }
-}
-```
-
-## Authorities
-
-Authorities define what a key identity may authorize.
-
-| Authority type | Use |
-|------------------------|---------------------------------------------------------|
-| `arbitrary` | raw signing; high-risk, no semantic intent in TKeeper |
-| `custom` | typed JSON commands, internal systems, AI-agent actions |
-| `evm.transaction` | governed EVM transaction signing |
-| `bitcoin.transaction` | governed Bitcoin transaction signing |
-| `x509.tbs-certificate` | governed certificate issuance |
+[Website](https://tkeeper.org) · [Documentation](docs/README.md) · [OpenAPI](openapi.yaml) · [Java SDK](sdk/README.md)
-Concrete authorities use digest-pinned authority documents that act as capability manifests. TKeeper materializes the command into an intent, evaluates policy, and signs only when the final decision is `ALLOW`.
-`custom` is available out of the box. Raw `arbitrary` signing is built in but disabled by default;
-enable `keeper.authority.arbitrary.enabled` only when raw signing is intentional. Other authority
-types can be included during the build.
+TKeeper provides cryptographic identities for AI agents, services, and other non-human actors. Each identity combines:
-See [Signing and Authorities](docs/signing-and-authorities/README.md).
+- a key, held by one node or split across a threshold quorum;
+- an authority manifest attached to that key, defining which actions it can authorize;
+- controls for callers, policy, approvals, audit, and key lifecycle.
-## Deployment modes
+As agents move from proposing actions to moving funds and changing production systems, applications need verifiable authorization for each action. TKeeper checks the identity's manifest and controls, then signs the approved action. The receiving system verifies that signature before executing it. Teams use this boundary to build and secure agent tools and payments, digital asset applications, PKI, and internal automation.
-TKeeper supports 2 deployment modes, one for low-risk and the other for mid-high risk organizations.
+## How an identity authorizes an action
-### Mono
-TKeeper is deployed as single instance. It is the simplest form designed for small orgs where no high-risk (e.g money transfers) actions are executed.
+1. A caller authenticates and requests a signature with a specific key.
+2. TKeeper checks the caller's permission and the key's authority. For a typed command, the authority interprets the action and its policy checks it.
+3. TKeeper collects required approvals and, in threshold mode, a signing quorum. An allowed command gets a signature.
+4. The receiving system verifies the signature or artifact, then executes the authorized action (for example, broadcasting the transaction).
-### Threshold
-TKeeper is deployed as `t` of `n` quorum, where `n` defines total number of TKeeper nodes, `t` defines **compromise tolerance** and `n-t` defines **fault tolerance**.
+`POST /v2/keeper/sign` returns a signature.
+`POST /v2/keeper/compose` runs the same checks and can return a signed transaction, payment credential or any other "composed" artifact. [Signing and Authorities](docs/signing-and-authorities/README.md) explains the request and policy model.
-Simply talking, if you deployed `2-of-3` configuration, it means that:
+Raw `arbitrary` signing has no intent policy and is disabled by default.
-- While **2** nodes are not compromised, attacker can't produce fake proof, steal key or tamper policies.
-- If **1** node is unavailable (e.g. network error), the system continues to work without interruptions.
+## What you can govern
-This mode is designed for high-risk organizations, as it allows to distribute risks inside/across organizations, prevents silent policy weakening, and even allows control to be shared externally (for example, between a platform and users of a crypto wallet).
+| Work | Guide |
+|---------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Agent tools over MCP; AP2 and Mastercard Verifiable Intent payments | [MCP action](docs/use-cases/for-ai.md#govern-an-mcp-action) · [AP2](docs/ai/agentic-payments/ap2.md) · [MC Intent](docs/ai/agentic-payments/mcintent.md) |
+| Bitcoin, EVM, Tron, XRP, and Solana transactions | [For Digital Assets](docs/use-cases/for-digital-assets.md) · [Chain authorities](docs/digital-assets/README.md) |
+| X.509 certificate signing | [For PKI](docs/use-cases/for-pki.md) · [X.509 example](docs/pki/x509.md#authority-example-workload-server-certificate) |
+| Typed commands for internal systems | [For Other Activities](docs/use-cases/for-other-activities.md) · [Custom authorities](docs/signing-and-authorities/arbitrary-and-typed.md) |
->
-> TKeeper can be first configured in mono mode and then promoted to `t-of-n` quorum.
->
+## Multi-Party Computation and compromise resistance
-See [Quorum Modes](docs/security-model/quorum-modes.md).
+TKeeper supports `mono` and `threshold` operating modes. In `mono`, one host holds the full key material; compromising that host compromises the identity.
-## Documentation
+In `threshold` mode, TKeeper uses multi-party computation (MPC) for operations. Each peer holds one key share and checks the operation before contributing. A signature needs `t` accepted contributions from `n` peers. Normal threshold signing does not reconstruct the private key.
-- [Product Overview](docs/overview/README.md)
-- [Use Cases](docs/use-cases/README.md)
-- [Getting Started](docs/getting-started/README.md)
-- [Deployment](docs/deployment/README.md)
-- [Security Model](docs/security-model/README.md)
-- [Status and Limitations](docs/overview/status-and-limitations.md)
-- [Cryptographic Identities](docs/key-management/README.md)
-- [Signing and Authorities](docs/signing-and-authorities/README.md)
-- [Crypto Platforms](docs/crypto-platforms/README.md)
-- [API Reference](docs/api-reference/README.md)
-- [Operations](docs/operations/README.md)
+For a `2-of-3` identity, one compromised peer cannot sign alone or recover the key from its share. One unavailable peer leaves two that can still sign. The protection boundary ends at two compromised peers, and signing stops if fewer than two healthy peers can participate.
-## Build
+Place peers across separate hosts and failure domains to distribute compromise risk. Honest peers with matching authority and policy state reject actions that violate those rules, even if a minority peer is compromised. Keys created with distributed key generation never exist whole on one peer; imported or promoted keys may have existed whole earlier. See [Quorum Modes](docs/security-model/quorum-modes.md) and the [Threat Model](docs/security-model/threat-model.md).
-Cryptographic implementations are selected at build time.
+## Get started
-| Platform | Provides |
-|----------|------------------------------------------------------------------------------|
-| `ecc` | `SECP256K1`, `P256`, `ED25519`, ECDSA, FROST, BIP-340/Taproot, ECIES support |
-| `pqc` | `MLDSA44`, `MLDSA65`, `MLDSA87`, threshold ML-DSA DKG and signing |
+Follow [Local Single Node](docs/getting-started/local-single-node.md) to try TKeeper. A default production build requires Java 25:
-A deployable artifact must include at least one platform.
-
-Build all default production features and platforms:
-
-```bash
+```sh
./gradlew :build -Pkeeper.features=all -Pkeeper.platforms=all
```
-Build only what you need:
-
-```bash
-./gradlew :build -Pkeeper.features=authority-evm -Pkeeper.platforms=ecc
-```
-
-See [Build and Features](docs/deployment/build-and-features.md).
-
-## Security references
-
-**Security assurance:** 367 automated functional scenarios, including 90
-protocol and corruption failure-injection scenarios plus one 3-of-5 share-recovery scenario, run
-for every pull request and commit to `main`. See
-[Security Assurance](docs/security-model/security-assurance.md) for tested attack
-vectors and executable evidence.
-
-- [TKeeper Threat Model](docs/security-model/threat-model.md)
-- [Anvil](https://github.com/exploit-org/anvil) for protocol-level cryptographic components
-
-## API
-
-The HTTP contract is described by [openapi.yaml](openapi.yaml).
-
-Java integrations can use [`org.exploit:tkeeper-sdk:2.4.1`](sdk/README.md).
-
-## Verification
-
-Build a selected production artifact and run its root, SDK, feature, and platform unit tests:
-
-```bash
-./gradlew build -Pkeeper.features=all -Pkeeper.platforms=all
-```
-
-Run the complete release gate:
-
-```bash
-./gradlew releaseGate
-```
+`all` includes default production features. MCP, developer authentication, policy dry run, and recovery require explicit selectors. See [Build and Features](docs/deployment/build-and-features.md) before building a smaller artifact.
-`releaseGate` runs every module's unit tests, verifies artifact isolation, builds `exploit/tkeeper:dev` and `exploit/tkeeper:production-it`, and runs the functional integration suite. Performance benchmarks remain separate. Do not deploy either test image as production runtime.
+To package native libraries for one OS/CPU instead of all bundled variants, add `-Ptarget=linux-amd64` (or another [supported target](docs/deployment/build-and-features.md#select-a-native-oscpu-target)). The default `-Ptarget=all` keeps all bundled variants.
-See [integration-tests](integration-tests/README.md).
+Use the [Java SDK](sdk/README.md) or [OpenAPI](openapi.yaml) to integrate. For production boundaries, read the [Threat Model](docs/security-model/threat-model.md) and [Status and Limitations](docs/overview/status-and-limitations.md).
-## License
+Run the full release checks with `./gradlew releaseGate`. See [Integration Tests](integration-tests/README.md) for the Docker-backed suite.
Apache License 2.0. See [LICENSE.md](LICENSE.md).
diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
index a74abe50..9eaa5cbc 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -1,6 +1,6 @@
# TKeeper Threat Model
-The maintained threat model lives in [docs/threat-model.md](docs/threat-model.md).
+The maintained threat model lives in [docs/security-model/threat-model.md](docs/security-model/threat-model.md).
Protocol-level details for FROST, GG20, ECIES, ZK proofs, nonce handling, Paillier, and curve math live in the tss4j threat model:
diff --git a/assets/tk-bg.png b/assets/tk-bg.png
index 7640bc71..0907116d 100644
Binary files a/assets/tk-bg.png and b/assets/tk-bg.png differ
diff --git a/build.gradle b/build.gradle
index 4cf542e0..faaf293d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,14 +1,14 @@
plugins {
id 'java-library'
- id 'org.jetbrains.kotlin.jvm' version '2.3.20'
+ id 'org.jetbrains.kotlin.jvm' version '2.4.20'
id 'com.gradleup.shadow' version '9.4.1'
- id 'com.google.devtools.ksp' version '2.3.6'
+ id 'com.google.devtools.ksp' version '2.3.10'
}
def mainClassName = 'org.exploit.keeper.TKeeperKt'
group = 'org.exploit'
-version = '2.4.1'
+version = '2.5.0'
repositories {
mavenCentral()
@@ -24,22 +24,111 @@ repositories {
allprojects {
tasks.withType(org.gradle.api.tasks.testing.Test).configureEach {
jvmArgs '--enable-native-access=ALL-UNNAMED'
+ systemProperty 'msgpack.universal-buffer', System.getProperty('msgpack.universal-buffer', 'true')
+ }
+}
+
+def alignedRuntimeVersions = [
+ 'com.fasterxml.jackson:jackson-bom' : '2.22.3',
+ 'com.google.auto.value:auto-value-annotations' : '1.11.1',
+ 'com.google.errorprone:error_prone_annotations' : '2.50.0',
+ 'com.google.guava:guava' : '33.7.1-jre',
+ 'com.google.j2objc:j2objc-annotations' : '3.1',
+ 'com.google.protobuf:protobuf-java' : '4.36.0',
+ 'com.google.protobuf:protobuf-java-util' : '4.36.0',
+ 'commons-codec:commons-codec' : '1.22.1',
+ 'org.apache.httpcomponents:httpclient' : '4.5.14',
+ // t-mldsa:0.2.2 still uses org.bouncycastle.pqc.crypto.mldsa, removed in 1.86.
+ 'org.bouncycastle:bcprov-jdk18on' : '1.85.2',
+ 'org.jetbrains.kotlin:kotlin-reflect' : '2.4.20',
+ 'org.jetbrains.kotlin:kotlin-stdlib' : '2.4.20',
+ 'org.jetbrains:annotations' : '26.1.0',
+ 'org.jspecify:jspecify' : '1.0.1',
+ 'org.slf4j:slf4j-api' : '2.0.19'
+]
+
+// -Ptarget chooses the OS/CPU binaries in the production fat jar; it does not select crypto modules.
+// Keep "all" as the default. GMP, secp256k1, and libsodium loaders use system libraries
+// when a native is not bundled for the selected target.
+def nativeTargets = [
+ 'linux-amd64' : [rocksdb: 'librocksdbjni-linux64.so', bundled: 'natives/linux-amd64/',
+ jansi: 'Linux/x86_64/', zstd: 'linux/amd64/', nativeArch: 'x86_64', nativeExtensions: ['.so']],
+ 'linux-arm64' : [rocksdb: 'librocksdbjni-linux-aarch64.so', bundled: null,
+ jansi: 'Linux/arm64/', zstd: 'linux/aarch64/', nativeArch: 'aarch_64', nativeExtensions: ['.so']],
+ 'macos-amd64' : [rocksdb: 'librocksdbjni-osx-x86_64.jnilib', bundled: null,
+ jansi: 'Mac/x86_64/', zstd: 'darwin/x86_64/', nativeArch: 'x86_64', nativeExtensions: ['.jnilib', '.dylib']],
+ 'macos-aarch64': [rocksdb: 'librocksdbjni-osx-arm64.jnilib', bundled: 'natives/macos-aarch64/',
+ jansi: 'Mac/arm64/', zstd: 'darwin/aarch64/', nativeArch: 'aarch_64', nativeExtensions: ['.jnilib', '.dylib']],
+ 'windows-amd64': [rocksdb: 'librocksdbjni-win64.dll', bundled: 'natives/windows-amd64/',
+ jansi: 'Windows/x86_64/', zstd: 'win/amd64/', nativeArch: 'x86_64', nativeExtensions: ['.dll']]
+]
+def nativeTarget = findProperty('target') == null
+ ? 'all'
+ : findProperty('target').toString().trim().toLowerCase(java.util.Locale.ROOT)
+if (nativeTarget != 'all' && !nativeTargets.containsKey(nativeTarget)) {
+ throw new GradleException("Unknown -Ptarget=$nativeTarget. Use all or one of: ${nativeTargets.keySet().join(', ')}")
+}
+def selectedNatives = nativeTargets[nativeTarget]
+def excludesOtherTargetNative = { String path ->
+ if (path.startsWith('librocksdbjni-')) {
+ return path != selectedNatives.rocksdb
+ }
+ if (path.startsWith('natives/')) {
+ return selectedNatives.bundled == null || !path.startsWith(selectedNatives.bundled)
+ }
+ if (path.startsWith('org/fusesource/jansi/internal/native/')) {
+ return !path.startsWith("org/fusesource/jansi/internal/native/${selectedNatives.jansi}")
+ }
+ if (path ==~ /(?:aix|darwin|freebsd|linux|win)\/[^\/]+\/libzstd-jni-.*\.(?:so|dylib|dll)/) {
+ return !path.startsWith(selectedNatives.zstd)
+ }
+ if (path.startsWith('META-INF/native/')) {
+ return !selectedNatives.nativeExtensions.any { path.endsWith(it) } ||
+ !path.contains(selectedNatives.nativeArch)
+ }
+ return false
+}
+
+allprojects {
+ configurations.matching {
+ it.name in ['compileClasspath', 'runtimeClasspath', 'testCompileClasspath', 'testRuntimeClasspath',
+ 'testFixturesCompileClasspath', 'testFixturesRuntimeClasspath']
+ }.configureEach {
+ resolutionStrategy.eachDependency { details ->
+ def requested = details.requested
+ def version = alignedRuntimeVersions["${requested.group}:${requested.name}"]
+
+ if (requested.group == 'io.netty' && requested.name.startsWith('netty-')) {
+ // netty-handler 4.2.18 calls SSL.getGroupName, absent from tcnative 2.0.77.
+ version = requested.name.startsWith('netty-tcnative') ? '2.0.84.Final' : '4.2.18.Final'
+ }
+
+ if (version != null) {
+ details.useVersion(version)
+ }
+ }
}
}
def featureModules = [
- 'features:seal-aws' : 'keeper.feature.seal.aws',
- 'features:seal-gcloud' : 'keeper.feature.seal.gcloud',
- 'features:authority-bitcoin' : 'keeper.feature.authority.bitcoin',
- 'features:authority-evm' : 'keeper.feature.authority.evm',
- 'features:authority-x509' : 'keeper.feature.authority.x509',
- 'features:ecies' : 'keeper.feature.ecies',
- 'features:ui' : 'keeper.feature.ui'
+ 'features:seal-aws' : 'keeper.feature.seal.aws',
+ 'features:seal-gcloud' : 'keeper.feature.seal.gcloud',
+ 'features:digital-assets:bitcoin' : 'keeper.feature.digital-assets.bitcoin',
+ 'features:digital-assets:evm' : 'keeper.feature.digital-assets.evm',
+ 'features:digital-assets:tron' : 'keeper.feature.digital-assets.tron',
+ 'features:digital-assets:xrp' : 'keeper.feature.digital-assets.xrp',
+ 'features:digital-assets:solana' : 'keeper.feature.digital-assets.solana',
+ 'features:authority-x509' : 'keeper.feature.authority.x509',
+ 'features:ecies' : 'keeper.feature.ecies',
+ 'features:ui' : 'keeper.feature.ui',
+ 'features:agentic-payments:ap2' : 'keeper.feature.agentic-payments.ap2',
+ 'features:agentic-payments:mc-vi' : 'keeper.feature.agentic-payments.mc-vi'
]
def explicitFeatureModules = [
'features:auth-dev' : 'keeper.feature.auth.dev',
'features:dry-run' : 'keeper.feature.dry-run',
+ 'features:mcp' : 'keeper.feature.mcp',
'features:recovery' : 'keeper.feature.recovery',
]
@@ -59,7 +148,8 @@ def shadowedRuntimeModules =
featureModules.keySet() +
explicitFeatureModules.keySet() +
recoveryPlatformModules.values() +
- testOnlyFeatureModules
+ testOnlyFeatureModules +
+ ['features:agentic-payments']
shadowedRuntimeModules.each { moduleName ->
def moduleProject = project(":$moduleName")
@@ -68,13 +158,23 @@ shadowedRuntimeModules.each { moduleName ->
archiveClassifier.set('plain')
}
}
+ moduleProject.pluginManager.withPlugin('com.gradleup.shadow') {
+ moduleProject.tasks.named('shadowJar') {
+ exclude 'org/slf4j/**'
+ }
+ }
}
def featurePlatformRequirements = [
- 'features:authority-bitcoin' : ['platform-ecc'],
- 'features:authority-evm' : ['platform-ecc'],
+ 'features:digital-assets:bitcoin' : ['platform-ecc'],
+ 'features:digital-assets:evm' : ['platform-ecc'],
+ 'features:digital-assets:tron' : ['platform-ecc'],
+ 'features:digital-assets:xrp' : ['platform-ecc'],
+ 'features:digital-assets:solana' : ['platform-ecc'],
'features:authority-x509' : ['platform-ecc'],
- 'features:ecies' : ['platform-ecc']
+ 'features:ecies' : ['platform-ecc'],
+ 'features:agentic-payments:ap2' : ['platform-ecc'],
+ 'features:agentic-payments:mc-vi' : ['platform-ecc']
]
def truthy = { value ->
@@ -96,6 +196,11 @@ def selectedProductionFeatures = [selectedRuntimeFeatures, selectedDockerFeature
.flatten()
.toSet()
+def legacyDigitalAssetSelectors = [
+ 'features:digital-assets:bitcoin': ['authority-bitcoin', 'keeper.feature.authority.bitcoin'],
+ 'features:digital-assets:evm' : ['authority-evm', 'keeper.feature.authority.evm']
+]
+
def explicitlySelectedFeature = { Set features, String moduleName ->
def shortName = moduleName.tokenize(':').last()
features.contains(moduleName) || features.contains(shortName) || features.contains("feature-$shortName")
@@ -107,8 +212,15 @@ def allProductionFeaturesSelected =
selectedDockerFeatures.contains('all')
def shouldIncludeProductionFeature = { String moduleName, String propertyName ->
+ def path = moduleName.tokenize(':')
+ def groupSelected = path.size() == 3 &&
+ explicitlySelectedFeature(selectedProductionFeatures, "features:${path[1]}")
+ def legacySelectors = legacyDigitalAssetSelectors.get(moduleName, [])
+
allProductionFeaturesSelected ||
explicitlySelectedFeature(selectedProductionFeatures, moduleName) ||
+ groupSelected ||
+ legacySelectors.any { selectedProductionFeatures.contains(it) || truthy(findProperty(it)) } ||
truthy(findProperty(propertyName))
}
@@ -179,7 +291,7 @@ dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.14.3'
api 'com.fasterxml.jackson.module:jackson-module-kotlin'
- api 'org.msgpack:jackson-dataformat-msgpack:0.9.10'
+ api 'org.msgpack:jackson-dataformat-msgpack:0.9.12'
api 'org.exploit:jettyx:0.1.7'
api 'org.exploit:jettyx-jackson:0.1.7'
@@ -198,10 +310,10 @@ dependencies {
api 'org.exploit.anvil:xchacha20-poly1305'
api 'org.exploit.anvil:util'
api 'org.exploit.anvil:crypto-spi'
- api 'org.exploit:verdict:0.1.2'
+ api 'org.exploit.verdict:core:0.2.0'
- api 'org.exploit:verdict-authority:0.1.2'
- api 'org.exploit:verdict-authority-oci:0.1.2'
+ api 'org.exploit.verdict:authority:0.2.0'
+ api 'org.exploit.verdict:authority-oci:0.2.0'
api 'com.linecorp.armeria:armeria:1.40.0'
api 'com.linecorp.armeria:armeria-kotlin:1.40.0'
@@ -211,9 +323,9 @@ dependencies {
implementation 'org.fusesource.jansi:jansi:2.4.2'
implementation 'ch.qos.logback:logback-classic:1.5.32'
- implementation 'org.exploit:verdict-intent-typed:0.1.2'
+ implementation 'org.exploit.verdict:typed:0.2.0'
- implementation 'org.bouncycastle:bcprov-jdk18on:1.84'
+ implementation 'org.bouncycastle:bcprov-jdk18on:1.85.2'
implementation("org.exploit:tss4j-natives:1.0.0:linux-amd64@jar")
implementation("org.exploit:tss4j-natives:1.0.0:macos-aarch64@jar")
@@ -265,7 +377,7 @@ dependencies {
tasks.named('shadowJar') {
archiveBaseName.set('tkeeper')
- archiveClassifier.set('')
+ archiveClassifier.set(nativeTarget == 'all' ? '' : nativeTarget)
configurations = [
project.configurations.runtimeClasspath,
@@ -286,6 +398,10 @@ tasks.named('shadowJar') {
'META-INF/NOTICE',
'META-INF/LICENSE*'
+ if (selectedNatives != null) {
+ exclude { element -> !element.directory && excludesOtherTargetNative(element.path) }
+ }
+
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
doFirst {
@@ -297,6 +413,63 @@ tasks.named('shadowJar') {
)
}
}
+
+ doLast {
+ if (selectedNatives == null) {
+ return
+ }
+
+ def archive = new java.util.zip.ZipFile(archiveFile.get().asFile)
+ try {
+ def entries = [] as Set
+ def iterator = archive.entries()
+ while (iterator.hasMoreElements()) {
+ def entry = iterator.nextElement()
+ if (!entry.isDirectory()) {
+ entries.add(entry.name)
+ }
+ }
+ def required = [selectedNatives.rocksdb,
+ 'org/rocksdb/RocksDB.class',
+ 'org/exploit/bigint/LibGMP.class',
+ 'org/exploit/sodium/LibSodium.class']
+ if (selectedNatives.bundled != null) {
+ def extension = nativeTarget.startsWith('linux-') ? '.so' :
+ nativeTarget.startsWith('macos-') ? '.dylib' : '.dll'
+ required += ['libgmp', 'libsecp256k1', 'libsodium'].collect {
+ "${selectedNatives.bundled}${it}${extension}"
+ }
+ }
+ if (enabledPlatformModules.containsKey('platform-ecc')) {
+ required += 'org/exploit/ecc/secp256k1/LibSecp256k1.class'
+ }
+
+ def missing = required.findAll { !entries.contains(it) }
+ def foreign = entries.findAll(excludesOtherTargetNative)
+ if (!missing.isEmpty() || !foreign.isEmpty()) {
+ throw new GradleException("Invalid $nativeTarget jar: missing $missing; foreign native files $foreign")
+ }
+ logger.lifecycle("Verified native contents of ${archiveFile.get().asFile.name} for $nativeTarget")
+ } finally {
+ archive.close()
+ }
+ }
+}
+
+tasks.register('smokeTargetJar', Exec) {
+ group = 'verification'
+ description = 'Loads native libraries from the production fat jar on the selected target OS/CPU.'
+ dependsOn tasks.named('shadowJar')
+
+ def java25 = javaToolchains.launcherFor {
+ languageVersion = org.gradle.jvm.toolchain.JavaLanguageVersion.of(25)
+ }
+ executable = java25.get().executablePath.asFile.absolutePath
+ args '--enable-native-access=ALL-UNNAMED',
+ '-cp', tasks.named('shadowJar').get().archiveFile.get().asFile.absolutePath,
+ 'tools/NativeJarSmoke.java',
+ nativeTarget,
+ enabledPlatformModules.containsKey('platform-ecc').toString()
}
tasks.named('jar') {
@@ -375,8 +548,17 @@ tasks.register('shadowJarProductionTest', com.github.jengelman.gradle.plugins.sh
doLast {
def forbidden = [
+ 'bitcoin' : 'org/exploit/keeper/authority/bitcoin/UtxoInputCommandMaterializer.class',
+ 'evm' : 'org/exploit/keeper/authority/evm/EvmCommandMaterializer.class',
+ 'tron' : 'org/exploit/keeper/authority/tron/TronCommandMaterializer.class',
+ 'xrp' : 'org/exploit/keeper/authority/xrp/XrpCommandMaterializer.class',
+ 'solana' : 'org/exploit/keeper/authority/solana/SolanaCommandMaterializer.class',
+ 'ap2' : 'org/exploit/keeper/payment/ap2/Ap2CommandMaterializer.class',
+ 'mc-vi' : 'org/exploit/keeper/payment/mcvi/McCommandMaterializer.class',
+ 'agentic-payments' : 'org/exploit/keeper/payment/customizer/RegisterPaymentsLibrary.class',
'auth-dev' : 'org/exploit/keeper/auth/dev/provider/DevAuthProvider.class',
'dry-run' : 'org/exploit/keeper/emulator/controller/DryRunController.class',
+ 'mcp' : 'org/exploit/keeper/mcp/controller/McpController.class',
'failure-injection': 'org/exploit/keeper/module/failure/FailureInjectionController.class',
'recovery' : 'org/exploit/keeper/controller/external/RecoveryController.class',
'recovery-ecc' : 'org/exploit/keeper/module/recovery/ecc/service/ECCRecoveryService.class',
@@ -404,8 +586,17 @@ tasks.register('artifactIsolationTest') {
doLast {
def markers = [
+ 'bitcoin' : 'org/exploit/keeper/authority/bitcoin/UtxoInputCommandMaterializer.class',
+ 'evm' : 'org/exploit/keeper/authority/evm/EvmCommandMaterializer.class',
+ 'tron' : 'org/exploit/keeper/authority/tron/TronCommandMaterializer.class',
+ 'xrp' : 'org/exploit/keeper/authority/xrp/XrpCommandMaterializer.class',
+ 'solana' : 'org/exploit/keeper/authority/solana/SolanaCommandMaterializer.class',
+ 'ap2' : 'org/exploit/keeper/payment/ap2/Ap2CommandMaterializer.class',
+ 'mc-vi' : 'org/exploit/keeper/payment/mcvi/McCommandMaterializer.class',
+ 'agentic-payments' : 'org/exploit/keeper/payment/customizer/RegisterPaymentsLibrary.class',
'auth-dev' : 'org/exploit/keeper/auth/dev/provider/DevAuthProvider.class',
'dry-run' : 'org/exploit/keeper/emulator/controller/DryRunController.class',
+ 'mcp' : 'org/exploit/keeper/mcp/controller/McpController.class',
'failure-injection': 'org/exploit/keeper/module/failure/FailureInjectionController.class',
'recovery' : 'org/exploit/keeper/controller/external/RecoveryController.class',
'recovery-ecc' : 'org/exploit/keeper/module/recovery/ecc/service/ECCRecoveryService.class',
@@ -461,11 +652,22 @@ tasks.register('dockerBuild', Exec) {
dependsOn tasks.named('prepareDocker')
executable = 'docker'
- args 'build',
- '-f', 'Dockerfile',
+ args 'build'
+ if (nativeTarget in ['linux-amd64', 'linux-arm64']) {
+ args '--platform', nativeTarget == 'linux-amd64' ? 'linux/amd64' : 'linux/arm64'
+ }
+ args '-f', 'Dockerfile',
+ '--build-arg', "TKEEPER_TARGET=$nativeTarget",
+ '--build-arg', "TKEEPER_ECC=${enabledPlatformModules.containsKey('platform-ecc')}",
'-t', "exploit/tkeeper:${project.version}",
'-t', 'exploit/tkeeper:latest',
'.'
+
+ doFirst {
+ if (nativeTarget != 'all' && !nativeTarget.startsWith('linux-')) {
+ throw new GradleException("Dockerfile builds Linux images; use -Ptarget=linux-amd64, -Ptarget=linux-arm64, or -Ptarget=all")
+ }
+ }
}
tasks.register('dockerBuildIntegration', Exec) {
@@ -546,9 +748,3 @@ test {
kotlin {
jvmToolchain(25)
}
-
-compileKotlin {
- compilerOptions {
- freeCompilerArgs.set(["-Xannotation-default-target=param-property"])
- }
-}
diff --git a/docs/README.md b/docs/README.md
index e9051641..19154c5c 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,43 +1,26 @@
# TKeeper Docs
-TKeeper gives machines, agents, services, and workflows a cryptographic identity whose use is constrained by explicit authorities and policy.
+TKeeper gives machines, agents, services, and workflows a cryptographic identity constrained by authorities and policy. Start with the [overview](overview/README.md), [use cases](use-cases/README.md), or [local setup](getting-started/local-single-node.md).
-## Read first
+## Use cases
-- [Overview](overview/README.md)
-- [Use Cases](use-cases/README.md)
-- [Getting Started](getting-started/README.md)
-- [Deployment](deployment/README.md)
-- [Security Model](security-model/README.md)
-- [Cryptographic Identities](key-management/README.md)
-- [Signing and Authorities](signing-and-authorities/README.md)
-- [Crypto Platforms](crypto-platforms/README.md)
-- [API Reference](api-reference/README.md)
-- [Operations](operations/README.md)
+| Area | Workflow | Authority guides |
+| --- | --- | --- |
+| AI and agentic payments | [For AI](use-cases/for-ai.md) | [MCP, AP2, MC Intent](ai/README.md) |
+| Digital assets | [For Digital Assets](use-cases/for-digital-assets.md) | [Bitcoin, EVM, Tron, XRP, Solana](digital-assets/README.md) |
+| PKI | [For PKI](use-cases/for-pki.md) | [X.509](pki/README.md) |
+| Other activities | [For Other Activities](use-cases/for-other-activities.md) | [Typed authorities](signing-and-authorities/arbitrary-and-typed.md) |
-## Common paths
+## Build an integration
-| Goal | Start here |
-| --- | --- |
-| Understand the product | [What is TKeeper?](overview/what-is-tkeeper.md) |
-| Map it to your use case | [Use Cases](use-cases/README.md) |
-| Run it locally | [Local Single Node](getting-started/local-single-node.md) |
-| Build an artifact | [Build and Features](deployment/build-and-features.md) |
-| Design backup and recovery | [Backup and Recovery](deployment/backup-and-recovery.md) |
-| Choose mono or threshold | [Quorum Modes](security-model/quorum-modes.md) |
-| Create and govern identities | [Create, Rotate, and Refresh](key-management/key-lifecycle.md) |
-| Govern signing | [Authorities](signing-and-authorities/authorities.md) |
-| Preview an authority-policy decision | [Dry Run Policy Evaluation](signing-and-authorities/dry-run.md) |
-| Select cryptographic platforms | [Platforms](crypto-platforms/platforms.md) |
-| Use the HTTP API | [OpenAPI](api-reference/openapi.md) |
-| Run integration tests | [Integration Tests](operations/integration-tests.md) |
-| Review executable security evidence | [Security Assurance](security-model/security-assurance.md) |
+- [Signing and Authorities](signing-and-authorities/README.md): commands, policies, approvals, composition
+- [Cryptographic Identities](key-management/README.md): create, import, rotate, refresh
+- [Crypto Platforms](crypto-platforms/README.md): supported algorithms and platforms
+- [API Reference](api-reference/README.md): HTTP API, SDK, permissions, errors
-## By audience
+## Run Keeper
-| Reader | Recommended path |
-| --- | --- |
-| Application integrator | [Getting Started](getting-started/README.md) -> [Signing](signing-and-authorities/signing.md) -> [Java SDK](api-reference/sdk.md) or [OpenAPI](api-reference/openapi.md) |
-| Security reviewer | [Security Model](security-model/README.md) -> [Threat Model](security-model/threat-model.md) -> [Security Assurance](security-model/security-assurance.md) -> [Status and Limitations](overview/status-and-limitations.md) |
-| Platform operator | [Deployment](deployment/README.md) -> [Production Checklist](deployment/production-checklist.md) -> [Operations](operations/README.md) |
-| Cryptography reviewer | [Crypto Platforms](crypto-platforms/README.md) -> [Quorum Modes](security-model/quorum-modes.md) -> [Anvil threat model](https://github.com/exploit-org/anvil/blob/main/THREAT_MODEL.md) |
+- [Getting Started](getting-started/README.md)
+- [Deployment](deployment/README.md)
+- [Security Model](security-model/README.md)
+- [Operations](operations/README.md)
diff --git a/docs/ai/README.md b/docs/ai/README.md
new file mode 100644
index 00000000..d5449552
--- /dev/null
+++ b/docs/ai/README.md
@@ -0,0 +1,10 @@
+# AI and Agentic Payments
+
+| Task | Guide |
+| --- | --- |
+| Connect an agent host, discover tools, govern MCP calls | [MCP connection and tools](mcp.md) |
+| Sign AP2 payment credentials | [AP2 authority](agentic-payments/ap2.md) |
+| Sign MC Intent payment credentials | [MC Intent authority](agentic-payments/mcintent.md) |
+| Apply Keeper to an AI workflow | [For AI](../use-cases/for-ai.md) |
+
+MCP tools and payment commands use the same [authority and policy checks](../signing-and-authorities/authorities.md) as the HTTP API. See [Agentic Payment Authorities](agentic-payments/README.md) for the shared payment contract.
diff --git a/docs/ai/agentic-payments/README.md b/docs/ai/agentic-payments/README.md
new file mode 100644
index 00000000..c531e6f7
--- /dev/null
+++ b/docs/ai/agentic-payments/README.md
@@ -0,0 +1,14 @@
+# Agentic Payment Authorities
+
+AP2 and Mastercard Verifiable Intent (MC VI) sign ES256 JWS inputs with a P-256 key. Both use Keeper's authority, approval, and signing pipeline.
+
+| Protocol | Authority type | Build selector | Guide |
+| --- | --- | --- | --- |
+| AP2 | `ap2.mandate` | `ap2` | [AP2](ap2.md) |
+| MC VI | `mcintent.mandate` | `mc-vi` | [MC Intent](mcintent.md) |
+
+Build both with `-Pkeeper.features=agentic-payments -Pkeeper.platforms=ecc`, or select one feature from the table. Each guide contains a complete authority YAML and a compose request.
+
+`signingInput` is the exact unpadded base64url JWS header and payload joined by `.`. Supply every disclosure referenced by the mandate. Keeper checks their commitments, evaluates every action against the authority policy, and signs the unchanged input. `/v2/keeper/sign` returns the raw signature; [Composer](../../signing-and-authorities/composer.md) returns a serialized `PaymentCredential` layer.
+
+The caller supplies issuer, audience, nonce, time, and parent-binding claims. The recipient verifies the credential chain, merchant identity, audience, expiry, and replay rules. Keeper checks the outgoing layer's content commitments; parent credentials and merchant signatures need verification by the recipient. See the [module contract](../../../features/agentic-payments/README.md) for supported profiles and disclosure rules.
diff --git a/docs/ai/agentic-payments/ap2.md b/docs/ai/agentic-payments/ap2.md
new file mode 100644
index 00000000..d8eb8c4c
--- /dev/null
+++ b/docs/ai/agentic-payments/ap2.md
@@ -0,0 +1,52 @@
+# AP2 Authority
+
+Use `type: ap2.mandate` for an AP2 payment mandate. The [tested authority](../../../integration-tests/src/testFixtures/resources/authorities/payments/ap2-purchases.yaml) permits the configured shop and card, at most USD 100 per purchase and USD 150 across the request:
+
+```yaml
+schemaVersion: verdict.authority/v1
+id: test:ap2/purchases
+type: ap2.mandate
+version: 2.0.0
+
+config:
+ merchants:
+ officeShop:
+ website: https://office.example
+ methods:
+ companyCard:
+ id: card-1
+ type: card
+
+policy:
+ id: office-purchases
+ fallback: DENY
+ variables:
+ maxPerPurchase: "100.00"
+ maxPerRequest: "150.00"
+
+ allow:
+ - id: office-purchase
+ where:
+ - "action.kind == 'purchase'"
+ - "checkout.merchantIs(merchants.officeShop)"
+ - "payment.payeeIs(merchants.officeShop)"
+ - "payment.methodIs(methods.companyCard)"
+ - "payment.amountAtMost(maxPerPurchase, 'USD')"
+
+ deny:
+ - id: request-total
+ where:
+ - "!request.totalAtMost(maxPerRequest, 'USD')"
+```
+
+`config.mode` defaults to `PAIRED`. For a standalone stage, set it to `PAYMENTS` or `CHECKOUTS`, write the policy for that stage, and use the same mode in the artifact. This example uses `PAIRED`.
+
+```java
+var artifact = new Ap2Mandates(PaymentRequestMode.PAIRED, signingInput, disclosures);
+var command = Command.of("test:ap2/purchases", artifact);
+var result = client.signature().compose(
+ Sign.of(p256KeyId, command), PaymentCredential.class);
+String credential = result.credential();
+```
+
+`signingInput` contains the AP2 protected header and payload encoded as an unsigned JWS; `disclosures` contains every referenced SD-JWT disclosure. The result is the signed credential layer. AP2 supports trusted-provider SD-JWT and delegated KB-SD-JWT; for a delegated credential, present the parent credential bound by `sd_hash`. See the [shared signing contract](README.md) and [module contract](../../../features/agentic-payments/README.md).
diff --git a/docs/ai/agentic-payments/mcintent.md b/docs/ai/agentic-payments/mcintent.md
new file mode 100644
index 00000000..011e38ac
--- /dev/null
+++ b/docs/ai/agentic-payments/mcintent.md
@@ -0,0 +1,52 @@
+# MC Intent Authority
+
+Use `type: mcintent.mandate` for a Mastercard Verifiable Intent mandate. The [tested authority](../../../integration-tests/src/testFixtures/resources/authorities/payments/mcintent-purchases.yaml) permits the configured shop and card, at most USD 100 per purchase and USD 150 across the request:
+
+```yaml
+schemaVersion: verdict.authority/v1
+id: test:mcintent/purchases
+type: mcintent.mandate
+version: 2.0.0
+
+config:
+ merchants:
+ officeShop:
+ website: https://office.example
+ methods:
+ companyCard:
+ id: card-1
+ type: card
+
+policy:
+ id: office-purchases
+ fallback: DENY
+ variables:
+ maxPerPurchase: "100.00"
+ maxPerRequest: "150.00"
+
+ allow:
+ - id: office-purchase
+ where:
+ - "action.kind == 'purchase'"
+ - "checkout.merchantIs(merchants.officeShop)"
+ - "payment.payeeIs(merchants.officeShop)"
+ - "payment.methodIs(methods.companyCard)"
+ - "payment.amountAtMost(maxPerPurchase, 'USD')"
+
+ deny:
+ - id: request-total
+ where:
+ - "!request.totalAtMost(maxPerRequest, 'USD')"
+```
+
+`config.mode` defaults to `PAIRED`. For a terminal L3 stage, set it to `PAYMENTS` or `CHECKOUTS`, write the policy for that stage, and use the same mode in the artifact. This example uses `PAIRED`.
+
+```java
+var artifact = new McMandates(PaymentRequestMode.PAIRED, signingInput, disclosures);
+var command = Command.of("test:mcintent/purchases", artifact);
+var result = client.signature().compose(
+ Sign.of(p256KeyId, command), PaymentCredential.class);
+String credential = result.credential();
+```
+
+`signingInput` contains the MC VI protected header and payload encoded as an unsigned JWS; `disclosures` contains every referenced SD-JWT disclosure. The result is the signed credential layer. MC VI supports L2 Immediate and Autonomous credentials and terminal L3 credentials. The protected header's `typ` identifies the profile; L3 requires `kid` and a validity interval of at most one hour. See the [shared signing contract](README.md) and [module contract](../../../features/agentic-payments/README.md).
diff --git a/docs/ai/mcp.md b/docs/ai/mcp.md
new file mode 100644
index 00000000..b20444ad
--- /dev/null
+++ b/docs/ai/mcp.md
@@ -0,0 +1,58 @@
+# MCP
+
+The `mcp` feature adds `POST /mcp` to TKeeper's public HTTP server. An agent host can discover permitted identities and authorities, read command schemas, verify signatures, and request governed signing or composition. The host holds the Keeper token and sends it with each request.
+
+## What the tools provide
+
+| Tools | Result | Permission |
+| --- | --- | --- |
+| `identity.list`, `identity.describe_authority` | Active signing identities, authority descriptions, command JSON Schemas | `tkeeper.key..sign` |
+| `utility.keeper_status` | Sealed and ready state | `tkeeper.system.status` |
+| `utility.get_public_key` | Public key for an identity | `tkeeper.key..public` |
+| `utility.verify_signature` | Cryptographic command verification | `tkeeper.key..verify` |
+| `action.sign`, `action.compose` | Signature or [composed result](../signing-and-authorities/composer.md) | `tkeeper.key..sign` |
+
+`tools/list` shows tools available to the caller. Each tool call checks permission again for its target key. `identity.describe_authority` exposes the input schema; the attached authority and policy decide whether a submitted command may be signed.
+
+## Connect an agent host
+
+1. Build Keeper with `mcp` and the authority features the host needs. `mcp` is an explicit build selector, including when `keeper.features=all` is selected:
+
+ ```sh
+ ./gradlew :build -Pkeeper.features=mcp,digital-assets,agentic-payments -Pkeeper.platforms=ecc
+ ```
+
+ For local developer authentication, also select `auth-dev` and follow the [single-node setup](../getting-started/local-single-node.md).
+
+2. Run Keeper with its normal [public server and authentication configuration](../security-model/authentication-authorization.md). Create a key with an authority before expecting it in `identity.list`. MCP uses the same public port; there is no separate MCP process.
+
+3. Configure the agent host for Streamable HTTP at `https:///mcp`. It must support MCP version `2026-07-28`, the `server/discover`, `tools/list`, and `tools/call` methods, and custom request headers:
+
+ | Authentication mode | Request header |
+ | --- | --- |
+ | Production JWT | `X-JWT-TOKEN: ` |
+ | Local `auth-dev` | `X-DEV-TOKEN: ` |
+
+ Send the token from the host on every request. Browser requests with an `Origin` header are rejected, so connect from a server-side host. Keeper accepts HTTP POST and returns JSON.
+
+## Check the connection
+
+With a JWT for the agent host, list the available tools:
+
+```sh
+curl -sS 'https:///mcp' \
+ -H 'X-JWT-TOKEN: ' \
+ -H 'Accept: application/json, text/event-stream' \
+ -H 'Content-Type: application/json' \
+ -H 'MCP-Protocol-Version: 2026-07-28' \
+ -H 'Mcp-Method: tools/list' \
+ --data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
+```
+
+For `tools/call`, set `Mcp-Method: tools/call`, set `Mcp-Name` to the tool name, and supply the same name plus `arguments` in `params`. The response places a tool's data in `result.structuredContent`; tool failures set `result.isError: true`. Discovery and tool lists have `ttlMs: 0` and `cacheScope: private`, so refresh them after permission or authority changes.
+
+## Govern signing
+
+Attach a digest-pinned authority to each signing key and grant the host `tkeeper.key..sign` only for the keys it may use. `action.sign` and `action.compose` run the regular policy, approval, audit, and signing pipeline. The system that performs the action must verify the returned proof and enforce freshness or replay rules.
+
+[For AI](../use-cases/for-ai.md#govern-an-mcp-action) has a complete governed action and `tools/call` request. See [agentic payment authorities](agentic-payments/README.md) and [digital asset authorities](../digital-assets/README.md) for command examples. The [feature reference](../../features/mcp/README.md) lists each tool.
diff --git a/docs/api-reference/sdk.md b/docs/api-reference/sdk.md
index 9094612d..ada7d5c1 100644
--- a/docs/api-reference/sdk.md
+++ b/docs/api-reference/sdk.md
@@ -3,7 +3,7 @@
The Java SDK provides typed modules for the public TKeeper API:
- [SDK README](../../sdk/README.md)
-- Maven coordinate: `org.exploit:tkeeper-sdk:2.4.1`
+- Maven coordinate: `org.exploit:tkeeper-sdk:2.5.0`
- Java toolchain: 17 or newer
The SDK follows the same module boundaries as the API: system, DKG, signing, storage/import, quorum promotion, ECIES, dry run, compliance, expiration, audit, integrity, consistency, destroy, and control-plane reads.
@@ -19,3 +19,17 @@ The wire contract remains [`../../openapi.yaml`](../../openapi.yaml). Treat gene
- Use `dryRun().emulate(...)` to preview an authority-policy decision; see [Dry Run Policy Evaluation](../signing-and-authorities/dry-run.md).
- Catch `TKeeperException` and branch on `ErrorType`, not diagnostic detail text.
- Do not retry authorization or policy denials as availability failures.
+
+## Compose results
+
+`signature().compose(Sign.of(keyId, command), ResultType.class)` runs the same authority, policy, approval, and signing checks as `signature().sign(...)`. It then assembles a protocol result when the command type has a composer. See [Composer](../signing-and-authorities/composer.md) for the endpoint and fallback behavior.
+
+| Command type | Result type |
+| --- | --- |
+| `ap2.mandate`, `mcintent.mandate` | `PaymentCredential` |
+| `evm.transaction` | `SignedEvmTransaction` |
+| `tron.transaction` | `SignedTronTransaction` |
+| `xrp.transaction` | `SignedXrpTransaction` |
+| `solana.transaction` | `SignedSolanaTransaction` |
+
+For other types, compose returns `ThresholdSignature`, the same raw signature result as sign. To inspect an unknown result, use `signature().compose(request)` for JSON. The signed Solana result can be partial; check `complete` before broadcast. See [agentic payments](../ai/agentic-payments/README.md) and [digital assets](../digital-assets/README.md) for examples.
diff --git a/docs/crypto-platforms/ecc.md b/docs/crypto-platforms/ecc.md
index 850a054a..46fe4b50 100644
--- a/docs/crypto-platforms/ecc.md
+++ b/docs/crypto-platforms/ecc.md
@@ -13,13 +13,13 @@ The `ecc` platform provides:
Features that currently require `ecc`:
-- `authority-evm`
-- `authority-bitcoin`
+- Bitcoin, EVM, Tron, XRP, and Solana (`digital-assets`)
+- AP2 and MC VI (`agentic-payments`)
- `authority-x509`
- `ecies`
Build example:
```bash
-./gradlew shadowJar -Pkeeper.features=authority-evm,ecies -Pkeeper.platforms=ecc
+./gradlew shadowJar -Pkeeper.features=evm,ecies -Pkeeper.platforms=ecc
```
diff --git a/docs/crypto-platforms/platforms.md b/docs/crypto-platforms/platforms.md
index e675f0a2..33a1fe61 100644
--- a/docs/crypto-platforms/platforms.md
+++ b/docs/crypto-platforms/platforms.md
@@ -33,8 +33,11 @@ Some features require a platform:
| Feature | Required platform |
| --- | --- |
-| `authority-evm` | `ecc` |
-| `authority-bitcoin` | `ecc` |
+| `evm` | `ecc` |
+| `bitcoin` | `ecc` |
+| `tron` | `ecc` |
+| `xrp` | `ecc` |
+| `solana` | `ecc` |
| `authority-x509` | `ecc` |
| `ecies` | `ecc` |
diff --git a/docs/deployment/backup-and-recovery.md b/docs/deployment/backup-and-recovery.md
index 7e6c9cda..4838e3a9 100644
--- a/docs/deployment/backup-and-recovery.md
+++ b/docs/deployment/backup-and-recovery.md
@@ -83,7 +83,7 @@ Legacy unversioned generation zero is outside the recovery boundary.
3. Restart each participant with recovery mode enabled:
```bash
- java -Dkeeper.recovery=true -jar build/libs/tkeeper-2.4.1.jar
+ java -Dkeeper.recovery=true -jar build/libs/tkeeper-2.5.0.jar
```
4. Unseal the keepers if required and confirm health and status.
@@ -93,7 +93,7 @@ Legacy unversioned generation zero is outside the recovery boundary.
```bash
curl --fail-with-body \
--cacert public-ca.crt \
- -H 'Authorization: Bearer ' \
+ -H 'X-JWT-TOKEN: ' \
-H 'Content-Type: application/json' \
-d '{"keyId":"signing-key","helperIds":[1,2,3]}' \
https://keeper-4:8080/v1/keeper/recovery/recover
diff --git a/docs/deployment/build-and-features.md b/docs/deployment/build-and-features.md
index 3fe239c7..6b1ebe70 100644
--- a/docs/deployment/build-and-features.md
+++ b/docs/deployment/build-and-features.md
@@ -26,7 +26,7 @@ Equivalent:
The jar lands under:
```text
-build/libs/tkeeper-2.4.1.jar
+build/libs/tkeeper-2.5.0.jar
```
TKeeper requires Java 25.
@@ -36,7 +36,7 @@ TKeeper requires Java 25.
Example: EVM signing, ECIES, and the UI:
```bash
-./gradlew :build -Pkeeper.features=authority-evm,ecies,ui -Pkeeper.platforms=ecc
+./gradlew :build -Pkeeper.features=evm,ecies,ui -Pkeeper.platforms=ecc
```
Example: ML-DSA only:
@@ -45,14 +45,88 @@ Example: ML-DSA only:
./gradlew :build -Pkeeper.platforms=pqc
```
-Feature names match child project names. The module `:features:authority-evm` is selected with `authority-evm`.
+Feature names match child project names. `:features:digital-assets:evm` is selected with `evm`.
+Use `digital-assets` for Bitcoin, EVM, Tron, XRP, and Solana, and `agentic-payments` for both AP2 and MC VI:
+
+```bash
+./gradlew :build -Pkeeper.features=agentic-payments,digital-assets -Pkeeper.platforms=ecc
+```
+
+The earlier `authority-bitcoin` and `authority-evm` selectors remain accepted.
+
+## Select a native OS/CPU target
+
+`keeper.platforms` selects cryptographic modules (`ecc` and `pqc`). The separate `target`
+property selects native libraries packaged into the **production** fat jar. By default,
+`target=all` keeps native binaries for every bundled OS/CPU combination and produces
+`build/libs/tkeeper-2.5.0.jar`.
+
+Build the complete feature set for a Linux amd64 host with a smaller jar:
+
+```bash
+./gradlew shadowJar -Pkeeper.features=all -Pkeeper.platforms=all -Ptarget=linux-amd64
+```
+
+The output is `build/libs/tkeeper-2.5.0-linux-amd64.jar`. To keep every bundled native variant, use
+`-Ptarget=all` or omit `target`:
+
+```bash
+./gradlew shadowJar -Pkeeper.features=all -Pkeeper.platforms=all -Ptarget=all
+```
+
+| `target` | Output classifier |
+| --- | --- |
+| `all` (default) | none |
+| `linux-amd64` | `linux-amd64` |
+| `linux-arm64` | `linux-arm64` |
+| `macos-amd64` | `macos-amd64` |
+| `macos-aarch64` | `macos-aarch64` |
+| `windows-amd64` | `windows-amd64` |
+
+Targeted jars keep only the matching RocksDB, Netty, gRPC Netty, Conscrypt, Zstd, Jansi,
+GMP, secp256k1, and libsodium native files that are bundled by the dependencies. Java
+classes and selected features remain the same. The integration and production test jars
+always retain their full native set, regardless of `target`.
+
+On Linux arm64 and macOS amd64, GMP and libsodium are loaded from the system;
+secp256k1 is also loaded when `ecc` is selected.
+At startup Keeper tries the bundled libraries first, then calls `System.loadLibrary` for
+any that are absent. Install these shared libraries where the JVM can find them (for
+example via `-Djava.library.path`). The production Dockerfile builds all three for Linux
+and sets the JVM library path; a standalone jar does not install system libraries.
+Linux targets use glibc binaries. For musl-based distributions, use `target=all` and
+verify the remaining native dependencies. A targeted jar is specific to its OS/CPU.
+
+`shadowJar` checks the finished targeted archive: it requires the selected RocksDB
+binary and runtime classes, requires bundled GMP, secp256k1, and libsodium when the
+target has them, and rejects native files for other targets. This check also runs during
+a cross build. It verifies archive contents, not whether the libraries can load on the
+target machine.
+
+On the target host, load the native libraries from the built jar with:
+
+```bash
+./gradlew smokeTargetJar -Pkeeper.features=all -Pkeeper.platforms=all -Ptarget=linux-amd64
+```
+
+This checks the JVM OS/CPU against `target`, then loads RocksDB, GMP, libsodium, and
+secp256k1 when `ecc` is selected. It fails if a required library cannot load. For
+Linux arm64 or macOS amd64, provide these system libraries in the JVM library path.
+For example, set `JAVA_TOOL_OPTIONS=-Djava.library.path=/path/to/libs` for a standalone
+run. A cross build needs this smoke command run on the target OS/CPU; it cannot validate
+native loading on the build host.
## Feature and platform matrix
| Need | Feature selector | Platform selector |
| --- | --- | --- |
-| EVM transaction authority | `authority-evm` | `ecc` |
-| Bitcoin transaction authority | `authority-bitcoin` | `ecc` |
+| EVM transaction authority | `evm` or `digital-assets` | `ecc` |
+| Bitcoin transaction authority | `bitcoin` or `digital-assets` | `ecc` |
+| Tron transaction authority | `tron` or `digital-assets` | `ecc` |
+| XRP transaction authority | `xrp` or `digital-assets` | `ecc` |
+| Solana transaction authority | `solana` or `digital-assets` | `ecc` |
+| AP2 payment authority | `ap2` or `agentic-payments` | `ecc` |
+| MC VI payment authority | `mc-vi` or `agentic-payments` | `ecc` |
| X.509 certificate authority | `authority-x509` | `ecc` |
| ECIES | `ecies` | `ecc` |
| Peer share recovery | `recovery` (explicit opt-in) | `ecc`, `pqc`, or both |
@@ -61,6 +135,7 @@ Feature names match child project names. The module `:features:authority-evm` is
| Google Cloud KMS seal provider | `seal-gcloud` | any required crypto platform |
| Developer token authentication | `auth-dev` (explicit opt-in, excluded from `all`) | any required crypto platform |
| Authority policy dry run | `dry-run` (explicit opt-in, excluded from `all`) | any required crypto platform |
+| MCP discovery, utilities, signing, and composition | `mcp` (explicit opt-in, excluded from `all`) | any required crypto platform |
| ML-DSA identities | none | `pqc` |
| Default production set | `all` | `all` |
@@ -77,7 +152,7 @@ recovery module for each selected platform:
The first command includes `:features:recovery` and `:features:recovery:ecc`; the second includes
`:features:recovery` and `:features:recovery:pqc`; the third includes all three. The platform modules
-are not selected separately. Recovery, `auth-dev`, and `dry-run` are excluded from `keeper.features=all` and
+are not selected separately. Recovery, `auth-dev`, `dry-run`, and `mcp` are excluded from `keeper.features=all` and
must be requested explicitly.
Treat this as a maintenance artifact. After recovery, rebuild and redeploy the normal production
@@ -96,6 +171,15 @@ Build the dry-run endpoint explicitly in the same way:
./gradlew :build -Pkeeper.features=dry-run -Pkeeper.platforms=ecc
```
+Build the MCP endpoint into the same public Keeper server:
+
+```bash
+./gradlew :build -Pkeeper.features=mcp,digital-assets -Pkeeper.platforms=ecc
+```
+
+The endpoint is `POST /mcp` and uses the configured Keeper authentication provider. See
+[MCP connection and tools](../ai/mcp.md) for setup and request format.
+
## Selection properties
| Scope | Features | Platforms |
@@ -105,9 +189,12 @@ Build the dry-run endpoint explicitly in the same way:
| Select all | `keeper.features.all=true` | `keeper.platforms.all=true` |
Comma-separated selectors accept short names such as `ecies`, `ecc`, and `pqc`. `all` selects every
-default production module in that category. Explicit features such as `recovery`, `auth-dev`, and `dry-run` are
+default production module in that category. Explicit features such as `recovery`, `auth-dev`, `dry-run`, and `mcp` are
not included.
+`target` is independent of these selectors. Invalid target names fail the Gradle build
+instead of silently producing a jar with mismatched native libraries.
+
## Docker
Build the production Docker image:
@@ -116,6 +203,16 @@ Build the production Docker image:
./gradlew dockerBuild -Pkeeper.features=all -Pkeeper.platforms=all
```
+For a Linux amd64 or arm64 image, pass `-Ptarget=linux-amd64` or
+`-Ptarget=linux-arm64`. The Docker build uses the matching `--platform` and the
+matching production jar. `target=all` preserves the existing Docker build with the
+multi-target native jar. macOS and Windows targets cannot be used with `dockerBuild` because the
+Dockerfile produces Linux images.
+
+`dockerBuild` also loads the jar's native libraries inside the target image before
+finishing. For `linux-arm64` on an amd64 host, Docker must be able to execute arm64
+build steps (for example through an arm64 builder or emulation).
+
Build a recovery image with both platform implementations:
```bash
@@ -127,7 +224,7 @@ Build a recovery image with both platform implementations:
Production image tags:
```text
-exploit/tkeeper:2.4.1
+exploit/tkeeper:2.5.0
exploit/tkeeper:latest
```
@@ -146,7 +243,7 @@ docker run --rm \
-v "$PWD/config:/etc/tkeeper:ro" \
-v "$PWD/data:/var/lib/tkeeper" \
-e KEEPER_CONFIG_LOCATION=/etc/tkeeper \
- exploit/tkeeper:2.4.1
+ exploit/tkeeper:2.5.0
```
## Integration image
@@ -195,7 +292,7 @@ The platform was not included in the artifact.
Rebuild with the required platform, for example:
```bash
-./gradlew :build -Pkeeper.features=authority-evm -Pkeeper.platforms=ecc
+./gradlew :build -Pkeeper.features=evm -Pkeeper.platforms=ecc
```
### Native access warning
diff --git a/docs/deployment/configuration.md b/docs/deployment/configuration.md
index 7d3a379c..3b063178 100644
--- a/docs/deployment/configuration.md
+++ b/docs/deployment/configuration.md
@@ -43,7 +43,7 @@ Example:
java \
-Dkeeper.config.location=/etc/tkeeper \
-Dkeeper.dev.config.location=/etc/tkeeper \
- -jar build/libs/tkeeper-2.4.1.jar
+ -jar build/libs/tkeeper-2.5.0.jar
```
Minimal node config:
diff --git a/docs/deployment/control-plane-ui.md b/docs/deployment/control-plane-ui.md
index 7e39e850..b6cb540f 100644
--- a/docs/deployment/control-plane-ui.md
+++ b/docs/deployment/control-plane-ui.md
@@ -19,7 +19,7 @@ Disable the UI at startup:
```bash
java \
-Dkeeper.ui.enabled=false \
- -jar build/libs/tkeeper-2.4.1.jar
+ -jar build/libs/tkeeper-2.5.0.jar
```
## Authentication
diff --git a/docs/digital-assets/README.md b/docs/digital-assets/README.md
new file mode 100644
index 00000000..fd38ea30
--- /dev/null
+++ b/docs/digital-assets/README.md
@@ -0,0 +1,13 @@
+# Digital Asset Authorities
+
+Build all chains with `-Pkeeper.features=digital-assets -Pkeeper.platforms=ecc`, or select one chain by name. Each authority parses an unsigned transaction, evaluates its effects, and signs only after policy and approval checks pass.
+
+| Chain | Authority | Key | Result from `/compose` | Guide |
+| --- | --- | --- | --- | --- |
+| Bitcoin | `bitcoin.transaction` | secp256k1 | Raw signature | [Bitcoin](bitcoin.md) |
+| EVM | `evm.transaction` | secp256k1 | Signed hex and hash | [EVM](evm.md) |
+| Tron | `tron.transaction` | secp256k1 | Signed JSON and hash | [Tron](tron.md) |
+| XRP | `xrp.transaction` | secp256k1 or Ed25519 | Signed hex and hash | [XRP](xrp.md) |
+| Solana | `solana.transaction` | Ed25519 | Signed Base64, completeness, and optional hash | [Solana](solana.md) |
+
+The caller constructs the unsigned transaction and handles fees, submission, and confirmation. For Bitcoin, Tron, XRP, or Solana signing with a `tweak`, construct the transaction from `central().getPublicKey(keyId, tweak)`; TKeeper checks that derived key against the transaction's owner or required signer. See [Composer](../signing-and-authorities/composer.md) for result behavior.
diff --git a/docs/signing-and-authorities/bitcoin.md b/docs/digital-assets/bitcoin.md
similarity index 76%
rename from docs/signing-and-authorities/bitcoin.md
rename to docs/digital-assets/bitcoin.md
index 50e4d647..4e18149e 100644
--- a/docs/signing-and-authorities/bitcoin.md
+++ b/docs/digital-assets/bitcoin.md
@@ -1,15 +1,23 @@
# Bitcoin Authorities
-Bitcoin authority support lives in the `authority-bitcoin` feature and currently requires the `ecc` platform.
+Bitcoin authority support lives in the `digital-assets:bitcoin` module and currently requires the `ecc` platform.
Build example:
```bash
-./gradlew shadowJar -Pkeeper.features=authority-bitcoin -Pkeeper.platforms=ecc
+./gradlew shadowJar -Pkeeper.features=bitcoin -Pkeeper.platforms=ecc
```
A Bitcoin authority lets TKeeper parse unsigned transaction data, previous transactions, signing input, sighash settings, and policy effects before signing.
+```java
+var input = new UtxoInput(unsignedTransaction64, previousTransactions64, inputIndex);
+var command = Command.of("btc-cold-storage-sweep", input);
+var signature = client.signature().sign(Sign.of(bitcoinKeyId, command));
+```
+
+`unsignedTransaction64` and each previous transaction are Base64-encoded transaction bytes. The wallet attaches the returned signature to the selected input. Bitcoin has no registered composer; `/v2/keeper/compose` returns the raw `ThresholdSignature`.
+
Use Bitcoin authorities for:
- governed UTXO spending
@@ -28,10 +36,10 @@ TKeeper does not select coins, construct change, choose fees, broadcast transact
```yaml
type: bitcoin.transaction
config:
- protocol: BTC
+ network: MAINNET
```
-Known protocols are `BTC`, `LTC`, `DASH`, and `BCH`. Custom protocols pin their asset symbol, address versions, Bech32 prefix, and decimals in the authority config.
+Verdict 0.2 supports Bitcoin networks `MAINNET`, `TESTNET`, `REGTEST`, and `SIGNET`. TKeeper signs the selected artifact input with `SIGHASH_ALL`.
Effects:
@@ -42,7 +50,7 @@ Effects:
Strict CEL roots:
-- `protocol`, `asset`, `assetDecimals`
+- `protocol`, `network`, `asset`, `assetDecimals`
- `txId`, `wtxId`, `version`, `lockTime`
- `sighash`, `signing`
- `inputs`, `outputs`, `previousTransactions`
@@ -64,7 +72,7 @@ metadata:
title: BTC sweep to cold storage
config:
- protocol: BTC
+ network: MAINNET
policy:
id: btc-cold-storage-sweep
@@ -108,4 +116,4 @@ policy:
Replace the address, limits, and `publicKey64` with trusted production values. A payout, consolidation with change, or sweep to another vault should use another authority id and document.
-See [Authorities](authorities.md) for the document and policy schema and [CEL Functions](cel-functions.md) for policy helpers.
+See [Authorities](../signing-and-authorities/authorities.md) for the document and policy schema, [CEL Functions](../signing-and-authorities/cel-functions.md) for policy helpers, and [Composer](../signing-and-authorities/composer.md) for response behavior.
diff --git a/docs/signing-and-authorities/evm.md b/docs/digital-assets/evm.md
similarity index 77%
rename from docs/signing-and-authorities/evm.md
rename to docs/digital-assets/evm.md
index 27c9c34d..0557bd32 100644
--- a/docs/signing-and-authorities/evm.md
+++ b/docs/digital-assets/evm.md
@@ -1,11 +1,11 @@
# EVM Authorities
-EVM authority support lives in the `authority-evm` feature and currently requires the `ecc` platform.
+EVM authority support lives in the `digital-assets:evm` module and currently requires the `ecc` platform.
Build example:
```bash
-./gradlew shadowJar -Pkeeper.features=authority-evm -Pkeeper.platforms=ecc
+./gradlew shadowJar -Pkeeper.features=evm -Pkeeper.platforms=ecc
```
An EVM authority lets TKeeper parse an unsigned serialized transaction, decode configured contract calls, expose normalized effects to policy, and sign after the resulting allow decision and any approval requirements are satisfied.
@@ -25,6 +25,18 @@ TKeeper signs the approved transaction. The surrounding wallet or custody servic
External risk verdicts must be bound to the same transaction intent; a verdict for an address or amount outside the signed transaction is only advisory metadata.
+## Compose a signed transaction
+
+Pass the serialized unsigned transaction bytes as Base64 in `UnsignedEvmTransaction.message64` to `/v2/keeper/compose`. The EVM composer applies the Keeper signature to that transaction and returns `SignedEvmTransaction` with `rawTransaction` (hex for `eth_sendRawTransaction`), `transactionHash`, `generation`, and `imposters`. The regular sign endpoint still returns the signature alone.
+
+```java
+var command = Command.of(authorityId, new UnsignedEvmTransaction(message64));
+var signed = client.signature().compose(Sign.of(keyId, command), SignedEvmTransaction.class);
+var rawTransaction = signed.rawTransaction();
+```
+
+The result covers legacy and typed transactions 1–4. For type 3 blob transactions, `rawTransaction` contains the signed transaction envelope; broadcasting also requires the blob sidecar.
+
## Authority config
```yaml
@@ -127,4 +139,4 @@ policy:
Replace the token contract, recipient, limits, and `publicKey64` with trusted production values. A transfer to another wallet should use another authority id and document.
-See [Authorities](authorities.md) for the document and policy schema and [CEL Functions](cel-functions.md) for policy helpers.
+See [Authorities](../signing-and-authorities/authorities.md) for the document and policy schema, [CEL Functions](../signing-and-authorities/cel-functions.md) for policy helpers, and [Composer](../signing-and-authorities/composer.md) for response behavior.
diff --git a/docs/digital-assets/solana.md b/docs/digital-assets/solana.md
new file mode 100644
index 00000000..b3a59fe9
--- /dev/null
+++ b/docs/digital-assets/solana.md
@@ -0,0 +1,43 @@
+# Solana Authorities
+
+`solana.transaction` accepts an unsigned transaction in Base64 and requires an Ed25519 key. TKeeper checks that the signing key is a required signer of the message. Build with `-Pkeeper.features=solana -Pkeeper.platforms=ecc`.
+
+## Example: one native transfer
+
+This [authority](../../integration-tests/src/testFixtures/resources/authorities/solana/native-transfer.yaml) permits one required signer and a transfer of at most 100 [lamports](https://solana.com/docs/references/terminology) to one recipient:
+
+```yaml
+schemaVersion: verdict.authority/v1
+id: test:solana/native-transfer
+type: solana.transaction
+version: 1.0.0
+config: {}
+policy:
+ id: solana-native-transfer
+ fallback: DENY
+ variables:
+ recipient: "8qbHbw2BbbTHBW1sbeqakYXVKRQM8Ne7pLK7m6CVfeR"
+ allow:
+ - id: small-transfer
+ where:
+ - "requiredSignatures == 1"
+ - "effect.onlyTypes(effects, ['native.transfer'])"
+ - "effect.one(effects, 'native.transfer')"
+ - "effect.all(effects, 'native.transfer', {'asset': 'sol', 'to': recipient})"
+ - "bigint.gt(effect.amount(effects, 'native.transfer'), '0')"
+ - "bigint.lte(effect.amount(effects, 'native.transfer'), '100')"
+```
+
+Build the message with the TKeeper key as a required signer, then compose:
+
+```java
+var command = Command.of("test:solana/native-transfer",
+ new UnsignedSolanaTransaction(unsignedSolana64));
+var signed = client.signature().compose(
+ Sign.of(solanaKeyId, command), SignedSolanaTransaction.class);
+if (!signed.complete()) throw new IllegalStateException("Missing signatures");
+String transaction64 = signed.rawTransaction();
+String transactionHash = signed.transactionHash();
+```
+
+For policies that allow multiple signers, the composer can return a partial transaction with `complete: false` and no hash. With a `tweak`, build the message using `central().getPublicKey(solanaKeyId, tweak)`. The caller submits the complete transaction. [Composer](../signing-and-authorities/composer.md) describes the common response contract.
diff --git a/docs/digital-assets/tron.md b/docs/digital-assets/tron.md
new file mode 100644
index 00000000..2a37b66b
--- /dev/null
+++ b/docs/digital-assets/tron.md
@@ -0,0 +1,42 @@
+# Tron Authorities
+
+`tron.transaction` accepts unsigned transaction JSON. It requires a secp256k1 key and checks that the transaction owner matches the signing key. Build with `-Pkeeper.features=tron -Pkeeper.platforms=ecc`.
+
+## Example: one recipient, at most 100 TRX
+
+Tron amounts and `feeLimit` use [sun](https://developers.tron.network/docs/token-standards-trx); 100 TRX is 100,000,000 sun. This [authority](../../integration-tests/src/testFixtures/resources/authorities/tron/native-transfer.yaml) limits the recipient, amount, fee limit, and effect type:
+
+```yaml
+schemaVersion: verdict.authority/v1
+id: test:tron/native-transfer
+type: tron.transaction
+version: 1.0.0
+config: {}
+policy:
+ id: tron-native-transfer
+ fallback: DENY
+ variables:
+ recipient: "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"
+ allow:
+ - id: small-transfer
+ where:
+ - "feeLimit <= 30000000"
+ - "effect.onlyTypes(effects, ['native.transfer'])"
+ - "effect.one(effects, 'native.transfer')"
+ - "effect.all(effects, 'native.transfer', {'asset': 'trx', 'to': recipient})"
+ - "bigint.gt(effect.amount(effects, 'native.transfer'), '0')"
+ - "bigint.lte(effect.amount(effects, 'native.transfer'), '100000000')"
+```
+
+Build the unsigned JSON with the key's owner address, then compose:
+
+```java
+var command = Command.of("test:tron/native-transfer",
+ new UnsignedTronTransaction(unsignedTronJson));
+var signed = client.signature().compose(
+ Sign.of(tronKeyId, command), SignedTronTransaction.class);
+String transactionJson = signed.rawTransaction();
+String transactionHash = signed.transactionHash();
+```
+
+The composer keeps the approved transaction body and adds the signature. The broadcaster submits `transactionJson`. With a `tweak`, derive the owner address from `central().getPublicKey(tronKeyId, tweak)`. [Composer](../signing-and-authorities/composer.md) describes the common response contract.
diff --git a/docs/digital-assets/xrp.md b/docs/digital-assets/xrp.md
new file mode 100644
index 00000000..3d41e89c
--- /dev/null
+++ b/docs/digital-assets/xrp.md
@@ -0,0 +1,44 @@
+# XRP Authorities
+
+`xrp.transaction` accepts an unsigned transaction in hex. It supports secp256k1 and Ed25519 keys. TKeeper checks both `Account` and `SigningPubKey` against the signing key. Build with `-Pkeeper.features=xrp -Pkeeper.platforms=ecc`.
+
+## Example: bounded native payment
+
+This [authority](../../integration-tests/src/testFixtures/resources/authorities/xrp/native-payment.yaml) permits one recipient, at most 100 [drops](https://xrpl.org/docs/introduction/transactions-and-requests), a fee of at most 12 drops, and a ledger expiry:
+
+```yaml
+schemaVersion: verdict.authority/v1
+id: test:xrp/native-payment
+type: xrp.transaction
+version: 1.0.0
+config: {}
+policy:
+ id: xrp-native-payment
+ fallback: DENY
+ variables:
+ recipient: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"
+ allow:
+ - id: small-payment
+ where:
+ - "type == 'payment'"
+ - "lastLedgerSequence != null"
+ - "bigint.lte(fee, '12')"
+ - "effect.onlyTypes(effects, ['native.transfer'])"
+ - "effect.one(effects, 'native.transfer')"
+ - "effect.all(effects, 'native.transfer', {'asset': 'xrp', 'to': recipient, 'destinationTag': 0})"
+ - "bigint.gt(effect.amount(effects, 'native.transfer'), '0')"
+ - "bigint.lte(effect.amount(effects, 'native.transfer'), '100')"
+```
+
+Set `Account` and `SigningPubKey` from the TKeeper public key before serializing the unsigned transaction:
+
+```java
+var command = Command.of("test:xrp/native-payment",
+ new UnsignedXrpTransaction(unsignedXrpHex));
+var signed = client.signature().compose(
+ Sign.of(xrpKeyId, command), SignedXrpTransaction.class);
+String transactionHex = signed.rawTransaction();
+String transactionHash = signed.transactionHash();
+```
+
+With a `tweak`, derive those fields from `central().getPublicKey(xrpKeyId, tweak)`. The caller submits the signed hex and tracks its ledger result. [Composer](../signing-and-authorities/composer.md) describes the common response contract.
diff --git a/docs/getting-started/local-single-node.md b/docs/getting-started/local-single-node.md
index a66f2a97..d3f0d478 100644
--- a/docs/getting-started/local-single-node.md
+++ b/docs/getting-started/local-single-node.md
@@ -37,7 +37,7 @@ Build the jar with the explicit developer-auth feature:
The jar is:
```text
-build/libs/tkeeper-2.4.1.jar
+build/libs/tkeeper-2.5.0.jar
```
## Create local config
@@ -104,7 +104,7 @@ java \
-Dkeeper.dev.enabled=true \
-Dkeeper.dev.config.location=/tmp/tkeeper \
-Dkeeper.coordinator.enabled=true \
- -jar build/libs/tkeeper-2.4.1.jar
+ -jar build/libs/tkeeper-2.5.0.jar
```
## Initialize the node
diff --git a/docs/operations/error-tracking.md b/docs/operations/error-tracking.md
index 0a4459e5..944db9b0 100644
--- a/docs/operations/error-tracking.md
+++ b/docs/operations/error-tracking.md
@@ -9,7 +9,7 @@ sentry {
enabled = true
dsn = "https://public@example.sentry.io/1"
environment = "prod"
- release = "2.4.1"
+ release = "2.5.0"
}
```
diff --git a/docs/overview/architecture.md b/docs/overview/architecture.md
index 1e840248..b1c2c78a 100644
--- a/docs/overview/architecture.md
+++ b/docs/overview/architecture.md
@@ -39,8 +39,13 @@ Examples:
| Feature | Adds |
| --- | --- |
-| `authority-evm` | EVM transaction intent support |
-| `authority-bitcoin` | Bitcoin transaction intent support |
+| `digital-assets:evm` | EVM transaction intent support |
+| `digital-assets:bitcoin` | Bitcoin transaction intent support |
+| `digital-assets:tron` | Tron transaction intent and composition |
+| `digital-assets:xrp` | XRP transaction intent and composition |
+| `digital-assets:solana` | Solana transaction intent and composition |
+| `agentic-payments` | AP2 and MC VI mandate signing and composition |
+| `mcp` | MCP discovery, utility, signing, and composition tools |
| `authority-x509` | Certificate issuance intent support |
| `ecies` | ECIES encryption and threshold decryption |
| `ui` | Control-plane UI |
@@ -58,7 +63,7 @@ Platforms add algorithm implementations. They are selected separately from featu
| `ecc` | `SECP256K1`, `P256`, `ED25519`, ECC signing protocols, ECC key derivation, ECIES support |
| `pqc` | `MLDSA44`, `MLDSA65`, `MLDSA87`, ML-DSA DKG, ML-DSA signing |
-Features that depend on a platform require that platform explicitly. For example, EVM, Bitcoin, X.509, and ECIES currently require `ecc`.
+Features that depend on a platform require that platform explicitly. Digital assets, agentic payments, X.509, and ECIES require `ecc`.
## Mono and threshold
diff --git a/docs/overview/glossary.md b/docs/overview/glossary.md
index f735f0fa..05294ac7 100644
--- a/docs/overview/glossary.md
+++ b/docs/overview/glossary.md
@@ -30,7 +30,7 @@ A normalized consequence derived from an intent and exposed to policy, such as a
A build-time module that adds product functionality, endpoints, authority types, UI, or providers.
-Examples: `authority-evm`, `authority-bitcoin`, `authority-x509`, `ecies`, `ui`, `seal-aws`, `seal-gcloud`.
+Examples: `digital-assets`, `agentic-payments`, `authority-x509`, `ecies`, `ui`, `seal-aws`, `seal-gcloud`.
## Four-eye control
diff --git a/docs/overview/use-cases.md b/docs/overview/use-cases.md
index 09306f99..466e412a 100644
--- a/docs/overview/use-cases.md
+++ b/docs/overview/use-cases.md
@@ -4,17 +4,17 @@ TKeeper fits workflows where a cryptographic identity is the final authority for
| Scenario | Governed intent | Verifier | TKeeper does not replace |
| --- | --- | --- | --- |
-| AI agent | typed tool call, payment, or production action | tool backend or workflow engine | model security, sandboxing, or risk detection |
-| Crypto assets | exact EVM or Bitcoin transaction | chain client, broadcaster, or custody backend | transaction construction, broadcast, or settlement monitoring |
-| Certificates | DER-encoded TBS certificate | relying party or CA pipeline | enrollment, serial allocation, revocation, or certificate publication |
-| Internal systems | typed privileged command | service that performs the command | business logic or host authorization |
+| AI | MCP tool call, AP2 or MC VI payment, production action | tool backend or credential verifier | model security, sandboxing, or risk detection |
+| Digital assets | Bitcoin, EVM, Tron, XRP, or Solana transaction | chain client, broadcaster, or custody backend | transaction construction, broadcast, or settlement monitoring |
+| PKI | DER-encoded TBS certificate | relying party or CA pipeline | enrollment, serial allocation, revocation, or certificate publication |
+| Other activities | typed privileged command | service that performs the command | business logic or host authorization |
Detailed integration guidance:
-- [For AI Agents](../use-cases/for-ai-agents.md)
-- [For Crypto Assets](../use-cases/for-crypto-assets.md)
-- [For Certificates](../use-cases/for-certificates.md)
-- [For Internal Systems](../use-cases/for-internal-systems.md)
+- [For AI](../use-cases/for-ai.md)
+- [For Digital Assets](../use-cases/for-digital-assets.md)
+- [For PKI](../use-cases/for-pki.md)
+- [For Other Activities](../use-cases/for-other-activities.md)
## Fit test
diff --git a/docs/pki/README.md b/docs/pki/README.md
new file mode 100644
index 00000000..e04d1cca
--- /dev/null
+++ b/docs/pki/README.md
@@ -0,0 +1,3 @@
+# PKI Authorities
+
+Use [X.509 authorities](x509.md) to govern certificate fields and signing. [For PKI](../use-cases/for-pki.md) shows the issuance flow; [Signing and Authorities](../signing-and-authorities/README.md) explains the shared policy and approval checks.
diff --git a/docs/signing-and-authorities/x509.md b/docs/pki/x509.md
similarity index 95%
rename from docs/signing-and-authorities/x509.md
rename to docs/pki/x509.md
index 51908e0d..756760ec 100644
--- a/docs/signing-and-authorities/x509.md
+++ b/docs/pki/x509.md
@@ -91,4 +91,4 @@ policy:
Replace the issuer, DNS name, and `publicKey64` with trusted production values. Another DNS identity or certificate profile should use another authority id and document. The rule governs the TBS certificate fields and approval requirement. The CA pipeline must still authenticate the requester, authorize control of the requested name, validate the submitted public key, and verify proof of possession before calling TKeeper.
-See [Authorities](authorities.md) for the document and policy schema and [CEL Functions](cel-functions.md) for policy helpers.
+See [Authorities](../signing-and-authorities/authorities.md) for the document and policy schema and [CEL Functions](../signing-and-authorities/cel-functions.md) for policy helpers.
diff --git a/docs/signing-and-authorities/README.md b/docs/signing-and-authorities/README.md
index 128d8736..d72812b6 100644
--- a/docs/signing-and-authorities/README.md
+++ b/docs/signing-and-authorities/README.md
@@ -1,24 +1,23 @@
# Signing and Authorities
-Signing is an identity action. TKeeper produces a signature only after the requested action is understood through an authority and allowed by policy.
+TKeeper signs a command after its authority turns it into an intent and policy allows it.
-Read:
+## Sign and compose
-- [Signing](signing.md)
-- [Authorities](authorities.md)
-- [Dry Run Policy Evaluation](dry-run.md)
-- [CEL Functions](cel-functions.md)
-- [Arbitrary and Typed Authorities](arbitrary-and-typed.md)
-- [EVM Authorities](evm.md)
-- [Bitcoin Authorities](bitcoin.md)
-- [X.509 Authorities](x509.md)
+- [Signing](signing.md): command requests and verification
+- [Composer](composer.md): protocol results from signed commands
-## Core rule
+## Define and check policy
-```text
-command -> authority -> intent -> policy -> proof
-```
+- [Authorities](authorities.md): attach and govern an identity's capabilities
+- [Arbitrary and Typed Authorities](arbitrary-and-typed.md): raw and JSON commands
+- [CEL Functions](cel-functions.md): policy expressions
+- [Dry Run Policy Evaluation](dry-run.md): preview a decision
-Use `arbitrary` only when raw signing is intentional. Use concrete authorities when TKeeper must understand and govern the action.
+## Protocol guides
-The consumer remains part of the security boundary: it must trust the expected identity, verify the exact intent, and prevent replay where the action requires freshness.
+- [AI and Agentic Payments](../ai/README.md): MCP, AP2, MC Intent
+- [Digital Asset Authorities](../digital-assets/README.md): Bitcoin, EVM, Tron, XRP, Solana
+- [PKI Authorities](../pki/README.md): X.509 certificates
+
+The consumer must trust the expected identity, verify the signed command, and prevent replay where the action requires freshness.
diff --git a/docs/signing-and-authorities/arbitrary-and-typed.md b/docs/signing-and-authorities/arbitrary-and-typed.md
index 18689f4f..30118395 100644
--- a/docs/signing-and-authorities/arbitrary-and-typed.md
+++ b/docs/signing-and-authorities/arbitrary-and-typed.md
@@ -60,7 +60,7 @@ For example, this submitted object:
{
"sequence": 7,
"deployment": {
- "version": "2.4.1",
+ "version": "2.5.0",
"environment": "production"
},
"roles": [
@@ -73,7 +73,7 @@ For example, this submitted object:
is serialized for signing as:
```json
-{"deployment":{"environment":"production","version":"2.4.1"},"roles":[{"name":"operator","priority":1},{"name":"auditor","priority":2}],"sequence":7}
+{"deployment":{"environment":"production","version":"2.5.0"},"roles":[{"name":"operator","priority":1},{"name":"auditor","priority":2}],"sequence":7}
```
With `hash: SHA256`, the expected signing material is:
@@ -180,6 +180,8 @@ The declared fields and `effects` become strict CEL roots. See [Authorities](aut
| Govern a typed business action | `custom` |
| Govern an EVM transaction | `evm.transaction` |
| Govern a Bitcoin transaction | `bitcoin.transaction` |
+| Govern a Tron, XRP, or Solana transaction | `tron.transaction`, `xrp.transaction`, or `solana.transaction` |
+| Govern an AP2 or MC VI mandate | `ap2.mandate` or `mcintent.mandate` |
| Govern certificate issuance | `x509.tbs-certificate` |
`arbitrary` cannot be mixed with concrete authorities on the same key identity.
diff --git a/docs/signing-and-authorities/authorities.md b/docs/signing-and-authorities/authorities.md
index 235f2c51..aa5195c7 100644
--- a/docs/signing-and-authorities/authorities.md
+++ b/docs/signing-and-authorities/authorities.md
@@ -115,7 +115,7 @@ policy:
allowedEnvironments: [production]
requiredRoles: [release-manager, production]
allowedCidrs: ["10.20.0.0/16", "fd00:20::/48"]
- minimumReleaseVersion: "2.4.1"
+ minimumReleaseVersion: "2.5.0"
maximumRiskScore: "0.25"
minimumSequence: "10000000000000000000"
maximumWindowSeconds: 300
@@ -183,7 +183,7 @@ For a concrete authority, the sign command must reference an authority attached
"action": "deploy",
"service": "billing-api",
"environment": "production",
- "releaseVersion": "2.4.1",
+ "releaseVersion": "2.5.0",
"sequence": 10000000000000000001,
"riskScore": 0.20,
"roles": ["release-manager", "production"],
@@ -214,9 +214,14 @@ Authority `type` selects the payload format and policy context.
| Authority type | Build feature | Command data | Main policy surface |
| --- | --- | --- | --- |
| [`custom`](arbitrary-and-typed.md) | core | typed JSON | declared fields and configured `effects` |
-| [`evm.transaction`](evm.md) | `authority-evm` | unsigned serialized EVM transaction | transaction fields, decoded call, `effects` |
-| [`bitcoin.transaction`](bitcoin.md) | `authority-bitcoin` | unsigned tx, previous txs, signing input, sighash | inputs, outputs, fee, sighash, `effects` |
-| [`x509.tbs-certificate`](x509.md) | `authority-x509` | DER-encoded TBS certificate | subject, issuer, validity, extensions |
+| [`evm.transaction`](../digital-assets/evm.md) | `evm` | unsigned serialized EVM transaction | transaction fields, decoded call, `effects` |
+| [`bitcoin.transaction`](../digital-assets/bitcoin.md) | `bitcoin` | unsigned tx, previous txs, signing input, sighash | inputs, outputs, fee, sighash, `effects` |
+| [`tron.transaction`](../digital-assets/tron.md) | `tron` | unsigned transaction JSON | contracts, fee limit, `effects` |
+| [`xrp.transaction`](../digital-assets/xrp.md) | `xrp` | unsigned transaction hex | payment, fee, ledger limit, `effects` |
+| [`solana.transaction`](../digital-assets/solana.md) | `solana` | unsigned transaction Base64 | instructions, signers, `effects` |
+| [`ap2.mandate`](../ai/agentic-payments/ap2.md) | `ap2` | JWS signing input and SD-JWT disclosures | payment, checkout, request total |
+| [`mcintent.mandate`](../ai/agentic-payments/mcintent.md) | `mc-vi` | JWS signing input and SD-JWT disclosures | payment, checkout, request total |
+| [`x509.tbs-certificate`](../pki/x509.md) | `authority-x509` | DER-encoded TBS certificate | subject, issuer, validity, extensions |
| `arbitrary` | core | raw bytes | no Verdict policy |
If a feature module is missing, TKeeper cannot process that command type and returns `INVALID_AUTHORITY_ARTIFACT`.
@@ -224,7 +229,7 @@ If a feature module is missing, TKeeper cannot process that command type and ret
Build example:
```bash
-./gradlew shadowJar -Pkeeper.features=authority-evm,authority-bitcoin,authority-x509 -Pkeeper.platforms=ecc
+./gradlew shadowJar -Pkeeper.features=agentic-payments,digital-assets,authority-x509 -Pkeeper.platforms=ecc
```
## Effects
@@ -241,7 +246,7 @@ Example effect:
"action": "deploy",
"service": "billing-api",
"environment": "production",
- "version": "2.4.1",
+ "version": "2.5.0",
"sequence": 10000000000000000001
}
```
diff --git a/docs/signing-and-authorities/composer.md b/docs/signing-and-authorities/composer.md
new file mode 100644
index 00000000..76b0e868
--- /dev/null
+++ b/docs/signing-and-authorities/composer.md
@@ -0,0 +1,30 @@
+# Composer
+
+`POST /v2/keeper/compose` accepts the same `Sign` request as `/v2/keeper/sign` and requires `tkeeper.key.{keyId}.sign`. TKeeper checks the key's authority, policy, approvals, and signing controls, then passes the signature to the composer registered for the command type.
+
+| Command type | Compose result |
+| --- | --- |
+| `evm.transaction` | `SignedEvmTransaction`: signed hex and transaction hash |
+| `tron.transaction` | `SignedTronTransaction`: signed JSON and transaction hash |
+| `xrp.transaction` | `SignedXrpTransaction`: signed hex and transaction hash |
+| `solana.transaction` | `SignedSolanaTransaction`: signed Base64, `complete`, and hash when complete |
+| `ap2.mandate`, `mcintent.mandate` | `PaymentCredential`: signed credential layer |
+| Other types, including `bitcoin.transaction` | `ThresholdSignature`: raw signature, as with `/sign` |
+
+The result also carries `generation` and `imposters`. Composition does not broadcast a transaction or submit a credential to a merchant.
+
+## SDK example
+
+Build an unsigned EVM transaction first. Its serialized bytes go in `message64` as Base64:
+
+```java
+var command = Command.of(authorityId, new UnsignedEvmTransaction(message64));
+var result = client.signature().compose(
+ Sign.of(keyId, command), SignedEvmTransaction.class);
+String rawTransaction = result.rawTransaction();
+String transactionHash = result.transactionHash();
+```
+
+Use `signature().compose(request)` for an untyped JSON result, or pass the expected result class. `signature().sign(request)` still returns only the raw signature.
+
+See the [digital asset authorities](../digital-assets/README.md) and [agentic payments](../ai/agentic-payments/README.md) for input formats, policies, and result limits.
diff --git a/docs/signing-and-authorities/signing.md b/docs/signing-and-authorities/signing.md
index b8819e01..d6c49472 100644
--- a/docs/signing-and-authorities/signing.md
+++ b/docs/signing-and-authorities/signing.md
@@ -7,6 +7,7 @@ Signing is where a key identity produces proof. TKeeper signs only after the com
```http
POST /v2/keeper/sign
POST /v2/keeper/sign/verify
+POST /v2/keeper/compose
```
Required permissions:
@@ -84,7 +85,7 @@ Typed command:
"action": "deploy",
"service": "billing-api",
"environment": "production",
- "releaseVersion": "2.4.1",
+ "releaseVersion": "2.5.0",
"sequence": 10000000000000000001,
"riskScore": 0.20,
"roles": ["release-manager", "production"],
@@ -98,7 +99,7 @@ Typed command:
}
```
-See the [typed authority example](authorities.md#custom-authority-example) for the matching schema, payload, effects, and policy. EVM, Bitcoin, and X.509 commands are documented on their intent-specific pages.
+See the [typed authority example](authorities.md#custom-authority-example) for the matching schema, payload, effects, and policy. [Digital assets](../digital-assets/README.md), [agentic payments](../ai/agentic-payments/README.md), and [X.509](../pki/x509.md) have their own authority guides.
The exact typed JSON byte encoding and hashing contract is documented in
[Typed JSON signing material](arbitrary-and-typed.md#typed-json-signing-material).
@@ -137,6 +138,8 @@ The command artifact decides the scheme and hash. The top-level sign request doe
`imposters` is meaningful for threshold protocols. Mono signatures return an empty list.
+Use [Composer](composer.md) to turn supported signatures into signed transactions or payment credentials. Other command types return the raw signature result.
+
Verify response:
```json
diff --git a/docs/use-cases/README.md b/docs/use-cases/README.md
index 27cb4b72..ada276e0 100644
--- a/docs/use-cases/README.md
+++ b/docs/use-cases/README.md
@@ -1,23 +1,16 @@
# Use Cases
-Every supported use case has the same enforcement shape:
+Every supported action follows the same path:
```text
identity -> understood action -> policy -> proof -> verified execution
```
-Read:
+| Use case | Workflow | Authority guide |
+| --- | --- | --- |
+| AI | [For AI](for-ai.md) | [MCP and agentic payments](../ai/README.md) |
+| Digital assets | [For Digital Assets](for-digital-assets.md) | [Chain authorities](../digital-assets/README.md) |
+| PKI | [For PKI](for-pki.md) | [X.509](../pki/README.md) |
+| Other activities | [For Other Activities](for-other-activities.md) | [Typed authorities](../signing-and-authorities/arbitrary-and-typed.md) |
-- [For AI Agents](for-ai-agents.md)
-- [For Crypto Assets](for-crypto-assets.md)
-- [For Certificates](for-certificates.md)
-- [For Internal Systems](for-internal-systems.md)
-
-The integration is a good fit when:
-
-- the action has real consequences
-- the action can be represented as an intent
-- the downstream system can verify proof before execution
-- bypassing the governed identity is not allowed
-
-If the action can happen without the proof, TKeeper is not enforcing that path. If the goal is only secret storage, use a secrets manager.
+The downstream system verifies the proof before acting. If an action can bypass that check, Keeper does not govern it.
diff --git a/docs/use-cases/for-ai-agents.md b/docs/use-cases/for-ai-agents.md
deleted file mode 100644
index e0634c66..00000000
--- a/docs/use-cases/for-ai-agents.md
+++ /dev/null
@@ -1,63 +0,0 @@
-# For AI Agents
-
-Use TKeeper when an AI agent or autonomous workflow can request actions with real consequences and the executing backend can require cryptographic authorization.
-
-The agent should not receive a raw signing key and should not get arbitrary signing for whatever JSON it emits. The expected tool or action shape should be declared as an authority document attached to the key identity.
-
-## Model
-
-```text
-agent proposes typed action
--> TKeeper materializes intent
--> policy and external verdicts approve or deny
--> TKeeper signs exact approved intent
--> backend verifies proof before executing
-```
-
-Good candidates:
-
-- tool calls that change production state
-- spending or payment actions
-- sensitive internal operations
-- signed agent decisions
-- actions requiring human approval
-
-## What TKeeper enforces
-
-TKeeper does not decide whether every AI output is safe. External systems can detect prompt injection, classify risk, or require human approval.
-
-TKeeper enforces the final boundary: the agent identity cannot produce proof unless the typed action is accepted.
-
-## Authority shape
-
-Use `custom` typed authorities for agent-specific actions. The authority document should define:
-
-- accepted fields
-- effect extraction
-- policy variables
-- allow and deny rules
-
-Avoid `arbitrary` for agent tool calls unless raw signing is explicitly accepted by the security model.
-
-Treat the authority document as the agent identity's capability manifest. A change to the tool schema, effect mapping, or policy is a security change and should produce a new digest-pinned artifact.
-
-## Integration contract
-
-The tool backend should:
-
-- accept only the expected agent identity or public key
-- reconstruct the same typed action that TKeeper approved
-- reject action fields that were not covered by the authority schema
-- bind the action to its target environment and resource
-- enforce expiry, nonce, or idempotency rules for repeatable tool calls
-- execute only after proof verification succeeds
-
-A signature does not prove that the model was safe, that its reasoning was correct, or that prompt injection did not occur. It proves authorization by the governed identity for the signed action.
-
-## Bypass condition
-
-```text
-agent can perform the same action directly without the governed identity
-```
-
-In that topology, TKeeper can record or advise but cannot prevent the direct action.
diff --git a/docs/use-cases/for-ai.md b/docs/use-cases/for-ai.md
new file mode 100644
index 00000000..7f8deb66
--- /dev/null
+++ b/docs/use-cases/for-ai.md
@@ -0,0 +1,86 @@
+# For AI
+
+TKeeper lets an agent host discover permitted identities and request signatures through MCP. An authority document limits what each identity can sign. The system that performs the action verifies the signature before acting.
+
+## MCP
+
+Build the `mcp` feature with the authority modules the agent needs:
+
+```sh
+./gradlew shadowJar -Pkeeper.features=mcp,agentic-payments,digital-assets -Pkeeper.platforms=ecc
+```
+
+`POST /mcp` serves [MCP `2026-07-28`](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2026-07-28/basic/transports/streamable-http.mdx) over Streamable HTTP. It uses the public Keeper server's authentication. The agent host supplies its Keeper token; the model does not need the token. The endpoint supports `server/discover`, `tools/list`, and `tools/call`.
+
+| Tool | Permission | Use |
+| --- | --- | --- |
+| `identity.list`, `identity.describe_authority` | `tkeeper.key..sign` | Find active signing identities and their command schemas |
+| `utility.keeper_status` | `tkeeper.system.status` | Check readiness |
+| `utility.get_public_key` | `tkeeper.key..public` | Read a public key |
+| `utility.verify_signature` | `tkeeper.key..verify` | Verify a command signature |
+| `action.sign`, `action.compose` | `tkeeper.key..sign` | Sign; compose a credential or transaction when supported |
+
+The tool list reflects the token's permissions. Every call checks permission again for its target key. `identity.describe_authority` returns `metadata.description` and a JSON Schema for the command. The schema describes valid input; the authority policy decides whether that input is allowed. See [MCP connection and tools](../ai/mcp.md) for setup and the full tool list.
+
+## Govern an MCP action
+
+This authority permits one agent identity to authorize a restart of `billing-api` in production:
+
+```yaml
+schemaVersion: verdict.authority/v1
+id: billing-api-restart
+type: custom
+version: 1.0.0
+metadata:
+ description: Authorize a billing-api restart in production.
+config:
+ fields:
+ operation: { type: string }
+ service: { type: string }
+ environment: { type: string }
+ changeId: { type: string }
+ nonce: { type: string }
+ expiresAt: { type: time }
+ effects:
+ - type: service.restart
+ fields:
+ service: "$service"
+ environment: "$environment"
+policy:
+ id: billing-api-restart
+ fallback: DENY
+ allow:
+ - id: restart-billing-api
+ where:
+ - "operation == 'restart'"
+ - "service == 'billing-api' && environment == 'production'"
+ - "effect.one(effects, 'service.restart')"
+```
+
+Attach the authority to the signing key through a digest-pinned OCI reference. Give the agent host `tkeeper.key..sign` only for that key. It can call `identity.describe_authority` to inspect the command schema, then submit:
+
+```http
+POST /mcp HTTP/1.1
+Content-Type: application/json
+MCP-Protocol-Version: 2026-07-28
+Mcp-Method: tools/call
+Mcp-Name: action.sign
+X-JWT-TOKEN:
+
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"action.sign","arguments":{"keyId":"agent-billing","command":{"type":"custom","authorityId":"billing-api-restart","artifact":{"scheme":"ECDSA","hash":"SHA256","typed":{"operation":"restart","service":"billing-api","environment":"production","changeId":"CHG-42","nonce":"n-123","expiresAt":"2030-01-02T03:04:05Z"}}}},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}
+```
+
+The restart service verifies the signature against `agent-billing` and the exact typed command, checks `expiresAt` and consumes `nonce` once. The policy limits the service and environment; the backend enforces replay protection. Changes to the fields, effect mapping, or policy require a new authority digest. Add [four-eye approval](../security-model/four-eye-control.md) to the rule when a person must approve the restart. The action must have no path around the verifying service.
+
+## Agentic payments
+
+The `agentic-payments` feature adds AP2 (`ap2.mandate`) and Mastercard Verifiable Intent (`mcintent.mandate`). Both require a P-256 key and ES256. A policy can limit merchants, payment methods, each purchase, and the total request. For example, the [AP2 purchase authority](../ai/agentic-payments/ap2.md) allows the configured shop and card, at most USD 100 per purchase and USD 150 per request; the [MC VI authority](../ai/agentic-payments/mcintent.md) applies the same limits.
+
+```java
+var artifact = new Ap2Mandates(PaymentRequestMode.PAIRED, signingInput, disclosures);
+var command = Command.of("test:ap2/purchases", artifact);
+var credential = client.signature().compose(
+ Sign.of(p256KeyId, command), PaymentCredential.class).credential();
+```
+
+`signingInput` is the encoded JWS header and payload joined by `.`; `disclosures` contains every referenced SD-JWT disclosure. Keeper checks their commitments, runs the authority policy, signs the exact JWS input, and returns a credential layer. Use `McMandates` for MC VI. The recipient must verify the credential chain, audience, expiry, replay rules, and merchant identity.
diff --git a/docs/use-cases/for-certificates.md b/docs/use-cases/for-certificates.md
deleted file mode 100644
index e9125be8..00000000
--- a/docs/use-cases/for-certificates.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# For Certificates
-
-Use TKeeper when use of a CA key must depend on an understood TBS certificate and explicit issuance policy.
-
-Flow:
-
-```text
-certificate request
--> X.509 authority policy
--> optional four-eye approval
--> CA key signature
--> certificate accepted by downstream systems
-```
-
-Good candidates:
-
-- internal CA operations
-- workload identity certificates
-- service certificates
-- agent certificates
-- high-risk certificate requests requiring approval
-
-The boundary is the CA key identity. If issuing a certificate requires TKeeper, certificate creation can be tied to permissions, authority policy, quorum mode, and audit.
-
-## Integration contract
-
-The CA or enrollment service constructs the DER-encoded TBS certificate. TKeeper exposes supported certificate fields to policy and returns the CA signature after approval.
-
-The surrounding PKI remains responsible for:
-
-- authenticating and authorizing the enrollment request
-- proof-of-possession checks where required
-- serial-number allocation and uniqueness
-- assembling and publishing the final certificate
-- certificate transparency, revocation, renewal, and inventory
-
-Relying systems must trust the expected CA identity and validate the finished certificate. A TKeeper signature does not make an otherwise invalid certificate acceptable.
-
-Relevant docs:
-
-- [X.509 Authorities](../signing-and-authorities/x509.md)
-- [Four Eye Control](../security-model/four-eye-control.md)
-- [Audit Logging](../security-model/audit-logging.md)
diff --git a/docs/use-cases/for-crypto-assets.md b/docs/use-cases/for-crypto-assets.md
deleted file mode 100644
index 8ac34019..00000000
--- a/docs/use-cases/for-crypto-assets.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# For Crypto Assets
-
-Use TKeeper when transaction signing must depend on an understood transaction, policy, and optionally external risk or human verdicts.
-
-Flow:
-
-```text
-transaction intent
--> authority policy
--> optional external verdicts
--> quorum signing
--> transaction signature
-```
-
-Good candidates:
-
-- EVM transaction signing
-- Bitcoin transaction signing
-- treasury operations
-- spender approvals
-- AML, KYT, fraud, or business verdicts before signing
-- threshold custody where one peer must not be enough
-
-## Integration contract
-
-The transaction builder remains responsible for nonce or UTXO selection, fee strategy, and constructing the final unsigned transaction. TKeeper parses the transaction supported by the selected authority, exposes normalized effects to policy, and returns a signature after approval.
-
-The broadcaster or custody backend should:
-
-- verify the signature against the expected key identity
-- broadcast exactly the transaction that was approved
-- prevent unsigned metadata from changing the transaction's meaning
-- bind external AML, KYT, fraud, or business verdicts to the same transaction intent
-- handle replacement, confirmation, and settlement state outside TKeeper
-
-TKeeper does not broadcast transactions or prove that a confirmed transaction achieved the intended business outcome.
-
-Relevant docs:
-
-- [EVM Authorities](../signing-and-authorities/evm.md)
-- [Bitcoin Authorities](../signing-and-authorities/bitcoin.md)
-- [Quorum Modes](../security-model/quorum-modes.md)
diff --git a/docs/use-cases/for-digital-assets.md b/docs/use-cases/for-digital-assets.md
new file mode 100644
index 00000000..085f9fa7
--- /dev/null
+++ b/docs/use-cases/for-digital-assets.md
@@ -0,0 +1,62 @@
+# For Digital Assets
+
+TKeeper parses an unsigned transaction, evaluates its effects against the key's authority, then signs. The transaction builder handles nonce or UTXO selection, fees, and broadcast.
+
+```sh
+./gradlew shadowJar -Pkeeper.features=digital-assets -Pkeeper.platforms=ecc
+```
+
+Select `bitcoin`, `evm`, `tron`, `xrp`, or `solana` to build one authority.
+
+| Authority | SDK artifact | Key | Compose result |
+| --- | --- | --- | --- |
+| `bitcoin.transaction` | `UtxoInput` | secp256k1 | Signature only |
+| `evm.transaction` | `UnsignedEvmTransaction.message64` (Base64) | secp256k1 | Signed transaction hex and hash |
+| `tron.transaction` | `UnsignedTronTransaction.transaction` (JSON) | secp256k1 | Signed transaction JSON and hash |
+| `xrp.transaction` | `UnsignedXrpTransaction.transaction` (hex) | secp256k1 or Ed25519 | Signed transaction hex and hash |
+| `solana.transaction` | `UnsignedSolanaTransaction.transaction` (Base64) | Ed25519 | Signed transaction Base64, `complete`, and hash when complete |
+
+## Example: limit a Tron transfer
+
+This authority permits one recipient and at most 100 TRX. Tron transfer amounts and `feeLimit` are measured in [sun](https://developers.tron.network/docs/token-standards-trx); 100 TRX is 100,000,000 sun.
+
+```yaml
+schemaVersion: verdict.authority/v1
+id: test:tron/native-transfer
+type: tron.transaction
+version: 1.0.0
+config: {}
+policy:
+ id: tron-native-transfer
+ fallback: DENY
+ variables:
+ recipient: "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"
+ allow:
+ - id: small-transfer
+ where:
+ - "feeLimit <= 30000000"
+ - "effect.onlyTypes(effects, ['native.transfer'])"
+ - "effect.one(effects, 'native.transfer')"
+ - "effect.all(effects, 'native.transfer', {'asset': 'trx', 'to': recipient})"
+ - "bigint.gt(effect.amount(effects, 'native.transfer'), '0')"
+ - "bigint.lte(effect.amount(effects, 'native.transfer'), '100000000')"
+```
+
+Attach the [complete authority document](../../integration-tests/src/testFixtures/resources/authorities/tron/native-transfer.yaml) to a secp256k1 key through a digest-pinned OCI reference. Build the unsigned transaction with that key as its owner. Sign and compose it:
+
+```java
+var artifact = new UnsignedTronTransaction(unsignedTronJson);
+var command = Command.of("test:tron/native-transfer", artifact);
+var signed = client.signature().compose(
+ Sign.of(tronKeyId, command), SignedTronTransaction.class);
+String transactionJson = signed.rawTransaction();
+String transactionHash = signed.transactionHash();
+```
+
+The integration test checks the owner key, policy boundary, unchanged transaction body, signature, and returned hash. The broadcaster still decides when and where to submit `transactionJson`.
+
+When signing with a `tweak`, build Bitcoin, Tron, XRP, or Solana transactions from the public key returned by `central().getPublicKey(keyId, tweak)`. Keeper checks the transaction's owner or required signer against that derived key on every participating peer.
+
+For XRP, policy amounts are [drops](https://xrpl.org/docs/introduction/transactions-and-requests); for Solana, native transfer amounts are [lamports](https://solana.com/docs/references/terminology). Use raw protocol units in limits. EVM composition covers legacy and typed transactions 1–4; type 3 broadcast also needs its blob sidecar. Solana can return a partial transaction: check `complete` before broadcasting.
+
+See the separate [Bitcoin](../digital-assets/bitcoin.md), [EVM](../digital-assets/evm.md), [Tron](../digital-assets/tron.md), [XRP](../digital-assets/xrp.md), and [Solana](../digital-assets/solana.md) authority guides.
diff --git a/docs/use-cases/for-internal-systems.md b/docs/use-cases/for-internal-systems.md
deleted file mode 100644
index 6059b5a5..00000000
--- a/docs/use-cases/for-internal-systems.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# For Internal Systems
-
-Use TKeeper when an internal system should execute a sensitive action only after verifying authorization by a specific machine or workflow identity.
-
-Flow:
-
-```text
-internal command
--> typed authority
--> policy
--> signature proof
--> backend verifies proof and executes
-```
-
-Good candidates:
-
-- production maintenance actions
-- typed service-to-service commands
-- protected data export approval
-- break-glass workflows
-- privileged automation
-- custom business approvals
-
-Usually this uses `custom` typed authorities. The authority document defines the command shape and effects exposed to policy.
-
-## Integration contract
-
-The executing service should define a canonical command that contains every field that changes the effect. Typical context includes the operation, target resource, environment, requester, expiry, and nonce.
-
-The service must then:
-
-- accept only the expected TKeeper identity
-- verify proof over the exact command it will execute
-- reject or ignore no security-relevant unsigned fields
-- enforce replay and idempotency rules
-- keep any alternate administrative path at least as strongly controlled
-
-For `custom` authorities, only declared fields become policy inputs. Do not let the backend act on extra JSON fields that TKeeper did not govern.
-
-Relevant docs:
-
-- [Arbitrary and Typed Authorities](../signing-and-authorities/arbitrary-and-typed.md)
-- [Authorities](../signing-and-authorities/authorities.md)
-- [Permissions](../api-reference/permissions.md)
diff --git a/docs/use-cases/for-other-activities.md b/docs/use-cases/for-other-activities.md
new file mode 100644
index 00000000..bc2cec51
--- /dev/null
+++ b/docs/use-cases/for-other-activities.md
@@ -0,0 +1,55 @@
+# For Other Activities
+
+Use a `custom` authority when a service will execute a sensitive typed command after verifying a TKeeper signature. The authority declares input fields and policy effects. Raw `arbitrary` signing has no intent policy.
+
+## Example: administrative change
+
+This authority allows a normal rotation, reconciliation, or freeze tied to an approved change ticket:
+
+```yaml
+schemaVersion: verdict.authority/v1
+id: test:custom/admin-ops
+type: custom
+version: 1.0.0
+config:
+ fields:
+ purpose: { type: string }
+ operation: { type: string }
+ ticket: { type: string }
+ emergency: { type: bool, required: false, default: false }
+policy:
+ id: test-custom-admin-ops
+ fallback: DENY
+ allow:
+ - id: allow-normal-admin-change
+ where:
+ - "purpose == 'admin'"
+ - "operation in ['rotate', 'reconcile', 'freeze']"
+ - "ticket in ['INC-0001', 'INC-0002']"
+ unless:
+ - "emergency"
+```
+
+Attach the [authority document](../../integration-tests/src/testFixtures/resources/authorities/custom/admin-ops.yaml) by digest-pinned OCI reference to the administrative key. A matching command is:
+
+```json
+{
+ "keyId": "admin-automation",
+ "command": {
+ "type": "custom",
+ "authorityId": "test:custom/admin-ops",
+ "artifact": {
+ "scheme": "ECDSA",
+ "hash": "SHA256",
+ "typed": {
+ "purpose": "admin",
+ "operation": "rotate",
+ "ticket": "INC-0001",
+ "emergency": false
+ }
+ }
+ }
+}
+```
+
+Submit it to `POST /v2/keeper/sign` or through `action.sign` on MCP. The executing service verifies the signature against the expected key and exact command, then enforces its own ticket state and replay rules. Add target, environment, expiry, and nonce fields when they affect the action. The backend must not act on undeclared fields. [Typed signing material](../signing-and-authorities/arbitrary-and-typed.md#typed-json-signing-material) defines the byte representation used for verification.
diff --git a/docs/use-cases/for-pki.md b/docs/use-cases/for-pki.md
new file mode 100644
index 00000000..5c5e2c79
--- /dev/null
+++ b/docs/use-cases/for-pki.md
@@ -0,0 +1,44 @@
+# For PKI
+
+An X.509 authority governs a CA key's signature over a DER-encoded `TBSCertificate`. The CA service builds the certificate; TKeeper checks its parsed fields and signs only after policy and any required approvals pass.
+
+```sh
+./gradlew shadowJar -Pkeeper.features=authority-x509 -Pkeeper.platforms=ecc
+```
+
+## Example: service leaf certificate
+
+This authority accepts a server certificate for `api.svc.test`, with no CA privilege:
+
+```yaml
+schemaVersion: verdict.authority/v1
+id: test:x509/service-leaf
+type: x509.tbs-certificate
+version: 1.0.0
+config: {}
+policy:
+ id: test-x509-service-leaf
+ fallback: DENY
+ allow:
+ - id: allow-service-leaf
+ where:
+ - "version == 3"
+ - "subject.commonName == 'api.svc.test'"
+ - "extensions.basicConstraints.present"
+ - "!extensions.basicConstraints.cA"
+ - "extensions.keyUsage.digitalSignature"
+ - "extensions.extKeyUsage.serverAuth"
+```
+
+The [integration fixture](../../integration-tests/src/testFixtures/resources/authorities/x509/service-leaf.yaml) uses this rule. The [X.509 authority guide](../pki/x509.md#authority-example-workload-server-certificate) adds issuer, DNS SAN, validity, signature algorithm, and operator approval checks for a stricter profile.
+
+The CA service submits exactly the TBS bytes it will publish:
+
+```java
+var tbs = new TBSDerEncoded(Base64.getEncoder().encodeToString(tbsDer));
+var command = Command.of("test:x509/service-leaf", tbs);
+var signature = client.signature().sign(Sign.of(issuingKeyId, command));
+byte[] signatureBytes = Base64.getDecoder().decode(signature.signature64());
+```
+
+The CA service assembles the final certificate and checks that its signature algorithm matches the key. Enrollment authorization, proof of possession, serial-number uniqueness, revocation, and publication remain in the PKI. Relying parties validate the finished certificate and chain.
diff --git a/features/agentic-payments/README.md b/features/agentic-payments/README.md
new file mode 100644
index 00000000..91e2eb15
--- /dev/null
+++ b/features/agentic-payments/README.md
@@ -0,0 +1,112 @@
+# Agentic payments
+
+AP2 and Mastercard Verifiable Intent use the same authority, approval, signing and
+verification pipeline as the other keeper authority modules.
+
+```sh
+./gradlew shadowJar -Pkeeper.features=agentic-payments -Pkeeper.platforms=ecc
+```
+
+Select `ap2` or `mc-vi` independently when only one is needed. Their dependencies are
+`org.exploit.verdict:ap2:0.2.0` and
+`org.exploit.verdict:mc-vi:0.2.0`; shared request validation and CEL
+functions come from `org.exploit.verdict:payments:0.2.0`.
+
+## Authority
+
+Use `type: ap2.mandate` or `type: mcintent.mandate`.
+`config.merchants` and `config.methods` are the named identity catalogs validated
+by Verdict. `config.mode` is `PAIRED` by default; explicitly select `PAYMENTS` or
+`CHECKOUTS` for a standalone signing stage. The artifact mode must match it.
+
+The policy receives Verdict's action schema: `action`, `payment`, `checkout`,
+`delegation`, `request`, `mandates`, `context`, `merchants`, and `methods`.
+Every action is evaluated; a denied action denies the complete request, and all
+approval requirements are retained. `context` is empty.
+
+See the executable [AP2 authority](../../integration-tests/src/testFixtures/resources/authorities/payments/ap2-purchases.yaml)
+and [MC VI authority](../../integration-tests/src/testFixtures/resources/authorities/payments/mcintent-purchases.yaml).
+The [AP2 guide](../../docs/ai/agentic-payments/ap2.md) and [MC Intent guide](../../docs/ai/agentic-payments/mcintent.md) have complete authorities and SDK examples.
+
+## SDK and signing contract
+
+```java
+var artifact = new Ap2Mandates(
+ PaymentRequestMode.PAIRED,
+ protectedHeaderBase64Url + "." + payloadBase64Url,
+ encodedDisclosures);
+var command = Command.of("test:ap2/purchases", artifact);
+var result = client.signature().compose(Sign.of(p256KeyId, command), PaymentCredential.class);
+String credential = result.credential();
+```
+
+Use `McMandates` for MC VI. `signingInput` contains exactly two unpadded base64url
+segments: the protected JWS header and payload. Supply every disclosure referenced
+by `delegate_payload`, including nested disclosures. Keeper verifies their SHA-256
+commitments and reconstructs the mandates before running Verdict. Missing,
+duplicate, unreferenced or colliding disclosures fail closed. Signing requires
+complete disclosure even though subsequent presentations may disclose a subset.
+The supported top-level `_sd` index, when present, mirrors `delegate_payload`
+(the VI profile). Other top-level selectively disclosed claims are not supported.
+
+The signed bytes are the **unchanged ASCII `signingInput`**. ES256 fixes the signing
+scheme to ECDSA, the key to P-256 and the digest to SHA-256. Caller-selected
+`scheme`/`hash` and detached `mandates`/`checkoutJson` fields are rejected.
+A closed checkout's content comes exclusively from its `checkout_jwt` payload;
+`checkout_hash` must equal `base64url(SHA256(ASCII(checkout_jwt)))`. Open checkout
+references use the hash of the actual encoded disclosure. Verdict checks pair
+references, agent bindings, complete request contents and authority policies.
+
+AP2 supports trusted-provider SD-JWT (`dc+sd-jwt`/`sd+jwt`) and delegated KB-SD-JWT.
+MC VI supports L2 Immediate/Autonomous (`kb-sd-jwt`/`kb-sd-jwt+kb`) and terminal L3
+(`kb-sd-jwt` with an explicit PAYMENTS or CHECKOUTS authority mode). VI uses ES256,
+`sha-256`, required binding claims, compatible mandate kinds and P-256 public agent
+JWKs; L3 requires `kid` and a validity interval of at most one hour.
+
+The signing adapter validates the outgoing credential and its content commitments.
+The caller supplies issuer/audience/nonce/time claims and `sd_hash` computed from
+the exact parent presentation. Parent-chain authentication, trust roots, replay,
+expiry at presentation time and merchant-signature verification belong to the
+credential verifier. Parsing a merchant JWT here establishes its content binding,
+not the merchant's identity. Composer does not authenticate parent credentials.
+
+`POST /v2/keeper/compose` accepts the same `Sign` request as `/v2/keeper/sign`.
+It uses the same authority, approval and signing pipeline, then returns:
+
+```json
+{
+ "type": "ap2.mandate",
+ "credential": "..~~~",
+ "generation": 1,
+ "imposters": []
+}
+```
+
+MC VI returns `type: mcintent.mandate` with the same combined serialization; its
+protected JWT `typ` distinguishes Immediate, Autonomous and terminal credentials.
+The string is ready to use as the signed SD-JWT/KB-SD-JWT layer. Composer preserves
+the exact signing input and disclosure strings/order, encodes ES256 as the 64-byte
+`R || S` signature in unpadded base64url, and includes the trailing `~`.
+For delegated credentials, present this layer alongside the existing parent
+credential(s) bound by `sd_hash`; Keeper cannot reconstruct that chain from a hash.
+All supplied disclosures are returned. Select the recipient-appropriate disclosures
+when presenting the credential.
+
+`/v2/keeper/sign` still returns the raw Keeper signature. Compose falls back to that
+response for command types without a registered Composer. The SDK exposes
+`signature().compose(request)` as JSON and `signature().compose(request, Response.class)`
+for typed responses (`PaymentCredential` or `ThresholdSignature`, respectively).
+Authority ID and Keeper's artifact type/mode remain authorization metadata; they
+are not extra bytes in the protocol signature.
+
+Implemented against the pinned specification snapshots:
+[AP2 agent authorization](https://github.com/google-agentic-commerce/AP2/blob/e1ea56db72a6385bce3e5c1112b3a56ce60acb43/docs/ap2/agent_authorization.md)
+and [MC VI credential format](https://github.com/agent-intent/verifiable-intent/blob/356c29635f1c44df7de02edb58699ca9f29bece6/spec/credential-format.md).
+
+## Tests
+
+```sh
+./gradlew :features:agentic-payments:ap2:test :features:agentic-payments:mc-vi:test :sdk:test
+./gradlew dockerBuildIntegration
+./gradlew :integration-tests:functional:test --tests '*AgenticPaymentTests'
+```
diff --git a/features/agentic-payments/ap2/build.gradle b/features/agentic-payments/ap2/build.gradle
new file mode 100644
index 00000000..62db4d41
--- /dev/null
+++ b/features/agentic-payments/ap2/build.gradle
@@ -0,0 +1,70 @@
+plugins {
+ id 'org.jetbrains.kotlin.jvm'
+ id 'com.gradleup.shadow' version '9.4.1'
+ id 'com.google.devtools.ksp' version '2.3.10'
+}
+
+group = 'org.exploit'
+version = '2.5.0'
+
+repositories {
+ mavenCentral()
+ maven {
+ name = "sonatypeCentralSnapshots"
+ url = uri("https://central.sonatype.com/repository/maven-snapshots/")
+ mavenContent {
+ snapshotsOnly()
+ }
+ }
+}
+
+def jacksonGroups = [
+ 'com.fasterxml.jackson.core',
+ 'com.fasterxml.jackson.module',
+ 'com.fasterxml.jackson.datatype',
+ 'com.fasterxml.jackson.dataformat',
+ 'com.fasterxml.jackson.jaxrs',
+ 'com.fasterxml.jackson.jakarta.rs'
+]
+
+configurations.matching { it.name == 'runtimeClasspath' }.configureEach {
+ jacksonGroups.each { groupName -> exclude group: groupName }
+}
+
+configurations.testRuntimeClasspath {
+ exclude group: 'com.google.protobuf', module: 'protobuf-javalite'
+}
+
+dependencies {
+ testImplementation testFixtures(project(':features:agentic-payments'))
+ testImplementation 'org.jetbrains.kotlin:kotlin-test'
+ testImplementation testFixtures(project(':integration-tests'))
+ testImplementation(project(':'))
+ compileOnly(project(':'))
+ implementation(project(':features:agentic-payments'))
+
+ ksp "io.micronaut:micronaut-inject-kotlin:4.10.8"
+ implementation 'org.exploit.verdict:ap2:0.2.0'
+}
+
+tasks.named('shadowJar') {
+ archiveClassifier.set('')
+ mergeServiceFiles()
+ exclude 'com/fasterxml/jackson/**'
+ exclude 'META-INF/services/com.fasterxml.jackson.*'
+ exclude 'io/netty/**'
+ exclude 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA', 'META-INF/INDEX.LIST', 'META-INF/DEPENDENCIES', 'META-INF/NOTICE', 'META-INF/LICENSE*'
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+tasks.named('assemble') {
+ dependsOn tasks.named('shadowJar')
+}
+
+kotlin {
+ jvmToolchain(25)
+}
+
+test {
+ useJUnitPlatform()
+}
diff --git a/features/agentic-payments/ap2/src/main/kotlin/org/exploit/keeper/payment/ap2/Ap2CommandMaterializer.kt b/features/agentic-payments/ap2/src/main/kotlin/org/exploit/keeper/payment/ap2/Ap2CommandMaterializer.kt
new file mode 100644
index 00000000..32c65d59
--- /dev/null
+++ b/features/agentic-payments/ap2/src/main/kotlin/org/exploit/keeper/payment/ap2/Ap2CommandMaterializer.kt
@@ -0,0 +1,9 @@
+package org.exploit.keeper.payment.ap2
+
+import jakarta.inject.Singleton
+import org.exploit.keeper.service.db.RocksKeyDB
+import org.exploit.keeper.payment.PaymentCommandMaterializer
+import org.exploit.keeper.payment.ap2.intent.Ap2IntentBuilder
+
+@Singleton
+class Ap2CommandMaterializer(builder: Ap2IntentBuilder, db: RocksKeyDB): PaymentCommandMaterializer(builder, db)
diff --git a/features/agentic-payments/ap2/src/main/kotlin/org/exploit/keeper/payment/ap2/compose/Ap2Composer.kt b/features/agentic-payments/ap2/src/main/kotlin/org/exploit/keeper/payment/ap2/compose/Ap2Composer.kt
new file mode 100644
index 00000000..09c39cd2
--- /dev/null
+++ b/features/agentic-payments/ap2/src/main/kotlin/org/exploit/keeper/payment/ap2/compose/Ap2Composer.kt
@@ -0,0 +1,8 @@
+package org.exploit.keeper.payment.ap2.compose
+
+import jakarta.inject.Singleton
+import org.exploit.keeper.payment.ap2.intent.Ap2IntentBuilder
+import org.exploit.keeper.payment.compose.PaymentComposer
+
+@Singleton
+class Ap2Composer(builder: Ap2IntentBuilder): PaymentComposer(builder)
diff --git a/features/agentic-payments/ap2/src/main/kotlin/org/exploit/keeper/payment/ap2/deserializer/Ap2MandatesDeserializer.kt b/features/agentic-payments/ap2/src/main/kotlin/org/exploit/keeper/payment/ap2/deserializer/Ap2MandatesDeserializer.kt
new file mode 100644
index 00000000..b401db5f
--- /dev/null
+++ b/features/agentic-payments/ap2/src/main/kotlin/org/exploit/keeper/payment/ap2/deserializer/Ap2MandatesDeserializer.kt
@@ -0,0 +1,8 @@
+package org.exploit.keeper.payment.ap2.deserializer
+
+import jakarta.inject.Singleton
+import org.exploit.keeper.payment.deserializer.PaymentMandatesDeserializer
+import org.exploit.verdict.intent.IntentTypes
+
+@Singleton
+class Ap2MandatesDeserializer: PaymentMandatesDeserializer(IntentTypes.AP2_MANDATE)
diff --git a/features/agentic-payments/ap2/src/main/kotlin/org/exploit/keeper/payment/ap2/intent/Ap2IntentBuilder.kt b/features/agentic-payments/ap2/src/main/kotlin/org/exploit/keeper/payment/ap2/intent/Ap2IntentBuilder.kt
new file mode 100644
index 00000000..0195533c
--- /dev/null
+++ b/features/agentic-payments/ap2/src/main/kotlin/org/exploit/keeper/payment/ap2/intent/Ap2IntentBuilder.kt
@@ -0,0 +1,22 @@
+package org.exploit.keeper.payment.ap2.intent
+
+import jakarta.inject.Singleton
+import org.exploit.keeper.payment.intent.PaymentIntentBuilder
+import org.exploit.verdict.intent.IntentTypes
+import org.exploit.verdict.intent.ap2.Ap2Intent
+import org.exploit.verdict.intent.Intent
+import org.exploit.verdict.intent.payment.model.MandateInput
+
+@Singleton
+class Ap2IntentBuilder: PaymentIntentBuilder() {
+ override fun type(): String {
+ return IntentTypes.AP2_MANDATE
+ }
+
+ override fun parseMandate(input: MandateInput): Intent {
+ return if (input.checkoutJson() == null)
+ Ap2Intent.fromJson(input.json())
+ else
+ Ap2Intent.fromCheckoutJson(input.json(), input.checkoutJson())
+ }
+}
diff --git a/features/agentic-payments/ap2/src/test/kotlin/org/exploit/keeper/payment/ap2/Ap2PaymentTest.kt b/features/agentic-payments/ap2/src/test/kotlin/org/exploit/keeper/payment/ap2/Ap2PaymentTest.kt
new file mode 100644
index 00000000..af394910
--- /dev/null
+++ b/features/agentic-payments/ap2/src/test/kotlin/org/exploit/keeper/payment/ap2/Ap2PaymentTest.kt
@@ -0,0 +1,60 @@
+package org.exploit.keeper.payment.ap2
+
+import org.exploit.keeper.payment.PaymentSupportTest
+import org.exploit.keeper.payment.ap2.deserializer.Ap2MandatesDeserializer
+import org.exploit.keeper.payment.ap2.intent.Ap2IntentBuilder
+import org.exploit.test.authority.AuthorityFixtures
+import org.exploit.test.authority.PaymentMandate
+import org.exploit.test.authority.PaymentPayloads
+import org.exploit.tkeeper.sdk.model.command.artifact.Ap2Mandates
+import org.exploit.tkeeper.sdk.model.command.artifact.Artifact
+import org.exploit.tkeeper.sdk.model.command.artifact.PaymentRequestMode
+import org.junit.jupiter.api.Test
+
+class Ap2PaymentTest: PaymentSupportTest() {
+ override val builder = Ap2IntentBuilder()
+ override val composer = org.exploit.keeper.payment.ap2.compose.Ap2Composer(builder)
+ override val deserializer = Ap2MandatesDeserializer()
+ override val fixture = AuthorityFixtures.ap2Purchases
+
+ @Test
+ fun `AP2 delegated profile uses plus typ and supports either parent hash`() {
+ val original = builder.artifact(command())
+ val parts = original.signingInput.split('.')
+ val header = decode(parts[0]).put("typ", "kb+sd-jwt")
+ val payload = decode(parts[1]).apply {
+ remove(listOf("iss", "vct", "exp"))
+ put("aud", "merchant")
+ put("nonce", "challenge-0123456789abcdef")
+ put("sd_hash", PaymentPayloads.hash("parent~"))
+ }
+ val input = PaymentPayloads.encoded(header.toString()) + "." + PaymentPayloads.encoded(payload.toString())
+ builder.validate(wire(original, input))
+
+ payload.remove("sd_hash")
+ payload.put("issuer_jwt_hash", PaymentPayloads.hash("parent.jwt.signature"))
+ builder.validate(wire(original, PaymentPayloads.encoded(header.toString()) + "." + PaymentPayloads.encoded(payload.toString())))
+ payload.put("sd_hash", PaymentPayloads.hash("parent~"))
+ reject(wire(original, PaymentPayloads.encoded(header.toString()) + "." + PaymentPayloads.encoded(payload.toString())))
+ reject(wire(original, PaymentPayloads.encoded(header.put("typ", "kb-sd-jwt").toString()) + "." + parts[1]))
+ }
+
+ @Test
+ fun `AP2 accepts custom root profiles and validates open agent keys`() {
+ val original = builder.artifact(command(PaymentPayloads.delegation(builder.type())))
+ val parts = original.signingInput.split('.')
+ val header = decode(parts[0]).put("typ", "example+sd-jwt")
+ val payload = decode(parts[1]).apply { remove(listOf("iat", "exp", "iss", "vct")) }
+ builder.validate(wire(original, PaymentPayloads.encoded(header.toString()) + "." + PaymentPayloads.encoded(payload.toString())))
+
+ val invalid = PaymentPayloads.delegation(builder.type()).map {
+ PaymentMandate.of(it.json().replace("P-256", "invalid-curve"))
+ }
+ reject(builder.artifact(command(invalid)))
+ }
+
+ override fun artifact(mandates: List, mode: PaymentRequestMode): Artifact {
+ val input = PaymentPayloads.signingInput(builder.type(), mandates)
+ return Ap2Mandates(mode, input.value, input.disclosures)
+ }
+}
diff --git a/features/agentic-payments/build.gradle b/features/agentic-payments/build.gradle
new file mode 100644
index 00000000..a6d41570
--- /dev/null
+++ b/features/agentic-payments/build.gradle
@@ -0,0 +1,74 @@
+plugins {
+ id 'java-library'
+ id 'java-test-fixtures'
+ id 'org.jetbrains.kotlin.jvm'
+ id 'com.gradleup.shadow' version '9.4.1'
+ id 'com.google.devtools.ksp' version '2.3.10'
+}
+
+group = 'org.exploit'
+version = '2.5.0'
+
+repositories {
+ mavenCentral()
+ maven {
+ name = "sonatypeCentralSnapshots"
+ url = uri("https://central.sonatype.com/repository/maven-snapshots/")
+ mavenContent {
+ snapshotsOnly()
+ }
+ }
+}
+
+def jacksonGroups = [
+ 'com.fasterxml.jackson.core',
+ 'com.fasterxml.jackson.module',
+ 'com.fasterxml.jackson.datatype',
+ 'com.fasterxml.jackson.dataformat',
+ 'com.fasterxml.jackson.jaxrs',
+ 'com.fasterxml.jackson.jakarta.rs'
+]
+
+configurations.matching { it.name == 'runtimeClasspath' }.configureEach {
+ jacksonGroups.each { groupName -> exclude group: groupName }
+}
+
+configurations.testRuntimeClasspath {
+ exclude group: 'com.google.protobuf', module: 'protobuf-javalite'
+}
+
+dependencies {
+ testFixturesImplementation(project(':'))
+ testFixturesImplementation testFixtures(project(':integration-tests'))
+ testImplementation 'org.jetbrains.kotlin:kotlin-test'
+ testImplementation testFixtures(project(':integration-tests'))
+ testImplementation(project(':'))
+ compileOnly(project(':'))
+ compileOnly(project(':platform-ecc'))
+
+ ksp "io.micronaut:micronaut-inject-kotlin:4.10.8"
+ api 'org.exploit.verdict:payments:0.2.0'
+ implementation 'com.nimbusds:nimbus-jose-jwt:10.2'
+}
+
+tasks.named('shadowJar') {
+ archiveClassifier.set('')
+ mergeServiceFiles()
+ exclude 'com/fasterxml/jackson/**'
+ exclude 'META-INF/services/com.fasterxml.jackson.*'
+ exclude 'io/netty/**'
+ exclude 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA', 'META-INF/INDEX.LIST', 'META-INF/DEPENDENCIES', 'META-INF/NOTICE', 'META-INF/LICENSE*'
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+tasks.named('assemble') {
+ dependsOn tasks.named('shadowJar')
+}
+
+kotlin {
+ jvmToolchain(25)
+}
+
+test {
+ useJUnitPlatform()
+}
diff --git a/features/agentic-payments/mc-vi/build.gradle b/features/agentic-payments/mc-vi/build.gradle
new file mode 100644
index 00000000..0bc259ba
--- /dev/null
+++ b/features/agentic-payments/mc-vi/build.gradle
@@ -0,0 +1,70 @@
+plugins {
+ id 'org.jetbrains.kotlin.jvm'
+ id 'com.gradleup.shadow' version '9.4.1'
+ id 'com.google.devtools.ksp' version '2.3.10'
+}
+
+group = 'org.exploit'
+version = '2.5.0'
+
+repositories {
+ mavenCentral()
+ maven {
+ name = "sonatypeCentralSnapshots"
+ url = uri("https://central.sonatype.com/repository/maven-snapshots/")
+ mavenContent {
+ snapshotsOnly()
+ }
+ }
+}
+
+def jacksonGroups = [
+ 'com.fasterxml.jackson.core',
+ 'com.fasterxml.jackson.module',
+ 'com.fasterxml.jackson.datatype',
+ 'com.fasterxml.jackson.dataformat',
+ 'com.fasterxml.jackson.jaxrs',
+ 'com.fasterxml.jackson.jakarta.rs'
+]
+
+configurations.matching { it.name == 'runtimeClasspath' }.configureEach {
+ jacksonGroups.each { groupName -> exclude group: groupName }
+}
+
+configurations.testRuntimeClasspath {
+ exclude group: 'com.google.protobuf', module: 'protobuf-javalite'
+}
+
+dependencies {
+ testImplementation testFixtures(project(':features:agentic-payments'))
+ testImplementation 'org.jetbrains.kotlin:kotlin-test'
+ testImplementation testFixtures(project(':integration-tests'))
+ testImplementation(project(':'))
+ compileOnly(project(':'))
+ implementation(project(':features:agentic-payments'))
+
+ ksp "io.micronaut:micronaut-inject-kotlin:4.10.8"
+ implementation 'org.exploit.verdict:mc-vi:0.2.0'
+}
+
+tasks.named('shadowJar') {
+ archiveClassifier.set('')
+ mergeServiceFiles()
+ exclude 'com/fasterxml/jackson/**'
+ exclude 'META-INF/services/com.fasterxml.jackson.*'
+ exclude 'io/netty/**'
+ exclude 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA', 'META-INF/INDEX.LIST', 'META-INF/DEPENDENCIES', 'META-INF/NOTICE', 'META-INF/LICENSE*'
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+tasks.named('assemble') {
+ dependsOn tasks.named('shadowJar')
+}
+
+kotlin {
+ jvmToolchain(25)
+}
+
+test {
+ useJUnitPlatform()
+}
diff --git a/features/agentic-payments/mc-vi/src/main/kotlin/org/exploit/keeper/payment/mcvi/McCommandMaterializer.kt b/features/agentic-payments/mc-vi/src/main/kotlin/org/exploit/keeper/payment/mcvi/McCommandMaterializer.kt
new file mode 100644
index 00000000..7a2ea502
--- /dev/null
+++ b/features/agentic-payments/mc-vi/src/main/kotlin/org/exploit/keeper/payment/mcvi/McCommandMaterializer.kt
@@ -0,0 +1,9 @@
+package org.exploit.keeper.payment.mcvi
+
+import jakarta.inject.Singleton
+import org.exploit.keeper.service.db.RocksKeyDB
+import org.exploit.keeper.payment.PaymentCommandMaterializer
+import org.exploit.keeper.payment.mcvi.intent.McIntentBuilder
+
+@Singleton
+class McCommandMaterializer(builder: McIntentBuilder, db: RocksKeyDB): PaymentCommandMaterializer(builder, db)
diff --git a/features/agentic-payments/mc-vi/src/main/kotlin/org/exploit/keeper/payment/mcvi/compose/McComposer.kt b/features/agentic-payments/mc-vi/src/main/kotlin/org/exploit/keeper/payment/mcvi/compose/McComposer.kt
new file mode 100644
index 00000000..096c7e36
--- /dev/null
+++ b/features/agentic-payments/mc-vi/src/main/kotlin/org/exploit/keeper/payment/mcvi/compose/McComposer.kt
@@ -0,0 +1,8 @@
+package org.exploit.keeper.payment.mcvi.compose
+
+import jakarta.inject.Singleton
+import org.exploit.keeper.payment.compose.PaymentComposer
+import org.exploit.keeper.payment.mcvi.intent.McIntentBuilder
+
+@Singleton
+class McComposer(builder: McIntentBuilder): PaymentComposer(builder)
diff --git a/features/agentic-payments/mc-vi/src/main/kotlin/org/exploit/keeper/payment/mcvi/deserializer/McMandatesDeserializer.kt b/features/agentic-payments/mc-vi/src/main/kotlin/org/exploit/keeper/payment/mcvi/deserializer/McMandatesDeserializer.kt
new file mode 100644
index 00000000..34578fe6
--- /dev/null
+++ b/features/agentic-payments/mc-vi/src/main/kotlin/org/exploit/keeper/payment/mcvi/deserializer/McMandatesDeserializer.kt
@@ -0,0 +1,8 @@
+package org.exploit.keeper.payment.mcvi.deserializer
+
+import jakarta.inject.Singleton
+import org.exploit.keeper.payment.deserializer.PaymentMandatesDeserializer
+import org.exploit.verdict.intent.IntentTypes
+
+@Singleton
+class McMandatesDeserializer: PaymentMandatesDeserializer(IntentTypes.MCINTENT_MANDATE)
diff --git a/features/agentic-payments/mc-vi/src/main/kotlin/org/exploit/keeper/payment/mcvi/intent/McIntentBuilder.kt b/features/agentic-payments/mc-vi/src/main/kotlin/org/exploit/keeper/payment/mcvi/intent/McIntentBuilder.kt
new file mode 100644
index 00000000..e0880ba4
--- /dev/null
+++ b/features/agentic-payments/mc-vi/src/main/kotlin/org/exploit/keeper/payment/mcvi/intent/McIntentBuilder.kt
@@ -0,0 +1,22 @@
+package org.exploit.keeper.payment.mcvi.intent
+
+import jakarta.inject.Singleton
+import org.exploit.keeper.payment.intent.PaymentIntentBuilder
+import org.exploit.verdict.intent.IntentTypes
+import org.exploit.verdict.intent.mcintent.McIntent
+import org.exploit.verdict.intent.Intent
+import org.exploit.verdict.intent.payment.model.MandateInput
+
+@Singleton
+class McIntentBuilder: PaymentIntentBuilder() {
+ override fun type(): String {
+ return IntentTypes.MCINTENT_MANDATE
+ }
+
+ override fun parseMandate(input: MandateInput): Intent {
+ return if (input.checkoutJson() == null)
+ McIntent.fromJson(input.json())
+ else
+ McIntent.fromCheckoutJson(input.json(), input.checkoutJson())
+ }
+}
diff --git a/features/agentic-payments/mc-vi/src/test/kotlin/org/exploit/keeper/payment/mcvi/McPaymentTest.kt b/features/agentic-payments/mc-vi/src/test/kotlin/org/exploit/keeper/payment/mcvi/McPaymentTest.kt
new file mode 100644
index 00000000..57bea9a1
--- /dev/null
+++ b/features/agentic-payments/mc-vi/src/test/kotlin/org/exploit/keeper/payment/mcvi/McPaymentTest.kt
@@ -0,0 +1,98 @@
+package org.exploit.keeper.payment.mcvi
+
+import com.fasterxml.jackson.databind.node.ObjectNode
+import org.exploit.keeper.payment.PaymentSupportTest
+import org.exploit.keeper.payment.mcvi.deserializer.McMandatesDeserializer
+import org.exploit.keeper.payment.mcvi.intent.McIntentBuilder
+import org.exploit.test.authority.AuthorityFixtures
+import org.exploit.test.authority.PaymentMandate
+import org.exploit.test.authority.PaymentPayloads
+import org.exploit.tkeeper.sdk.model.command.artifact.Artifact
+import org.exploit.tkeeper.sdk.model.command.artifact.McMandates
+import org.exploit.tkeeper.sdk.model.command.artifact.PaymentRequestMode
+import org.junit.jupiter.api.Test
+
+class McPaymentTest : PaymentSupportTest() {
+ override val builder = McIntentBuilder()
+ override val composer = org.exploit.keeper.payment.mcvi.compose.McComposer(builder)
+ override val deserializer = McMandatesDeserializer()
+ override val fixture = AuthorityFixtures.mcPurchases
+
+ @Test
+ fun `VI requires key binding claims and terminal lifetime`() {
+ val original = builder.artifact(command())
+ val parts = original.signingInput.split('.')
+ val payload = decode(parts[1])
+ for (field in listOf("nonce", "aud", "iat", "exp", "sd_hash", "_sd_alg", "_sd")) {
+ val changed = payload.deepCopy().apply { remove(field) }
+ reject(wire(original, parts[0] + "." + PaymentPayloads.encoded(changed.toString())))
+ }
+ val changes = listOf(
+ payload.deepCopy().put("iat", 1.5), payload.deepCopy().put("iat", -1),
+ payload.deepCopy().put("exp", 1), payload.deepCopy().put("aud", "relative"),
+ payload.deepCopy().put("sd_hash", "AA"), payload.deepCopy().apply { putObject("cnf") })
+ changes.forEach { reject(wire(original, parts[0] + "." + PaymentPayloads.encoded(it.toString()))) }
+
+ val terminal = builder.artifact(command(listOf(PaymentPayloads.payment()), PaymentRequestMode.PAYMENTS))
+ val terminalParts = terminal.signingInput.split('.')
+ val tooLong = decode(terminalParts[1]).apply { put("exp", get("iat").longValue() + 3601) }
+ reject(wire(terminal, terminalParts[0] + "." + PaymentPayloads.encoded(tooLong.toString())))
+ val noKid = decode(terminalParts[0]).apply { remove("kid") }
+ reject(wire(terminal, PaymentPayloads.encoded(noKid.toString()) + "." + terminalParts[1]))
+ }
+
+ @Test
+ fun `VI rejects invalid agent JWKs and mismatched autonomous profiles`() {
+ val open = PaymentPayloads.delegation(builder.type())
+ val original = builder.artifact(command(open))
+ val parts = original.signingInput.split('.')
+ val terminalHeader = decode(parts[0]).put("typ", "kb-sd-jwt")
+ reject(wire(original, PaymentPayloads.encoded(terminalHeader.toString()) + "." + parts[1]))
+
+ for (replacement in listOf(
+ "{\"kid\":\"agent-1\"}",
+ "{\"kty\":\"EC\",\"crv\":\"P-256\",\"kid\":\"agent-1\",\"x\":\"AA\",\"y\":\"AA\"}"
+ )) {
+ val inputs = open.map { input ->
+ val mandate =
+ com.fasterxml.jackson.module.kotlin.jacksonObjectMapper().readTree(input.json()) as ObjectNode
+ mandate.putObject("cnf").set(
+ "jwk",
+ com.fasterxml.jackson.module.kotlin.jacksonObjectMapper().readTree(replacement)
+ )
+ PaymentMandate.of(mandate.toString())
+ }
+ val hash = PaymentPayloads.hash(PaymentPayloads.disclosure(inputs[0].json()))
+ val paired = listOf(
+ PaymentMandate.openCheckout(inputs[0].json(), hash),
+ PaymentMandate.of(inputs[1].json().replace(open[0].disclosureHash(), hash))
+ )
+ reject(builder.artifact(command(paired)))
+ }
+ }
+
+ @Test
+ fun `VI rejects invalid issuer excessive immediate lifetime and multiple L3 mandates`() {
+ val original = builder.artifact(command())
+ val parts = original.signingInput.split('.')
+ val payload = decode(parts[1])
+ val variants = listOf(
+ payload.deepCopy().put("iss", "relative"),
+ payload.deepCopy().put("exp", payload.get("iat").longValue() + 901),
+ payload.deepCopy().put("issuer_jwt_hash", PaymentPayloads.hash("parent.jwt.signature"))
+ )
+ variants.forEach { reject(wire(original, parts[0] + "." + PaymentPayloads.encoded(it.toString()))) }
+ val multiple = builder.artifact(
+ command(
+ listOf(PaymentPayloads.payment(), PaymentPayloads.payment("other")),
+ PaymentRequestMode.PAYMENTS
+ )
+ )
+ reject(multiple)
+ }
+
+ override fun artifact(mandates: List, mode: PaymentRequestMode): Artifact {
+ val input = PaymentPayloads.signingInput(builder.type(), mandates)
+ return McMandates(mode, input.value, input.disclosures)
+ }
+}
diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/PaymentCommandMaterializer.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/PaymentCommandMaterializer.kt
new file mode 100644
index 00000000..78d9236a
--- /dev/null
+++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/PaymentCommandMaterializer.kt
@@ -0,0 +1,39 @@
+package org.exploit.keeper.payment
+
+import org.exploit.keeper.constant.common.ErrorType
+import org.exploit.keeper.constant.signature.HashMethod
+import org.exploit.keeper.exception.TKeeperException
+import org.exploit.keeper.model.command.Command
+import org.exploit.keeper.model.material.DefaultSigningMaterial
+import org.exploit.keeper.model.material.SigningMaterial
+import org.exploit.keeper.payment.intent.PaymentIntentBuilder
+import org.exploit.keeper.platform.ecc.algorithm.P256AlgorithmProvider
+import org.exploit.keeper.platform.ecc.scheme.ECDSASchemeProvider
+import org.exploit.keeper.service.command.CommandMaterializer
+import org.exploit.keeper.service.db.RocksKeyDB
+import org.exploit.verdict.exception.IntentValidationException
+import java.nio.charset.StandardCharsets.US_ASCII
+
+abstract class PaymentCommandMaterializer(private val builder: PaymentIntentBuilder, private val db: RocksKeyDB): CommandMaterializer {
+ override fun type(): String {
+ return builder.type()
+ }
+
+ override fun toSigningMaterial(keyId: String, generation: Int, c: Command, tweak: String?): SigningMaterial {
+ val artifact = builder.artifact(c)
+
+ try {
+ builder.validate(artifact)
+ } catch (e: IntentValidationException) {
+ throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, e.reason(), e)
+ }
+
+ val algorithm = db.vault.extractAlgorithm(keyId, generation)
+ ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, keyId)
+
+ if (algorithm != P256AlgorithmProvider.ID)
+ throw TKeeperException(ErrorType.UNSUPPORTED_KEY_ALGORITHM, "ES256 requires P-256")
+
+ return DefaultSigningMaterial(ECDSASchemeProvider.NAME, HashMethod.SHA256.process(artifact.signingInput.toByteArray(US_ASCII)))
+ }
+}
diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/artifact/PaymentMandates.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/artifact/PaymentMandates.kt
new file mode 100644
index 00000000..439d015a
--- /dev/null
+++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/artifact/PaymentMandates.kt
@@ -0,0 +1,17 @@
+package org.exploit.keeper.payment.artifact
+
+import org.exploit.keeper.model.command.artifact.Artifact
+import org.exploit.verdict.intent.payment.constant.PaymentRequestMode
+
+class PaymentMandates(
+ private val protocol: String,
+ val mode: PaymentRequestMode,
+ val signingInput: String,
+ disclosures: List
+): Artifact {
+ val disclosures: List = java.util.List.copyOf(disclosures)
+
+ override fun type(): String {
+ return protocol
+ }
+}
diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/compose/PaymentComposer.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/compose/PaymentComposer.kt
new file mode 100644
index 00000000..cf177973
--- /dev/null
+++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/compose/PaymentComposer.kt
@@ -0,0 +1,39 @@
+package org.exploit.keeper.payment.compose
+
+import org.exploit.keeper.constant.common.ErrorType
+import org.exploit.keeper.exception.TKeeperException
+import org.exploit.keeper.model.api.incoming.Sign
+import org.exploit.keeper.model.api.outgoing.CalculatedSignature
+import org.exploit.keeper.payment.intent.PaymentIntentBuilder
+import org.exploit.keeper.payment.outcome.PaymentCredential
+import org.exploit.keeper.service.compose.spi.Composer
+import java.util.Base64
+
+abstract class PaymentComposer(private val builder: PaymentIntentBuilder): Composer {
+ override val type: String = builder.type()
+
+ override fun compose(request: Sign, signature: CalculatedSignature): PaymentCredential {
+ val artifact = builder.artifact(request.command)
+ val encoded = signature(signature)
+ val jwt = "${artifact.signingInput}.$encoded"
+ val credential = artifact.disclosures.joinToString("~", prefix = "$jwt~", postfix = "~")
+
+ return PaymentCredential(type, credential, signature.generation, signature.imposters)
+ }
+
+ private fun signature(signature: CalculatedSignature): String {
+ if (signature.type != "ECDSA" || signature.signature64 == null)
+ throw TKeeperException(ErrorType.INVALID_SIGNATURE, "ES256 signature is missing")
+
+ val bytes = try {
+ Base64.getDecoder().decode(signature.signature64)
+ } catch (e: IllegalArgumentException) {
+ throw TKeeperException(ErrorType.INVALID_SIGNATURE, "Invalid signature encoding", e)
+ }
+
+ if (bytes.size !in 64..65)
+ throw TKeeperException(ErrorType.INVALID_SIGNATURE, "ES256 requires a 64-byte R || S signature")
+
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes.copyOf(64))
+ }
+}
diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/config/CheckoutVerificationConfig.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/config/CheckoutVerificationConfig.kt
new file mode 100644
index 00000000..de22167c
--- /dev/null
+++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/config/CheckoutVerificationConfig.kt
@@ -0,0 +1,35 @@
+package org.exploit.keeper.payment.config
+
+import com.nimbusds.jose.JWSAlgorithm
+import com.nimbusds.jose.jwk.Curve
+import com.nimbusds.jose.jwk.ECKey
+import com.nimbusds.jose.jwk.KeyOperation
+import com.nimbusds.jose.jwk.KeyUse
+import org.exploit.keeper.util.extension.signingTree
+import org.exploit.verdict.intent.payment.util.PaymentValues
+
+internal class CheckoutVerificationConfig(value: Any?, merchants: Map>) {
+ val keys: Map>
+
+ init {
+ val entries = PaymentValues.map(value, "checkoutKeys")
+ require(entries.isNotEmpty()) { "checkoutKeys must not be empty" }
+ keys = entries.entries.associate { (name, value) ->
+ val alias = PaymentValues.text(name, "checkoutKeys merchant")
+ require(merchants.containsKey(alias)) { "Unknown checkout merchant: $alias" }
+ require(value is List<*> && value.isNotEmpty()) { "Checkout keys must be a nonempty array" }
+ alias to value.map(::key)
+ }
+ }
+
+ private fun key(value: Any?): ECKey {
+ val key = ECKey.parse(signingTree(value).toString())
+ require(key.curve == Curve.P_256 && !key.isPrivate) { "Checkout keys must be public P-256 JWKs" }
+ require(key.algorithm == null || key.algorithm == JWSAlgorithm.ES256) { "Checkout key alg must be ES256" }
+ require(key.keyUse == null || key.keyUse == KeyUse.SIGNATURE) { "Checkout key use must be sig" }
+ require(key.keyOperations == null || key.keyOperations == setOf(KeyOperation.VERIFY)) { "Checkout key_ops must be verify" }
+ if (key.keyID != null) require(key.keyID.isNotBlank()) { "Checkout kid must not be blank" }
+ key.toECPublicKey()
+ return key
+ }
+}
diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/config/PaymentAuthorityConfig.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/config/PaymentAuthorityConfig.kt
new file mode 100644
index 00000000..a7e4cf88
--- /dev/null
+++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/config/PaymentAuthorityConfig.kt
@@ -0,0 +1,26 @@
+package org.exploit.keeper.payment.config
+
+import org.exploit.verdict.exception.IntentValidationException
+import org.exploit.verdict.intent.payment.config.PaymentIntentConfig
+import org.exploit.verdict.intent.payment.constant.PaymentRequestMode
+
+class PaymentAuthorityConfig(config: Map) {
+ val mode: PaymentRequestMode = config["mode"]?.let { value ->
+ PaymentRequestMode.entries.firstOrNull { it.name == value }
+ ?: throw IntentValidationException("invalid_payment_request_mode")
+ } ?: PaymentRequestMode.PAIRED
+
+ val intent: PaymentIntentConfig = PaymentIntentConfig.fromMap(config.filterKeys { it !in setOf("mode", "checkoutKeys") })
+
+ internal val checkoutVerification: CheckoutVerificationConfig? = if (config.containsKey("checkoutKeys"))
+ CheckoutVerificationConfig(config["checkoutKeys"], intent.merchants())
+ else null
+
+ init {
+ if (config.containsKey("mode") && config["mode"] == null)
+ throw IntentValidationException("invalid_payment_request_mode")
+
+ if (checkoutVerification != null && mode == PaymentRequestMode.PAYMENTS)
+ throw IntentValidationException("checkout_verification_requires_checkout_stage")
+ }
+}
diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/customizer/RegisterPaymentsLibrary.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/customizer/RegisterPaymentsLibrary.kt
new file mode 100644
index 00000000..1b47ff7d
--- /dev/null
+++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/customizer/RegisterPaymentsLibrary.kt
@@ -0,0 +1,15 @@
+package org.exploit.keeper.payment.customizer
+
+import jakarta.inject.Singleton
+import org.exploit.keeper.service.policy.PolicyEvaluatorCustomizer
+import org.exploit.verdict.PolicyEvaluatorBuilder
+import org.exploit.verdict.intent.payment.function.PaymentFunctions
+
+@Singleton
+class RegisterPaymentsLibrary: PolicyEvaluatorCustomizer {
+ override fun customize(builder: PolicyEvaluatorBuilder) {
+ val functions = PaymentFunctions()
+
+ builder.library(functions, functions)
+ }
+}
\ No newline at end of file
diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/deserializer/PaymentMandatesDeserializer.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/deserializer/PaymentMandatesDeserializer.kt
new file mode 100644
index 00000000..123b3dd0
--- /dev/null
+++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/deserializer/PaymentMandatesDeserializer.kt
@@ -0,0 +1,48 @@
+package org.exploit.keeper.payment.deserializer
+
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.JsonMappingException
+import com.fasterxml.jackson.databind.JsonNode
+import org.exploit.keeper.model.command.artifact.Artifact
+import org.exploit.keeper.payment.artifact.PaymentMandates
+import org.exploit.keeper.service.command.deserializer.ArtifactDeserializer
+import org.exploit.keeper.service.command.deserializer.ArtifactSchemas
+import org.exploit.keeper.util.extension.require
+import org.exploit.keeper.util.extension.requireOnly
+import org.exploit.keeper.util.extension.requireText
+import org.exploit.verdict.intent.payment.constant.PaymentRequestMode
+
+abstract class PaymentMandatesDeserializer(private val protocol: String): ArtifactDeserializer {
+ override fun type(): String {
+ return protocol
+ }
+
+ override fun schema(config: Map): Map = ArtifactSchemas.objectFields(
+ mapOf(
+ "mode" to mapOf("type" to "string", "enum" to PaymentRequestMode.entries.map { it.name }),
+ "signingInput" to mapOf("type" to "string"),
+ "disclosures" to mapOf("type" to "array", "minItems" to 1, "items" to mapOf("type" to "string", "minLength" to 1))
+ ),
+ listOf("mode", "signingInput", "disclosures")
+ )
+
+
+ override fun deserialize(node: JsonNode, ctx: DeserializationContext): Artifact {
+ node.requireOnly(ctx, "mode", "signingInput", "disclosures")
+
+ val modeName = node.requireText(ctx, "mode")
+ val mode = PaymentRequestMode.entries.firstOrNull { it.name == modeName }
+ ?: throw JsonMappingException.from(ctx, "Invalid payment request mode")
+
+ val disclosures = node.require(ctx, "disclosures")
+ if (!disclosures.isArray || disclosures.isEmpty || disclosures.any { !it.isTextual || it.textValue().isEmpty() })
+ throw JsonMappingException.from(ctx, "disclosures must be a nonempty array of strings")
+
+ return PaymentMandates(
+ protocol = protocol,
+ mode = mode,
+ signingInput = node.requireText(ctx, "signingInput"),
+ disclosures = disclosures.map { it.textValue() }
+ )
+ }
+}
diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/intent/PaymentIntentBuilder.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/intent/PaymentIntentBuilder.kt
new file mode 100644
index 00000000..98abc01b
--- /dev/null
+++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/intent/PaymentIntentBuilder.kt
@@ -0,0 +1,78 @@
+package org.exploit.keeper.payment.intent
+
+import dev.cel.common.types.CelType
+import org.exploit.keeper.constant.common.ErrorType
+import org.exploit.keeper.exception.TKeeperException
+import org.exploit.keeper.model.command.AuthorityCommand
+import org.exploit.keeper.model.command.Command
+import org.exploit.keeper.payment.artifact.PaymentMandates
+import org.exploit.keeper.payment.config.PaymentAuthorityConfig
+import org.exploit.keeper.payment.util.PaymentMandatesReader
+import org.exploit.keeper.payment.util.PaymentJws
+import org.exploit.keeper.payment.util.CheckoutSignatureVerifier
+import org.exploit.keeper.payment.util.PaymentCredentialTime
+import org.exploit.keeper.service.command.intent.IntentBuilder
+import org.exploit.verdict.authority.model.CompiledAuthority
+import org.exploit.verdict.exception.IntentValidationException
+import org.exploit.verdict.intent.Intent
+import org.exploit.verdict.intent.payment.model.MandateInput
+import org.exploit.verdict.intent.payment.model.ParsedMandate
+import org.exploit.verdict.intent.payment.util.PaymentRequestReader
+import org.exploit.verdict.intent.payment.PaymentRequest
+
+abstract class PaymentIntentBuilder: IntentBuilder {
+ override fun validateConfig(config: Map) {
+ PaymentAuthorityConfig(config)
+ }
+
+ override fun policySchema(config: Map): Map = PaymentRequest.policySchema()
+
+ override fun buildIntent(command: AuthorityCommand, compiled: CompiledAuthority): PaymentRequest {
+ val artifact = artifact(command)
+ val config = PaymentAuthorityConfig(compiled.authority.config)
+
+ if (artifact.mode != config.mode)
+ throw IntentValidationException("payment_request_mode_mismatch")
+
+ return request(artifact, config)
+ }
+
+ fun artifact(command: Command): PaymentMandates {
+ val artifact = command.artifact as? PaymentMandates
+ ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Not payment mandates")
+
+ if (command.type != type() || artifact.type() != type())
+ throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Payment protocol mismatch")
+
+ return artifact
+ }
+
+ private fun request(artifact: PaymentMandates, config: PaymentAuthorityConfig): PaymentRequest {
+ return try {
+ val content = PaymentMandatesReader.read(artifact)
+ PaymentCredentialTime.validate(content)
+ config.checkoutVerification?.let { CheckoutSignatureVerifier.verify(content.mandates, it, config.intent.merchants()) }
+
+ PaymentRequest.fromParsed(type().substringBefore('.'), artifact.mode, parse(content.mandates), config.intent)
+ .withContext(mapOf("header" to PaymentJws.map(content.header), "payload" to PaymentJws.map(content.payload)))
+ } catch (e: IntentValidationException) {
+ throw e
+ } catch (e: Exception) {
+ throw IntentValidationException("invalid_payment_request", e)
+ }
+ }
+
+ fun validate(artifact: PaymentMandates) {
+ try {
+ PaymentRequestReader.actions(parse(PaymentMandatesReader.read(artifact).mandates), type().substringBefore('.'), artifact.mode)
+ } catch (e: IllegalArgumentException) {
+ throw IntentValidationException("invalid_payment_request", e)
+ }
+ }
+
+ private fun parse(inputs: List): List {
+ return inputs.map { ParsedMandate(it, parseMandate(it).variables()) }
+ }
+
+ protected abstract fun parseMandate(input: MandateInput): Intent
+}
diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/model/PaymentCredentialContent.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/model/PaymentCredentialContent.kt
new file mode 100644
index 00000000..438a594e
--- /dev/null
+++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/model/PaymentCredentialContent.kt
@@ -0,0 +1,10 @@
+package org.exploit.keeper.payment.model
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.exploit.verdict.intent.payment.model.MandateInput
+
+internal data class PaymentCredentialContent(
+ val header: JsonNode,
+ val payload: JsonNode,
+ val mandates: List
+)
diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/outcome/PaymentCredential.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/outcome/PaymentCredential.kt
new file mode 100644
index 00000000..d9424435
--- /dev/null
+++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/outcome/PaymentCredential.kt
@@ -0,0 +1,11 @@
+package org.exploit.keeper.payment.outcome
+
+import org.exploit.keeper.model.capability.HasImposters
+import org.exploit.keeper.model.outcome.Outcome
+
+data class PaymentCredential(
+ val type: String,
+ val credential: String,
+ val generation: Int,
+ override val imposters: List
+): HasImposters, Outcome
diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/CheckoutSignatureVerifier.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/CheckoutSignatureVerifier.kt
new file mode 100644
index 00000000..62bbf866
--- /dev/null
+++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/CheckoutSignatureVerifier.kt
@@ -0,0 +1,25 @@
+package org.exploit.keeper.payment.util
+
+import com.nimbusds.jose.JWSAlgorithm
+import com.nimbusds.jose.JWSObject
+import com.nimbusds.jose.crypto.ECDSAVerifier
+import org.exploit.keeper.payment.config.CheckoutVerificationConfig
+import org.exploit.verdict.intent.payment.model.MandateInput
+
+internal object CheckoutSignatureVerifier {
+ fun verify(inputs: List, config: CheckoutVerificationConfig, merchants: Map>) {
+ for (input in inputs) {
+ val checkout = input.checkoutJson() ?: continue
+ val merchant = checkout.path("merchant")
+ val aliases = merchants.filter { (_, identity) ->
+ identity.all { (name, value) -> merchant.path(name).isTextual && merchant.path(name).textValue() == value }
+ }.keys
+ val jwt = JWSObject.parse(PaymentJws.text(input.json(), "checkout_jwt"))
+ require(jwt.header.algorithm == JWSAlgorithm.ES256) { "Only ES256 checkout verification is supported" }
+ val keys = aliases.flatMap { config.keys[it].orEmpty() }
+ .filter { jwt.header.keyID == null || jwt.header.keyID == it.keyID }
+
+ require(keys.isNotEmpty() && keys.any { jwt.verify(ECDSAVerifier(it)) }) { "Untrusted checkout signature" }
+ }
+ }
+}
diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/MandateDisclosures.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/MandateDisclosures.kt
new file mode 100644
index 00000000..79bb2ad4
--- /dev/null
+++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/MandateDisclosures.kt
@@ -0,0 +1,77 @@
+package org.exploit.keeper.payment.util
+
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.node.ArrayNode
+import com.fasterxml.jackson.databind.node.ObjectNode
+
+internal class MandateDisclosures(encoded: List) {
+ private val disclosures = encoded.associateBy(PaymentJws::digest)
+ private val used = mutableSetOf()
+
+ init {
+ require(disclosures.size == encoded.size) { "Duplicate disclosures" }
+ }
+
+ fun mandate(reference: JsonNode): Pair {
+ require(reference.isObject && reference.size() == 1) { "Mandates must be array disclosures" }
+ val hash = PaymentJws.text(reference, "...")
+ return hash to resolve(disclosure(hash, 2)[1], 0)
+ }
+
+ fun requireComplete() {
+ require(used.size == disclosures.size) { "Unreferenced disclosure" }
+ }
+
+ private fun disclosure(hash: String, size: Int): JsonNode {
+ require(used.add(hash)) { "Repeated disclosure reference" }
+ val encoded = requireNotNull(disclosures[hash]) { "Missing disclosure" }
+ val value = PaymentJws.json(encoded)
+
+ require(value.isArray && value.size() == size && value[0].isTextual && value[0].textValue().isNotEmpty()) {
+ "Invalid disclosure"
+ }
+ return value
+ }
+
+ private fun resolve(node: JsonNode, depth: Int): JsonNode {
+ require(depth < 64) { "Disclosure nesting is too deep" }
+ return when {
+ node.isArray -> resolveArray(node as ArrayNode, depth)
+ node.isObject -> resolveObject(node as ObjectNode, depth)
+ else -> node
+ }
+ }
+
+ private fun resolveArray(node: ArrayNode, depth: Int): ArrayNode {
+ val result = node.arrayNode()
+ for (entry in node) {
+ val value = if (entry.isObject && entry.has("...")) {
+ require(entry.size() == 1) { "Invalid array disclosure reference" }
+ disclosure(PaymentJws.text(entry, "..."), 2)[1]
+ } else entry
+
+ result.add(resolve(value, depth + 1))
+ }
+ return result
+ }
+
+ private fun resolveObject(node: ObjectNode, depth: Int): ObjectNode {
+ require(!node.has("...") && !node.has("_sd_alg")) { "Invalid nested disclosure metadata" }
+ val result = node.objectNode()
+ node.properties().filter { it.key != "_sd" }.forEach { (name, value) ->
+ result.set(name, resolve(value, depth + 1))
+ }
+
+ val hashes = node.get("_sd") ?: return result
+ require(hashes.isArray) { "Invalid object disclosure references" }
+ for (hash in hashes) {
+ require(hash.isTextual) { "Invalid disclosure hash" }
+ val value = disclosure(hash.textValue(), 3)
+ require(value[1].isTextual) { "Invalid disclosed claim name" }
+ val name = value[1].textValue()
+ require(name !in setOf("_sd", "_sd_alg", "...") && !result.has(name)) { "Duplicate or reserved claim" }
+ result.set(name, resolve(value[2], depth + 1))
+ }
+ return result
+ }
+}
diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/PaymentCredentialClaims.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/PaymentCredentialClaims.kt
new file mode 100644
index 00000000..887159a3
--- /dev/null
+++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/PaymentCredentialClaims.kt
@@ -0,0 +1,114 @@
+package org.exploit.keeper.payment.util
+
+import com.fasterxml.jackson.databind.JsonNode
+import com.nimbusds.jose.jwk.Curve
+import com.nimbusds.jose.jwk.ECKey
+import org.exploit.keeper.payment.artifact.PaymentMandates
+import org.exploit.verdict.intent.payment.constant.PaymentRequestMode
+import java.net.URI
+
+internal object PaymentCredentialClaims {
+ private val claims = setOf("iss", "sub", "vct", "iat", "exp", "nbf", "aud", "nonce", "jti",
+ "sd_hash", "issuer_jwt_hash", "_sd_alg", "_sd", "delegate_payload")
+
+ fun validate(artifact: PaymentMandates, header: JsonNode, payload: JsonNode, mandates: List) {
+ require(header.isObject && header.properties().all { it.key in setOf("alg", "typ", "kid") }) { "Unsupported JWS header" }
+ require(PaymentJws.text(header, "alg") == "ES256") { "Only ES256 is supported" }
+ if (header.has("kid")) PaymentJws.text(header, "kid")
+ require(payload.properties().all { it.key in claims }) { "Unsupported credential claim" }
+ validateOptionalClaims(payload)
+
+ if (artifact.type() == "mcintent.mandate") mc(artifact, header, payload, mandates)
+ else ap2(header, payload, mandates)
+ }
+
+ private fun validateOptionalClaims(payload: JsonNode) {
+ for (name in listOf("sub", "vct", "aud", "nonce", "jti")) {
+ if (payload.has(name)) PaymentJws.text(payload, name)
+ }
+ if (payload.has("iss")) uri(payload, "iss")
+ for (name in listOf("iat", "exp", "nbf")) {
+ if (payload.has(name)) timestamp(payload, name)
+ }
+ if (payload.has("exp")) {
+ val expires = timestamp(payload, "exp")
+ for (name in listOf("iat", "nbf")) {
+ if (payload.has(name)) require(expires > timestamp(payload, name)) { "Invalid validity interval" }
+ }
+ }
+ }
+
+ private fun ap2(header: JsonNode, payload: JsonNode, mandates: List) {
+ val type = PaymentJws.text(header, "typ")
+ val delegated = type in setOf("kb+sd-jwt", "kb+sd-jwt+kb")
+ require(delegated || type == "sd+jwt" || type.endsWith("+sd-jwt")) { "Unsupported AP2 JWT type" }
+ if (delegated) {
+ binding(payload, false)
+ val open = mandates.map(::isOpen)
+ require(if (type == "kb+sd-jwt+kb") open.all { it } else open.none { it }) { "AP2 delegation type mismatch" }
+ } else {
+ require(!payload.has("sd_hash") && !payload.has("issuer_jwt_hash")) { "Root mandate cannot bind a parent" }
+ if (type == "dc+sd-jwt") {
+ uri(payload, "iss")
+ PaymentJws.text(payload, "vct")
+ }
+ }
+ mandates.filter(::isOpen).forEach { agentKey(it, false) }
+ }
+
+ private fun mc(artifact: PaymentMandates, header: JsonNode, payload: JsonNode, mandates: List) {
+ val type = PaymentJws.text(header, "typ")
+ require(type in setOf("kb-sd-jwt", "kb-sd-jwt+kb")) { "Unsupported VI JWT type" }
+ require(payload.path("_sd_alg").asText() == "sha-256" && payload.has("_sd")) { "Missing SD metadata" }
+ binding(payload, true)
+ uri(payload, "aud")
+
+ val issued = timestamp(payload, "iat")
+ val expires = timestamp(payload, "exp")
+ val open = mandates.map(::isOpen)
+ require(open.all { it } || open.none { it }) { "Mixed immediate and autonomous mandates" }
+ require((type == "kb-sd-jwt+kb") == open.all { it }) { "Invalid VI credential mode" }
+ mandates.filter(::isOpen).forEach { agentKey(it, true) }
+
+ if (artifact.mode != PaymentRequestMode.PAIRED) {
+ require(open.none { it } && mandates.size == 1) { "L3 requires one final mandate" }
+ PaymentJws.text(header, "kid")
+ require(expires - issued <= 3600) { "L3 lifetime exceeds one hour" }
+ } else if (open.none { it }) {
+ require(expires - issued <= 900) { "VI Immediate lifetime exceeds fifteen minutes" }
+ }
+ }
+
+ private fun binding(payload: JsonNode, mc: Boolean) {
+ PaymentJws.text(payload, "nonce")
+ PaymentJws.text(payload, "aud")
+ timestamp(payload, "iat")
+ val hash = if (mc) {
+ require(!payload.has("issuer_jwt_hash")) { "VI requires sd_hash" }
+ "sd_hash"
+ } else {
+ require(payload.has("sd_hash") != payload.has("issuer_jwt_hash")) { "Exactly one parent hash is required" }
+ if (payload.has("sd_hash")) "sd_hash" else "issuer_jwt_hash"
+ }
+ require(PaymentJws.decode(PaymentJws.text(payload, hash)).size == 32) { "Invalid parent hash" }
+ }
+
+ private fun agentKey(mandate: JsonNode, requireKid: Boolean) {
+ val key = ECKey.parse(mandate.path("cnf").path("jwk").toString())
+ require(key.curve == Curve.P_256 && !key.isPrivate) { "Only public P-256 agent JWKs are supported" }
+ if (requireKid) require(!key.keyID.isNullOrBlank()) { "Missing agent kid" }
+ key.toECPublicKey()
+ }
+
+ private fun isOpen(mandate: JsonNode): Boolean = PaymentJws.text(mandate, "vct").contains(".open.")
+
+ private fun uri(payload: JsonNode, name: String) {
+ require(URI(PaymentJws.text(payload, name)).isAbsolute) { "Invalid $name URI" }
+ }
+
+ private fun timestamp(payload: JsonNode, name: String): Long {
+ val value = payload.get(name)
+ require(value != null && value.isIntegralNumber && value.canConvertToLong() && value.longValue() >= 0) { "Invalid $name" }
+ return value.longValue()
+ }
+}
diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/PaymentCredentialTime.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/PaymentCredentialTime.kt
new file mode 100644
index 00000000..98c5e309
--- /dev/null
+++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/PaymentCredentialTime.kt
@@ -0,0 +1,28 @@
+package org.exploit.keeper.payment.util
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.exploit.keeper.payment.model.PaymentCredentialContent
+import java.time.Instant
+
+internal object PaymentCredentialTime {
+ fun validate(content: PaymentCredentialContent) {
+ val now = Instant.now().epochSecond
+ validate(content.payload, now)
+ content.mandates.forEach {
+ validate(it.json(), now)
+ it.checkoutJson()?.let { checkout -> validate(checkout, now) }
+ }
+ }
+
+ private fun validate(claims: JsonNode, now: Long) {
+ for (name in listOf("iat", "nbf")) {
+ val value = claims.get(name) ?: continue
+ require(value.isIntegralNumber && value.canConvertToLong() && value.longValue() in 0..(now + 300)) { "Credential is not yet valid" }
+ }
+ val expires = claims.get("exp") ?: return
+ require(expires.isIntegralNumber && expires.canConvertToLong() && expires.longValue() > now - 300) { "Credential has expired" }
+ for (name in listOf("iat", "nbf")) {
+ if (claims.has(name)) require(expires.longValue() > claims.get(name).longValue()) { "Invalid validity interval" }
+ }
+ }
+}
diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/PaymentJws.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/PaymentJws.kt
new file mode 100644
index 00000000..65a292e9
--- /dev/null
+++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/PaymentJws.kt
@@ -0,0 +1,51 @@
+package org.exploit.keeper.payment.util
+
+import com.fasterxml.jackson.core.StreamReadFeature
+import com.fasterxml.jackson.core.type.TypeReference
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.json.JsonMapper
+import org.exploit.keeper.constant.signature.HashMethod
+import java.nio.ByteBuffer
+import java.nio.charset.StandardCharsets.US_ASCII
+import java.nio.charset.StandardCharsets.UTF_8
+import java.util.Base64
+
+internal object PaymentJws {
+ private val mapper = JsonMapper.builder()
+ .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
+ .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
+ .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
+ .enable(DeserializationFeature.USE_LONG_FOR_INTS)
+ .build()
+
+ fun decode(encoded: String): ByteArray {
+ require(encoded.isNotEmpty() && encoded.all { it in 'A'..'Z' || it in 'a'..'z' || it in '0'..'9' || it == '-' || it == '_' }) {
+ "Invalid base64url"
+ }
+
+ val bytes = Base64.getUrlDecoder().decode(encoded)
+ require(Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) == encoded) { "Noncanonical base64url" }
+ return bytes
+ }
+
+ fun json(encoded: String): JsonNode = mapper.readTree(UTF_8.newDecoder().decode(ByteBuffer.wrap(decode(encoded))).toString())
+
+ fun map(value: JsonNode): Map = mapper.convertValue(value, object: TypeReference