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 logo](assets/tk-bg.png) -
- # 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>() {}) + + fun digest(encoded: String): String = Base64.getUrlEncoder().withoutPadding() + .encodeToString(HashMethod.SHA256.process(encoded.toByteArray(US_ASCII))) + + fun parts(value: String, count: Int): List { + val parts = value.split('.') + require(parts.size == count) { "Invalid compact JWS" } + parts.forEach(::decode) + return parts + } + + fun text(node: JsonNode, name: String): String { + val value = node.get(name) + require(value != null && value.isTextual && value.textValue().isNotBlank()) { "Invalid $name" } + return value.textValue() + } +} diff --git a/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/PaymentMandatesReader.kt b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/PaymentMandatesReader.kt new file mode 100644 index 00000000..632b1f3f --- /dev/null +++ b/features/agentic-payments/src/main/kotlin/org/exploit/keeper/payment/util/PaymentMandatesReader.kt @@ -0,0 +1,68 @@ +package org.exploit.keeper.payment.util + +import com.fasterxml.jackson.databind.JsonNode +import org.exploit.keeper.payment.artifact.PaymentMandates +import org.exploit.keeper.payment.model.PaymentCredentialContent +import org.exploit.verdict.exception.IntentValidationException +import org.exploit.verdict.intent.payment.model.MandateInput + +internal object PaymentMandatesReader { + fun read(artifact: PaymentMandates): PaymentCredentialContent { + return try { + readInput(artifact) + } catch (e: Exception) { + throw IntentValidationException("invalid_payment_signing_input", e) + } + } + + private fun readInput(artifact: PaymentMandates): PaymentCredentialContent { + val parts = PaymentJws.parts(artifact.signingInput, 2) + val header = PaymentJws.json(parts[0]) + val payload = PaymentJws.json(parts[1]) + require(payload.isObject) { "Invalid JWT payload" } + require(!payload.has("_sd_alg") || PaymentJws.text(payload, "_sd_alg") == "sha-256") { "Unsupported disclosure hash" } + + val references = requireNotNull(payload.get("delegate_payload")) { "Missing delegate_payload" } + require(references.isArray && !references.isEmpty) { "Invalid delegate_payload" } + val disclosures = MandateDisclosures(artifact.disclosures) + val mandates = references.map(disclosures::mandate) + disclosures.requireComplete() + PaymentCredentialClaims.validate(artifact, header, payload, mandates.map { it.second }) + validateIndex(payload, mandates.map { it.first }) + + return PaymentCredentialContent(header, payload, mandates.map { (hash, mandate) -> input(hash, mandate) }) + } + + private fun validateIndex(payload: JsonNode, hashes: List) { + val index = payload.get("_sd") ?: return + require(index.isArray && index.all { it.isTextual } && index.size() == hashes.size && index.map { it.textValue() }.toSet() == hashes.toSet()) { + "Unsupported top-level disclosures" + } + } + + private fun input(hash: String, mandate: JsonNode): MandateInput { + val type = PaymentJws.text(mandate, "vct") + if (type == "mandate.payment.1") + require(PaymentJws.decode(PaymentJws.text(mandate, "transaction_id")).size == 32) { "Invalid transaction hash" } + + val checkout = if (type == "mandate.checkout.1") checkout(mandate) else null + return MandateInput(mandate, checkout, if (type == "mandate.checkout.open.1") hash else null) + } + + private fun checkout(mandate: JsonNode): JsonNode { + val jwt = PaymentJws.text(mandate, "checkout_jwt") + val parts = PaymentJws.parts(jwt, 3) + require(PaymentJws.digest(jwt) == PaymentJws.text(mandate, "checkout_hash")) { "Checkout hash mismatch" } + val header = PaymentJws.json(parts[0]) + require(header.isObject && header.properties().all { it.key in setOf("alg", "typ", "kid") } && + PaymentJws.text(header, "alg") == "ES256" && PaymentJws.decode(parts[2]).size == 64) { + "Unsupported checkout JWT" + } + for (name in listOf("typ", "kid")) { + if (header.has(name)) PaymentJws.text(header, name) + } + val payload = PaymentJws.json(parts[1]) + require(payload.isObject) { "Invalid checkout payload" } + return payload + } +} diff --git a/features/agentic-payments/src/testFixtures/kotlin/org/exploit/keeper/payment/PaymentSupportTest.kt b/features/agentic-payments/src/testFixtures/kotlin/org/exploit/keeper/payment/PaymentSupportTest.kt new file mode 100644 index 00000000..2b9ee62e --- /dev/null +++ b/features/agentic-payments/src/testFixtures/kotlin/org/exploit/keeper/payment/PaymentSupportTest.kt @@ -0,0 +1,671 @@ +package org.exploit.keeper.payment + +import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import dev.cel.common.types.SimpleType +import io.micronaut.context.BeanContext +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.command.AuthorityCommand +import org.exploit.keeper.model.command.VerificationCommand +import org.exploit.keeper.payment.artifact.PaymentMandates +import org.exploit.keeper.payment.deserializer.PaymentMandatesDeserializer +import org.exploit.keeper.payment.intent.PaymentIntentBuilder +import org.exploit.keeper.service.command.intent.CommonIntentBuilder +import org.exploit.keeper.util.jackson.JacksonCustomizer +import org.exploit.test.authority.AuthorityFixture +import org.exploit.test.authority.PaymentMandate +import org.exploit.test.authority.PaymentPayloads +import org.exploit.tkeeper.sdk.model.command.Command +import org.exploit.tkeeper.sdk.model.command.artifact.Artifact +import org.exploit.tkeeper.sdk.model.command.artifact.PaymentRequestMode +import org.exploit.tkeeper.sdk.util.TKeeperJackson +import org.exploit.verdict.PolicyEvaluator +import org.exploit.verdict.authority.artifact.AuthorityArtifact +import org.exploit.verdict.authority.loader.AuthorityLoader +import org.exploit.verdict.authority.model.Authority +import org.exploit.verdict.authority.model.CompiledAuthority +import org.exploit.verdict.constant.Verdict +import org.exploit.verdict.exception.PolicyCompilationException +import org.exploit.verdict.intent.payment.function.PaymentFunctions +import org.exploit.verdict.model.Policy +import org.exploit.verdict.model.Rule +import org.exploit.verdict.model.approval.ApprovalPolicy +import org.exploit.verdict.model.approval.Approver +import org.junit.jupiter.api.Test +import org.msgpack.jackson.dataformat.MessagePackMapper +import kotlin.test.* +import org.exploit.tkeeper.sdk.model.command.VerificationCommand as SdkVerificationCommand +import org.exploit.verdict.intent.payment.constant.PaymentRequestMode as KeeperPaymentRequestMode + +abstract class PaymentSupportTest { + protected abstract val builder: PaymentIntentBuilder + protected abstract val deserializer: PaymentMandatesDeserializer + protected abstract val composer: org.exploit.keeper.payment.compose.PaymentComposer + protected abstract val fixture: AuthorityFixture + + protected abstract fun artifact(mandates: List, mode: PaymentRequestMode): Artifact + + private val evaluator = with(PaymentFunctions()) { + PolicyEvaluator.builder() + .standardLibraries(true) + .library(this, this) + .build() + } + + @Test + fun `Micronaut registers payment components and one policy evaluator`() { + BeanContext.run().use { context -> + val evaluators = context.getBeansOfType(PolicyEvaluator::class.java) + assertEquals(1, evaluators.size) + assertEquals(builder.type(), context.getBean(builder.javaClass).type()) + assertEquals(builder.type(), context.getBean(deserializer.javaClass).type()) + assertEquals(builder.type(), context.getBean(composer.javaClass).type) + assertEquals( + 1, context.getBeansOfType(org.exploit.keeper.service.compose.spi.Composer::class.java) + .count { it.type == builder.type() }) + + val authority = compiled() + val policy = evaluators.single() + .compileStrict(authority.authority.policy, builder.policySchema(authority.authority.config)) + assertEquals( + Verdict.ALLOW, + evaluators.single().evaluate(policy, builder.buildIntent(command(), authority)).decision() + ) + } + } + + @Test + fun `SDK JSON and peer messagepack preserve the complete signing request`() { + val sdk = Command.of(fixture.id, artifact(PaymentPayloads.pair(), PaymentRequestMode.PAIRED)) + val json = mapper(jacksonObjectMapper()) + val command = json.readValue(json.writeValueAsBytes(sdk), AuthorityCommand::class.java) + val messagepack = mapper(MessagePackMapper()) + val peer = messagepack.readValue(messagepack.writeValueAsBytes(command), AuthorityCommand::class.java) + + assertContentEquals(TKeeperJackson.signingBytes(sdk.signingNode()), command.bytes()) + assertContentEquals(command.bytes(), peer.bytes()) + assertEquals(builder.artifact(command).signingInput, builder.artifact(peer).signingInput) + builder.validate(builder.artifact(peer)) + + val verify = json.readValue( + json.writeValueAsBytes(SdkVerificationCommand.from(sdk)), + VerificationCommand::class.java + ) + assertEquals(builder.artifact(command).signingInput, builder.artifact(verify).signingInput) + builder.validate(builder.artifact(verify)) + } + + @Test + fun `strict schema matches runtime roots and rejects typos`() { + val compiled = compiled() + val request = builder.buildIntent(command(), compiled) + val schema = builder.policySchema(compiled.authority.config) + + assertEquals(request.variables().keys, schema.keys) + assertTrue(schema.values.all { it == SimpleType.DYN }) + assertFailsWith { + evaluator.compileStrict( + Policy.denyByDefault("typo").allow("r") { it.where("paymant.amountAtMost('1', 'USD')") }.build(), schema + ) + } + } + + @Test + fun `policy checks every action identities amount and total`() { + assertEquals(Verdict.ALLOW, evaluate(PaymentPayloads.pair(amount = 10000))) + assertEquals(Verdict.ALLOW, evaluate(PaymentPayloads.pair() + PaymentPayloads.pair("order-2"))) + assertEquals(Verdict.DENY, evaluate(PaymentPayloads.pair(amount = 10001))) + assertEquals( + Verdict.DENY, + evaluate(PaymentPayloads.pair(amount = 8000) + PaymentPayloads.pair("order-2", 8000)) + ) + + for ((from, to) in listOf( + "card-1" to "card-2", + "https://office.example" to "https://attacker.example", + "USD" to "EUR" + )) { + val bad = PaymentPayloads.pair("order-2").toMutableList() + bad[1] = PaymentMandate.of(bad[1].json().replace(from, to)) + assertEquals(Verdict.DENY, evaluate(PaymentPayloads.pair() + bad)) + } + } + + @Test + fun `all action approvals survive and a denied action overrides them`() { + val approvers = mapOf( + "alice" to Approver("ED25519", "YWxpY2U=", emptyMap()), + "bob" to Approver("ED25519", "Ym9i", emptyMap()) + ) + val policy = Policy( + "approvals", Verdict.DENY, approvers, emptyMap(), listOf( + Rule( + "small", + listOf("payment.amountAtMost('50', 'USD')"), + emptyList(), + ApprovalPolicy(1, listOf("alice")) + ), + Rule( + "large", + listOf("payment.amountAtLeast('50.01', 'USD')"), + emptyList(), + ApprovalPolicy(1, listOf("bob")) + ) + ), listOf(Rule("ceiling", listOf("!payment.amountAtMost('100', 'USD')"), emptyList())) + ) + val compiled = compiled() + val approvalPolicy = evaluator.compileStrict(policy, builder.policySchema(compiled.authority.config)) + val inputs = PaymentPayloads.pair(amount = 100) + PaymentPayloads.pair("order-2", 6000) + val request = builder.buildIntent(command(inputs), compiled) + val result = evaluator.evaluate(approvalPolicy, request) + + assertEquals(Verdict.ALLOW_WITH_REQUIREMENTS, result.decision()) + assertEquals(listOf("alice", "bob"), result.approvalRequirements().flatMap { it.approvers().keys }) + + val denied = builder.buildIntent( + command(PaymentPayloads.pair(amount = 100) + PaymentPayloads.pair("order-2", 10001)), + compiled + ) + assertEquals(Verdict.DENY, evaluator.evaluate(approvalPolicy, denied).decision()) + } + + @Test + fun `authority identities are isolated and mandate inputs are immutable`() { + val original = compiled() + val restricted = original.copyConfig( + original.authority.config + + ("methods" to mapOf("companyCard" to mapOf("id" to "other-card", "type" to "card"))) + ) + val command = command() + + assertEquals( + Verdict.ALLOW, + evaluator.evaluate(original.policy, builder.buildIntent(command, original)).decision() + ) + assertEquals( + Verdict.DENY, + evaluator.evaluate(original.policy, builder.buildIntent(command, restricted)).decision() + ) + assertFailsWith { (builder.artifact(command).disclosures as MutableList).clear() } + } + + @Test + fun `delegation remains visible and cannot pass a purchase policy`() { + val inputs = PaymentPayloads.delegation(builder.type()) + val request = builder.buildIntent(command(inputs), compiled()) + + assertEquals(listOf("delegation"), request.actions().map { it.kind() }) + assertEquals(Verdict.DENY, evaluate(inputs)) + assertEquals(2, request.inputs().size) + builder.validate(builder.artifact(command(inputs))) + } + + @Test + fun `incomplete duplicate malformed and unresolved requests fail closed on sign and verify`() { + val pair = PaymentPayloads.pair() + val cases = listOf( + emptyList(), listOf(pair[0]), listOf(pair[1]), pair + pair, + listOf(pair[0], PaymentPayloads.payment("other")), + pair + PaymentMandate.of("{}"), + listOf(pair[0], PaymentMandate.of(pair[1].json().replace("7500", "-1"))), + listOf(pair[0], PaymentMandate.of(pair[1].json().replace("7500", "1.5"))), + listOf(pair[0], PaymentMandate.of(pair[1].json().replace("7500", "7500,\"amount\":1"))) + ) + for (inputs in cases) { + val wire = PaymentPayloads.signingInput(builder.type(), inputs) + val artifact = + PaymentMandates(builder.type(), KeeperPaymentRequestMode.PAIRED, wire.value, wire.disclosures) + val c = AuthorityCommand.of(fixture.id, artifact) + + assertEquals(ErrorType.INVALID_INTENT, assertFailsWith { + CommonIntentBuilder(listOf(builder)).build(c, compiled()) + }.errorType) + assertFailsWith { + builder.validate(builder.artifact(VerificationCommand(c.type, c.artifact))) + } + } + } + + @Test + fun `signing stage must match authority configuration`() { + val payment = command(listOf(PaymentPayloads.payment()), PaymentRequestMode.PAYMENTS) + assertEquals(ErrorType.INVALID_INTENT, assertFailsWith { + CommonIntentBuilder(listOf(builder)).build(payment, compiled()) + }.errorType) + + val payments = compiled().let { original -> + val a = original.authority + original.copyConfig(a.config + ("mode" to "PAYMENTS")) + } + assertEquals("payment", builder.buildIntent(payment, payments).actions().single().kind()) + val checkouts = compiled().let { it.copyConfig(it.authority.config + ("mode" to "CHECKOUTS")) } + assertEquals( + "checkout", + builder.buildIntent(command(listOf(PaymentPayloads.pair()[0]), PaymentRequestMode.CHECKOUTS), checkouts) + .actions().single().kind() + ) + + for (config in listOf( + mapOf("mode" to "AUTO"), + mapOf("mode" to null), + mapOf("mode" to 1), + mapOf("unknown" to true) + )) { + assertFails { builder.validateConfig(config) } + } + } + + @Test + fun `wire rejects unknown missing and mistyped artifact fields`() { + val json = mapper(jacksonObjectMapper()) + val valid = json.valueToTree( + Command.of( + fixture.id, + artifact(PaymentPayloads.pair(), PaymentRequestMode.PAIRED) + ) + ) + val artifact = valid.get("artifact") as ObjectNode + val badArtifacts = listOf( + artifact.deepCopy().put("extra", true), artifact.deepCopy().put("mode", "AUTO"), + artifact.deepCopy().put("disclosures", "[]"), artifact.deepCopy().put("signingInput", 12), + artifact.deepCopy().apply { remove("mode") }, artifact.deepCopy().apply { putArray("disclosures") }, + artifact.deepCopy().apply { putArray("disclosures").add(42) }, + artifact.deepCopy().put("checkoutJson", "{}"), artifact.deepCopy().put("hash", "NONE") + ) + for (bad in badArtifacts) { + val node = valid.deepCopy().set("artifact", bad) + assertFailsWith { json.treeToValue(node, AuthorityCommand::class.java) } + } + } + + @Test + fun `protocol mismatch is rejected before policy or materialization`() { + val original = command() + val wrong = AuthorityCommand("wrong", fixture.id, original.artifact) + assertEquals( + ErrorType.INVALID_AUTHORITY_ARTIFACT, + assertFailsWith { builder.buildIntent(wrong, compiled()) }.errorType + ) + } + + @Test + fun `policy uses only disclosures committed by the signed payload`() { + val original = builder.artifact(command()) + val other = builder.artifact(command(PaymentPayloads.pair(amount = 100))) + val variants = listOf( + wire(original, disclosures = other.disclosures), + wire(original, disclosures = original.disclosures.dropLast(1)), + wire(original, disclosures = original.disclosures + other.disclosures), + wire(original, disclosures = original.disclosures + original.disclosures[0]) + ) + variants.forEach(::reject) + + val reordered = wire(original, disclosures = original.disclosures.reversed()) + assertEquals( + Verdict.ALLOW, evaluator.evaluate( + compiled().policy, + builder.buildIntent(AuthorityCommand.of(fixture.id, reordered), compiled()) + ).decision() + ) + } + + @Test + fun `any changed disclosure fails its signed commitment`() { + val original = builder.artifact(command()) + val random = kotlin.random.Random(21893) + repeat(100) { + val index = random.nextInt(original.disclosures.size) + val encoded = original.disclosures[index] + val offset = random.nextInt(encoded.length) + val replacement = if (encoded[offset] == 'A') 'B' else 'A' + val changed = encoded.replaceRange(offset, offset + 1, replacement.toString()) + val disclosures = original.disclosures.toMutableList().apply { this[index] = changed } + reject(wire(original, disclosures = disclosures)) + } + } + + @Test + fun `JWS rejects ambiguous encoding unsupported algorithms and hidden payloads`() { + val original = builder.artifact(command()) + val parts = original.signingInput.split('.') + val headers = listOf( + """{"alg":"none","typ":"kb-sd-jwt"}""", + """{"alg":"ES256K","typ":"kb-sd-jwt"}""", + """{"alg":"EdDSA","typ":"kb-sd-jwt"}""", + """{"alg":"ES256","typ":"kb-sd-jwt","b64":false}""", + """{"alg":"ES256","typ":"kb-sd-jwt","crit":["exp"]}""", + """{"alg":"ES256","alg":"ES256","typ":"kb-sd-jwt"}""", + """{"alg":"ES256","typ":"JWT"}""", + """{"alg":"ES256","typ":"kb-sd-jwt"} {}""" + ) + headers.forEach { reject(wire(original, PaymentPayloads.encoded(it) + "." + parts[1])) } + for (input in listOf( + original.signingInput + ".AA", parts[0] + "=." + parts[1], + parts[0] + ".", "." + parts[1], parts[0] + ".__8" + )) { + reject(wire(original, input)) + } + + val payload = decode(parts[1]) + val variants = listOf( + payload.deepCopy().put("_sd_alg", "sha-512"), + payload.deepCopy().putNull("_sd_alg"), + payload.deepCopy().apply { putArray("delegate_payload") }, + payload.deepCopy() + .apply { (get("delegate_payload") as com.fasterxml.jackson.databind.node.ArrayNode).add(get("delegate_payload")[0]) }, + payload.deepCopy() + .apply { (get("delegate_payload")[0] as ObjectNode).put("...", PaymentPayloads.hash("absent")) }, + payload.deepCopy().apply { putArray("_sd").add(PaymentPayloads.hash("unseen")) } + ) + variants.forEach { reject(wire(original, parts[0] + "." + PaymentPayloads.encoded(it.toString()))) } + val duplicate = payload.toString().replaceFirst("\"iat\":", "\"iat\":0,\"iat\":") + reject(wire(original, parts[0] + "." + PaymentPayloads.encoded(duplicate))) + } + + @Test + fun `checkout hash is derived from the compact merchant JWT`() { + val pair = PaymentPayloads.pair() + val checkout = jacksonObjectMapper().readTree(pair[0].json()) as ObjectNode + checkout.put("checkout_hash", PaymentPayloads.hash("unrelated")) + val inputs = + listOf(PaymentMandate.of(checkout.toString()), PaymentPayloads.payment(PaymentPayloads.hash("unrelated"))) + reject(builder.artifact(command(inputs))) + + val jwt = jacksonObjectMapper().readTree(pair[0].json()).get("checkout_jwt").textValue() + val parts = jwt.split('.').toMutableList() + val content = decode(parts[1]) + (content.get("merchant") as ObjectNode).put("website", "https://attacker.example") + parts[1] = PaymentPayloads.encoded(content.toString()) + val changed = parts.joinToString(".") + checkout.put("checkout_jwt", changed).put("checkout_hash", PaymentPayloads.hash(changed)) + assertEquals( + Verdict.DENY, evaluate( + listOf( + PaymentMandate.of(checkout.toString()), + PaymentPayloads.payment(PaymentPayloads.hash(changed)) + ) + ) + ) + } + + @Test + fun `nested object disclosures are resolved before evaluating the payment`() { + val pair = PaymentPayloads.pair() + val payment = jacksonObjectMapper().readTree(pair[1].json()) as ObjectNode + val instrument = payment.remove("payment_instrument") + val nested = PaymentPayloads.encoded("[\"0123456789abcdef\",\"payment_instrument\",$instrument]") + payment.putArray("_sd").add(PaymentPayloads.hash(nested)) + val original = builder.artifact(command(listOf(pair[0], PaymentMandate.of(payment.toString())))) + val resolved = wire(original, disclosures = original.disclosures + nested) + val authority = compiled() + assertEquals( + Verdict.ALLOW, evaluator.evaluate( + authority.policy, + builder.buildIntent(AuthorityCommand.of(fixture.id, resolved), authority) + ).decision() + ) + reject(original) + + payment.set("payment_instrument", instrument) + val duplicate = builder.artifact(command(listOf(pair[0], PaymentMandate.of(payment.toString())))) + reject(wire(duplicate, disclosures = duplicate.disclosures + nested)) + } + + @Test + fun `nested array disclosures preserve delegation and bind the open checkout reference`() { + val open = PaymentPayloads.delegation(builder.type()) + val checkout = jacksonObjectMapper().readTree(open[0].json()) as ObjectNode + val allowed = checkout.get("constraints")[0].get("allowed") as com.fasterxml.jackson.databind.node.ArrayNode + val nested = PaymentPayloads.disclosure(allowed[0].toString(), "nested-0123456789") + allowed.removeAll().addObject().put("...", PaymentPayloads.hash(nested)) + val hash = PaymentPayloads.hash(PaymentPayloads.disclosure(checkout.toString())) + val inputs = listOf( + PaymentMandate.openCheckout(checkout.toString(), hash), + PaymentMandate.of(open[1].json().replace(open[0].disclosureHash(), hash)) + ) + val original = builder.artifact(command(inputs)) + val resolved = wire(original, disclosures = original.disclosures + nested) + val authority = compiled() + val intent = builder.buildIntent(AuthorityCommand.of(fixture.id, resolved), authority) + assertEquals("delegation", intent.actions().single().kind()) + assertEquals(Verdict.DENY, evaluator.evaluate(authority.policy, intent).decision()) + reject(original) + } + + protected fun wire( + original: PaymentMandates, input: String = original.signingInput, + disclosures: List = original.disclosures + ): PaymentMandates = + PaymentMandates(builder.type(), original.mode, input, disclosures) + + protected fun decode(encoded: String): ObjectNode = jacksonObjectMapper() + .readTree(java.util.Base64.getUrlDecoder().decode(encoded)) as ObjectNode + + protected fun reject(artifact: PaymentMandates) { + assertEquals(ErrorType.INVALID_INTENT, assertFailsWith { + CommonIntentBuilder(listOf(builder)).build(AuthorityCommand.of(fixture.id, artifact), compiled()) + }.errorType) + assertFailsWith { builder.validate(artifact) } + } + + @Test + fun `composer preserves JWS bytes disclosures and signature metadata`() { + val command = command() + val artifact = builder.artifact(command) + val raw = ByteArray(64) { (it + 1).toByte() } + val encoder = java.util.Base64.getEncoder() + val signature = org.exploit.keeper.model.api.outgoing.CalculatedSignature( + "ECDSA", encoder.encodeToString(raw), 3, listOf("peer-2") + ) + val request = Sign("payment-test", command) + val result = composer.compose(request, signature) + val expected = + artifact.signingInput + "." + java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(raw) + + "~" + artifact.disclosures.joinToString("~") + "~" + + assertEquals(expected, result.credential) + assertEquals(builder.type(), result.type) + assertEquals(3, result.generation) + assertEquals(listOf("peer-2"), result.imposters) + assertEquals( + result, + composer.compose(request, signature.copy(signature64 = encoder.encodeToString(raw + byteArrayOf(1)))) + ) + + val sdk = jacksonObjectMapper().readValue( + jacksonObjectMapper().writeValueAsBytes(result), + org.exploit.tkeeper.sdk.model.PaymentCredential::class.java + ) + assertEquals(expected, sdk.credential()) + assertEquals(3, sdk.generation()) + assertEquals(result.imposters, sdk.imposters()) + } + + @Test + fun `composer rejects missing malformed and wrong scheme signatures`() { + val base = org.exploit.keeper.model.api.outgoing.CalculatedSignature("ECDSA", null, 1, emptyList()) + val encoder = java.util.Base64.getEncoder() + val invalid = listOf( + base, base.copy(signature64 = "%%%"), base.copy(signature64 = ""), + base.copy(signature64 = encoder.encodeToString(ByteArray(63))), + base.copy(signature64 = encoder.encodeToString(ByteArray(66))), + base.copy(type = "EdDSA", signature64 = encoder.encodeToString(ByteArray(64))) + ) + for (signature in invalid) { + assertEquals(ErrorType.INVALID_SIGNATURE, assertFailsWith { + composer.compose(Sign("payment-test", command()), signature) + }.errorType) + } + assertEquals(ErrorType.INVALID_AUTHORITY_ARTIFACT, assertFailsWith { + composer.compose(Sign("payment-test", AuthorityCommand("wrong", fixture.id, command().artifact)), base) + }.errorType) + } + + @Test + fun `signing rejects expired and future credentials independently of policy`() { + val original = builder.artifact(command()) + val parts = original.signingInput.split('.') + val payload = decode(parts[1]) + val now = java.time.Instant.now().epochSecond + val variants = listOf( + payload.deepCopy().put("iat", now - 1800).put("exp", now - 901), + payload.deepCopy().put("iat", now + 601).put("exp", now + 1501), + payload.deepCopy().put("nbf", now + 601) + ) + for (changed in variants) { + val artifact = wire(original, parts[0] + "." + PaymentPayloads.encoded(changed.toString())) + assertEquals(ErrorType.INVALID_INTENT, assertFailsWith { + CommonIntentBuilder(listOf(builder)).build(AuthorityCommand.of(fixture.id, artifact), compiled()) + }.errorType) + } + } + + @Test + fun `signed header and payload are available to every action policy`() { + val authority = compiled() + val original = command(PaymentPayloads.pair() + PaymentPayloads.pair("order-2", 100)) + val intent = builder.buildIntent(original, authority) + val typ = if (builder.type() == "ap2.mandate") "dc+sd-jwt" else "kb-sd-jwt" + val policy = evaluator.compileStrict( + Policy.denyByDefault("credential") + .allow("profile") { it.where("context.header.alg == 'ES256' && context.header.typ == '$typ' && context.payload.exp - context.payload.iat <= 900") } + .build(), builder.policySchema(authority.authority.config)) + + assertEquals(2, intent.evaluationInputs().size) + assertEquals(Verdict.ALLOW, evaluator.evaluate(policy, intent).decision()) + val rejected = evaluator.compileStrict( + Policy.denyByDefault("issuer") + .allow("only") { it.where("has(context.payload.iss) && context.payload.iss == 'https://other.example'") } + .build(), builder.policySchema(authority.authority.config)) + assertEquals(Verdict.DENY, evaluator.evaluate(rejected, intent).decision()) + } + + @Test + fun `checkout signature verification uses only authority pinned merchant keys`() { + val authority = compiled() + val trusted = authority.copyConfig( + authority.authority.config + ("checkoutKeys" to mapOf( + "officeShop" to listOf(PaymentPayloads.merchantJwk()) + )) + ) + builder.validateConfig(trusted.authority.config) + assertEquals( + Verdict.ALLOW, + evaluator.evaluate(trusted.policy, builder.buildIntent(command(), trusted)).decision() + ) + + val pair = PaymentPayloads.pair() + val checkout = jacksonObjectMapper().readTree(pair[0].json()) as ObjectNode + val jwt = checkout.get("checkout_jwt").textValue() + val parts = jwt.split('.') + val payload = decode(parts[1]) + payload.put("id", "tampered-order") + val changed = parts[0] + "." + PaymentPayloads.encoded(payload.toString()) + "." + parts[2] + checkout.put("checkout_jwt", changed).put("checkout_hash", PaymentPayloads.hash(changed)) + val malicious = command( + listOf( + PaymentMandate.of(checkout.toString()), + PaymentPayloads.payment(PaymentPayloads.hash(changed)) + ) + ) + assertEquals(ErrorType.INVALID_INTENT, assertFailsWith { + CommonIntentBuilder(listOf(builder)).build(malicious, trusted) + }.errorType) + + val otherMerchant = authority.copyConfig( + trusted.authority.config + + ("merchants" to mapOf("officeShop" to mapOf("website" to "https://other.example"))) + ) + assertEquals(ErrorType.INVALID_INTENT, assertFailsWith { + CommonIntentBuilder(listOf(builder)).build(command(), otherMerchant) + }.errorType) + val wrongKidHeader = decode(parts[0]).put("kid", "untrusted-key") + val wrongKidJwt = PaymentPayloads.encoded(wrongKidHeader.toString()) + "." + parts[1] + "." + parts[2] + checkout.put("checkout_jwt", wrongKidJwt).put("checkout_hash", PaymentPayloads.hash(wrongKidJwt)) + assertEquals(ErrorType.INVALID_INTENT, assertFailsWith { + CommonIntentBuilder(listOf(builder)).build( + command( + listOf( + PaymentMandate.of(checkout.toString()), + PaymentPayloads.payment(PaymentPayloads.hash(wrongKidJwt)) + ) + ), trusted + ) + }.errorType) + } + + @Test + fun `checkout trust configuration fails closed on invalid or private keys`() { + val config = compiled().authority.config + val jwk = PaymentPayloads.merchantJwk() + val invalid = listOf( + null, emptyMap(), mapOf("unknown" to listOf(jwk)), + mapOf("officeShop" to emptyList()), mapOf("officeShop" to listOf(jwk + ("d" to "AQ"))), + mapOf("officeShop" to listOf(jwk + ("alg" to "HS256"))), + mapOf("officeShop" to listOf(jwk + ("use" to "enc"))), + mapOf("officeShop" to listOf(jwk + ("key_ops" to listOf("sign")))) + ) + invalid.forEach { assertFails { builder.validateConfig(config + ("checkoutKeys" to it)) } } + assertFails { + builder.validateConfig( + config + mapOf( + "mode" to "PAYMENTS", + "checkoutKeys" to mapOf("officeShop" to listOf(jwk)) + ) + ) + } + } + + private fun compiled(): CompiledAuthority { + val loaded = AuthorityLoader.standard() + .load(AuthorityArtifact.of("test", fixture.readYaml().toByteArray(), "application/yaml")) + builder.validateConfig(loaded.authority.config) + val policy = evaluator.compileStrict(loaded.authority.policy, builder.policySchema(loaded.authority.config)) + return CompiledAuthority(loaded.authority, policy, loaded.artifact) + } + + private fun CompiledAuthority.copyConfig(config: Map): CompiledAuthority { + val a = authority + return CompiledAuthority( + Authority( + a.schemaVersion, a.id, a.type, a.version, + a.metadata, config, a.policy + ), policy, artifact + ) + } + + private fun evaluate(mandates: List): Verdict { + val authority = compiled() + return evaluator.evaluate(authority.policy, builder.buildIntent(command(mandates), authority)).decision() + } + + protected fun command( + mandates: List = PaymentPayloads.pair(), + mode: PaymentRequestMode = PaymentRequestMode.PAIRED + ): AuthorityCommand { + val json = mapper(jacksonObjectMapper()) + return json.readValue( + json.writeValueAsBytes(Command.of(fixture.id, artifact(mandates, mode))), + AuthorityCommand::class.java + ) + } + + + private fun mapper(mapper: ObjectMapper): ObjectMapper = mapper.registerModule( + SimpleModule() + .addDeserializer( + AuthorityCommand::class.java, + JacksonCustomizer.AuthorityCommandDeserializer(listOf(deserializer)) + ) + .addDeserializer( + VerificationCommand::class.java, + JacksonCustomizer.VerificationCommandDeserializer(listOf(deserializer)) + ) + ) +} diff --git a/features/auth-dev/build.gradle b/features/auth-dev/build.gradle index 0c779ca3..07c50b90 100644 --- a/features/auth-dev/build.gradle +++ b/features/auth-dev/build.gradle @@ -1,11 +1,11 @@ plugins { id 'org.jetbrains.kotlin.jvm' 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' } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() diff --git a/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinScriptPolicy.kt b/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinScriptPolicy.kt deleted file mode 100644 index fafc8984..00000000 --- a/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinScriptPolicy.kt +++ /dev/null @@ -1,83 +0,0 @@ -package org.exploit.keeper.authority.bitcoin.util - -import org.bitcoinj.script.Script -import org.bitcoinj.script.ScriptOpCodes.OP_CHECKMULTISIG -import org.bitcoinj.script.ScriptPattern -import org.exploit.keeper.constant.common.ErrorType -import org.exploit.keeper.exception.TKeeperException - -object BitcoinScriptPolicy { - fun validateKnownScriptMembership( - script: Script, - publicKey: ByteArray, - publicKeyHash: ByteArray - ) { - when { - ScriptPattern.isP2PKH(script) -> { - val expected = ScriptPattern.extractHashFromP2PKH(script) - BitcoinUtxoUtils.requireBytesEqual(publicKeyHash, expected, "public key does not match P2PKH script") - } - - ScriptPattern.isP2WPKH(script) -> { - val expected = ScriptPattern.extractHashFromP2WH(script) - BitcoinUtxoUtils.requireBytesEqual(publicKeyHash, expected, "public key does not match P2WPKH script") - } - - isStandardCheckMultisig(script) -> { - val pubkeys = extractCheckMultisigPubkeys(script) - if (pubkeys.none { it.contentEquals(publicKey) }) { - throw TKeeperException( - ErrorType.AUTHORITY_VIOLATION, - "public key is not present in multisig script" - ) - } - } - - else -> { - throw TKeeperException( - ErrorType.INVALID_AUTHORITY_ARTIFACT, - "Unsupported script template; cannot validate key membership" - ) - } - } - } - - fun isStandardCheckMultisig(script: Script): Boolean { - val chunks = script.chunks() - if (chunks.size < 4) return false - - val first = chunks.first().opcode - val last = chunks.last().opcode - val beforeLast = chunks[chunks.size - 2].opcode - - val m = decodeSmallNum(first) ?: return false - val n = decodeSmallNum(beforeLast) ?: return false - - if (last != OP_CHECKMULTISIG) return false - if (m < 1 || n < 1 || m > n || n > 20) return false - if (chunks.size != n + 3) return false - - val pubkeyChunks = chunks.subList(1, chunks.size - 2) - return pubkeyChunks.all { chunk -> - val data = chunk.data - data != null && BitcoinUtxoUtils.isCanonicalSecp256k1PublicKey(data) - } - } - - fun extractCheckMultisigPubkeys(script: Script): List { - if (!isStandardCheckMultisig(script)) { - throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Not a standard CHECKMULTISIG script") - } - - val chunks = script.chunks() - return chunks.subList(1, chunks.size - 2).map { it.data!! } - } - - private fun decodeSmallNum(opcode: Int): Int? { - return when (opcode) { - 0x00 -> 0 - in 0x51..0x60 -> opcode - 0x50 - else -> null - } - } -} diff --git a/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoSighash.kt b/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoSighash.kt deleted file mode 100644 index a2e8bd2c..00000000 --- a/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoSighash.kt +++ /dev/null @@ -1,129 +0,0 @@ -package org.exploit.keeper.authority.bitcoin.util - -import org.bitcoinj.core.Transaction -import org.bitcoinj.core.TransactionOutput -import org.exploit.keeper.constant.common.ErrorType -import org.exploit.keeper.exception.TKeeperException -import java.io.ByteArrayOutputStream -import java.security.MessageDigest - -object BitcoinUtxoSighash { - fun hashForTaprootKeyPathSignature( - tx: Transaction, - prevouts: List, - inputIndex: Int, - sigHash: Int - ): ByteArray { - if (prevouts.size != tx.inputs.size) { - throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "prevouts count does not match inputs count") - } - if (inputIndex < 0 || inputIndex >= tx.inputs.size) { - throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Invalid input index: $inputIndex") - } - - val msg = ByteArrayOutputStream() - - msg.write(sigHash and 0xff) - writeInt32LE(msg, tx.version.toInt()) - writeUInt32LE(msg, tx.lockTime().rawValue()) - - msg.write(sha256(serializeAllInputOutpoints(tx))) - msg.write(sha256(serializeAllInputAmounts(prevouts))) - msg.write(sha256(serializeAllInputScriptPubKeys(prevouts))) - msg.write(sha256(serializeAllInputSequences(tx))) - msg.write(sha256(serializeAllOutputs(tx))) - - msg.write(0x00) - writeUInt32LE(msg, inputIndex.toLong()) - - return taggedHash("TapSighash", byteArrayOf(0x00.toByte()) + msg.toByteArray()) - } - - private fun serializeAllInputOutpoints(tx: Transaction): ByteArray { - val out = ByteArrayOutputStream() - tx.inputs.forEach { input -> out.write(input.outpoint.serialize()) } - return out.toByteArray() - } - - private fun serializeAllInputAmounts(prevouts: List): ByteArray { - val out = ByteArrayOutputStream() - prevouts.forEach { prevout -> writeInt64LE(out, prevout.value.value) } - return out.toByteArray() - } - - private fun serializeAllInputScriptPubKeys(prevouts: List): ByteArray { - val out = ByteArrayOutputStream() - prevouts.forEach { prevout -> writeVarBytes(out, prevout.scriptBytes) } - return out.toByteArray() - } - - private fun serializeAllInputSequences(tx: Transaction): ByteArray { - val out = ByteArrayOutputStream() - tx.inputs.forEach { input -> writeUInt32LE(out, input.sequenceNumber) } - return out.toByteArray() - } - - private fun serializeAllOutputs(tx: Transaction): ByteArray { - val out = ByteArrayOutputStream() - tx.outputs.forEach { output -> out.write(output.serialize()) } - return out.toByteArray() - } - - private fun taggedHash(tag: String, payload: ByteArray): ByteArray { - val tagHash = sha256(tag.toByteArray(Charsets.UTF_8)) - return sha256(tagHash + tagHash + payload) - } - - private fun sha256(bytes: ByteArray): ByteArray = - MessageDigest.getInstance("SHA-256").digest(bytes) - - private fun writeVarBytes(out: ByteArrayOutputStream, bytes: ByteArray) { - writeCompactSize(out, bytes.size.toLong()) - out.write(bytes) - } - - private fun writeCompactSize(out: ByteArrayOutputStream, value: Long) { - if (value < 0) { - throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Negative compact size") - } - - when { - value < 0xfd -> out.write(value.toInt()) - value <= 0xffff -> { - out.write(0xfd) - writeUInt16LE(out, value.toInt()) - } - value <= 0xffffffffL -> { - out.write(0xfe) - writeUInt32LE(out, value) - } - else -> { - out.write(0xff) - writeInt64LE(out, value) - } - } - } - - private fun writeUInt16LE(out: ByteArrayOutputStream, value: Int) { - out.write(value and 0xff) - out.write((value ushr 8) and 0xff) - } - - private fun writeInt32LE(out: ByteArrayOutputStream, value: Int) = - writeUInt32LE(out, value.toLong()) - - private fun writeUInt32LE(out: ByteArrayOutputStream, value: Long) { - out.write((value and 0xff).toInt()) - out.write(((value ushr 8) and 0xff).toInt()) - out.write(((value ushr 16) and 0xff).toInt()) - out.write(((value ushr 24) and 0xff).toInt()) - } - - private fun writeInt64LE(out: ByteArrayOutputStream, value: Long) { - var v = value - repeat(8) { - out.write((v and 0xff).toInt()) - v = v ushr 8 - } - } -} diff --git a/features/authority-bitcoin/src/test/kotlin/org/exploit/keeper/authority/bitcoin/intent/BitcoinIntentPolicySchemaTest.kt b/features/authority-bitcoin/src/test/kotlin/org/exploit/keeper/authority/bitcoin/intent/BitcoinIntentPolicySchemaTest.kt deleted file mode 100644 index 50a51df1..00000000 --- a/features/authority-bitcoin/src/test/kotlin/org/exploit/keeper/authority/bitcoin/intent/BitcoinIntentPolicySchemaTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.exploit.keeper.authority.bitcoin.intent - -import dev.cel.common.types.SimpleType -import org.exploit.test.authority.AuthorityPayloads -import org.exploit.verdict.intent.bitcoin.BitcoinIntentConfig -import org.exploit.verdict.intent.bitcoin.BitcoinSigningIntent -import java.util.Base64 -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class BitcoinIntentPolicySchemaTest { - @Test - fun `policy schema exactly matches runtime intent roots`() { - val config = mapOf("protocol" to "BTC") - val publicKey64 = Base64.getEncoder().encodeToString(ByteArray(33) { 1 }) - val payload = AuthorityPayloads.bitcoinP2pkhSpend(publicKey64, outputSats = 100_000) - val intent = BitcoinSigningIntent.fromBase64( - payload.transaction64, - payload.previousTransactions64, - BitcoinIntentConfig.fromMap(config) - ) - - val schema = BitcoinTransactionIntentBuilder().policySchema(config) - - assertEquals(intent.variables().keys, schema.keys) - assertTrue(schema.values.all { it == SimpleType.DYN }) - } -} diff --git a/features/authority-x509/build.gradle b/features/authority-x509/build.gradle index a6a3d714..ac353117 100644 --- a/features/authority-x509/build.gradle +++ b/features/authority-x509/build.gradle @@ -1,11 +1,11 @@ plugins { id 'org.jetbrains.kotlin.jvm' 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' } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() @@ -38,7 +38,7 @@ dependencies { compileOnly(project(':platform-ecc')) ksp "io.micronaut:micronaut-inject-kotlin:4.10.8" - implementation 'org.exploit:verdict-intent-x509:0.1.2' + implementation 'org.exploit.verdict:x509:0.2.0' } tasks.named('shadowJar') { diff --git a/features/authority-x509/src/main/kotlin/org/exploit/keeper/authority/x509/X509TBSCommandMaterializer.kt b/features/authority-x509/src/main/kotlin/org/exploit/keeper/authority/x509/X509TBSCommandMaterializer.kt index 2ebdedb1..b6cb1c3f 100644 --- a/features/authority-x509/src/main/kotlin/org/exploit/keeper/authority/x509/X509TBSCommandMaterializer.kt +++ b/features/authority-x509/src/main/kotlin/org/exploit/keeper/authority/x509/X509TBSCommandMaterializer.kt @@ -26,7 +26,7 @@ class X509TBSCommandMaterializer(private val db: RocksKeyDB): CommandMaterialize return IntentTypes.X509_TBS_CERTIFICATE } - override fun toSigningMaterial(keyId: String, generation: Int, c: Command): SigningMaterial { + override fun toSigningMaterial(keyId: String, generation: Int, c: Command, tweak: String?): SigningMaterial { val artifact = c.artifact as? TBSDerEncoded ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Not TBS der encoded cert") diff --git a/features/authority-x509/src/main/kotlin/org/exploit/keeper/authority/x509/deserializer/TBSCertificateDeserializer.kt b/features/authority-x509/src/main/kotlin/org/exploit/keeper/authority/x509/deserializer/TBSCertificateDeserializer.kt index 421f6bee..4c133937 100644 --- a/features/authority-x509/src/main/kotlin/org/exploit/keeper/authority/x509/deserializer/TBSCertificateDeserializer.kt +++ b/features/authority-x509/src/main/kotlin/org/exploit/keeper/authority/x509/deserializer/TBSCertificateDeserializer.kt @@ -8,6 +8,7 @@ import org.exploit.keeper.util.extension.requireOnly import org.exploit.keeper.util.extension.requireText import org.exploit.keeper.model.command.artifact.Artifact import org.exploit.keeper.service.command.deserializer.ArtifactDeserializer +import org.exploit.keeper.service.command.deserializer.ArtifactSchemas import org.exploit.verdict.intent.IntentTypes @Singleton @@ -16,6 +17,10 @@ class TBSCertificateDeserializer: ArtifactDeserializer { return IntentTypes.X509_TBS_CERTIFICATE } + override fun schema(config: Map): Map = + ArtifactSchemas.stringField("der64", "Base64 encoded DER TBSCertificate") + + override fun deserialize(node: JsonNode, ctx: DeserializationContext): Artifact { node.requireOnly(ctx, "der64") return TBSDerEncoded( diff --git a/features/digital-assets/README.md b/features/digital-assets/README.md new file mode 100644 index 00000000..3aad1368 --- /dev/null +++ b/features/digital-assets/README.md @@ -0,0 +1,33 @@ +# Digital assets + +Bitcoin, EVM, Tron, XRP, and Solana transaction authorities are separate modules under +`digital-assets`. They use the normal authority, policy, approval, and signing +pipeline and require the `ecc` platform. Tron uses secp256k1, Solana uses Ed25519, +and XRP accepts either. Pass the unsigned Signet dump in the SDK artifact's +`transaction` field: JSON for Tron, hex for XRP, and Base64 for Solana. +`signature().compose(...)` signs and returns `SignedTronTransaction`, +`SignedXrpTransaction`, or `SignedSolanaTransaction`. Each contains the signed +transaction in the same wire format and its transaction hash. Solana also +reports `complete`: a transaction with other missing signatures is partial and +may not have a hash yet. EVM composition returns `SignedEvmTransaction` with +the signed hex transaction and hash. + +Build a runtime containing both digital assets and agentic payments: + +```sh +./gradlew :build -Pkeeper.features=agentic-payments,digital-assets -Pkeeper.platforms=ecc +``` + +Select `bitcoin`, `evm`, `tron`, `xrp`, or `solana` individually when only one is +needed. The previous `authority-bitcoin` and `authority-evm` selectors remain +accepted. + +For tweaked Bitcoin, Tron, XRP, or Solana signatures, construct the unsigned transaction with the derived public key from `central().getPublicKey(keyId, tweak)`. + +See the separate [Bitcoin](../../docs/digital-assets/bitcoin.md), +[EVM](../../docs/digital-assets/evm.md), +[Tron](../../docs/digital-assets/tron.md), +[XRP](../../docs/digital-assets/xrp.md), and +[Solana](../../docs/digital-assets/solana.md) authority guides. + +See [For Digital Assets](../../docs/use-cases/for-digital-assets.md) for a policy and SDK example. diff --git a/features/digital-assets/bitcoin/build.gradle b/features/digital-assets/bitcoin/build.gradle new file mode 100644 index 00000000..ac364264 --- /dev/null +++ b/features/digital-assets/bitcoin/build.gradle @@ -0,0 +1,66 @@ +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.toLowerCase(java.util.Locale.ROOT).contains('runtime') }.configureEach { + jacksonGroups.each { groupName -> exclude group: groupName } +} + +dependencies { + 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" + implementation 'org.exploit.verdict:bitcoin:0.2.0' + implementation 'org.exploit.signet:crypto-util:0.1.0-SNAPSHOT' +} + +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/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/UtxoInputCommandMaterializer.kt b/features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/UtxoInputCommandMaterializer.kt similarity index 73% rename from features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/UtxoInputCommandMaterializer.kt rename to features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/UtxoInputCommandMaterializer.kt index b15d9caa..31a30f16 100644 --- a/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/UtxoInputCommandMaterializer.kt +++ b/features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/UtxoInputCommandMaterializer.kt @@ -1,15 +1,10 @@ package org.exploit.keeper.authority.bitcoin import jakarta.inject.Singleton -import org.bitcoinj.core.Transaction -import org.bitcoinj.crypto.internal.CryptoUtils -import org.bitcoinj.script.Script -import org.bitcoinj.script.ScriptPattern import org.exploit.ecc.secp256k1.key.Secp256k1PublicKey import org.exploit.ecc.secp256k1.util.taproot.Taproot import org.exploit.keeper.authority.bitcoin.artifact.UtxoInput import org.exploit.keeper.authority.bitcoin.util.BitcoinScriptPolicy -import org.exploit.keeper.authority.bitcoin.util.BitcoinUtxoSighash import org.exploit.keeper.authority.bitcoin.util.BitcoinUtxoUtils import org.exploit.keeper.constant.common.ErrorType import org.exploit.keeper.exception.TKeeperException @@ -21,6 +16,10 @@ import org.exploit.keeper.platform.ecc.model.material.TaprootSigningMaterial import org.exploit.keeper.service.command.CommandMaterializer import org.exploit.keeper.service.db.RocksKeyDB import org.exploit.keeper.service.pub.PublicKeyService +import org.exploit.signet.bitcoin.codec.BitcoinSignatureHash +import org.exploit.signet.bitcoin.constant.BitcoinSigHash +import org.exploit.signet.bitcoin.model.transaction.BitcoinScript +import org.exploit.signet.crypto.util.HashUtil import org.exploit.verdict.intent.IntentTypes @Singleton @@ -30,7 +29,7 @@ class UtxoInputCommandMaterializer( ) : CommandMaterializer { override fun type(): String = IntentTypes.BITCOIN_TRANSACTION - override fun toSigningMaterial(keyId: String, generation: Int, c: Command): SigningMaterial { + override fun toSigningMaterial(keyId: String, generation: Int, c: Command, tweak: String?): SigningMaterial { val artifact = c.artifact as? UtxoInput ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Not UTXO input") @@ -41,12 +40,12 @@ class UtxoInputCommandMaterializer( throw TKeeperException(ErrorType.UNSUPPORTED_KEY_ALGORITHM, "Only secp256k1 is supported for UTXO inputs") val publicKey = pubKeyService.forAlgorithm(Secp256k1AlgorithmProvider.NAME) - .getPublicKey(keyId, generation) + .getPublicKey(keyId, generation, tweak) if (publicKey !is Secp256k1PublicKey) throw TKeeperException(ErrorType.KEY_ALGORITHM_MISMATCH, "Expected Secp256k1PublicKey") - val publicKeyHash = CryptoUtils.sha256hash160(publicKey.encode()) + val publicKeyHash = HashUtil.hash160(publicKey.encode()) val tx = BitcoinUtxoUtils.parseTx(ErrorType.INVALID_AUTHORITY_ARTIFACT, artifact.transaction64) BitcoinUtxoUtils.validateInputIndex(tx, artifact.input) @@ -57,7 +56,7 @@ class UtxoInputCommandMaterializer( val signingPrevout = prevouts[artifact.input] val spend = classifySpend( - prevoutScript = signingPrevout.scriptPubKey, + prevoutScript = signingPrevout.scriptPubKey(), artifact = artifact, publicKey = publicKey.encode(), publicKeyHash = publicKeyHash @@ -65,34 +64,31 @@ class UtxoInputCommandMaterializer( return when (spend) { is Spend.Legacy -> { - val digest = tx.hashForSignature( + val digest = BitcoinSignatureHash.legacy( + tx, artifact.input, spend.scriptCode, - Transaction.SigHash.ALL, - false + BitcoinSigHash.ALL ) - DefaultSigningMaterial("ECDSA", digest.bytes) + DefaultSigningMaterial("ECDSA", digest) } is Spend.WitnessV0 -> { - val digest = tx.hashForWitnessSignature( + val digest = BitcoinSignatureHash.segwitV0( + tx, artifact.input, spend.scriptCode, - signingPrevout.value, - Transaction.SigHash.ALL, - false + signingPrevout.value(), + BitcoinSigHash.ALL ) - DefaultSigningMaterial("ECDSA", digest.bytes) + DefaultSigningMaterial("ECDSA", digest) } is Spend.TaprootKeyPath -> { - val digest = BitcoinUtxoSighash.hashForTaprootKeyPathSignature( - tx = tx, - prevouts = prevouts, - inputIndex = artifact.input, - sigHash = Transaction.SigHash.ALL.value + val digest = BitcoinSignatureHash.taprootKeyPath( + tx, prevouts, artifact.input, BitcoinSigHash.ALL ) TaprootSigningMaterial( @@ -104,63 +100,64 @@ class UtxoInputCommandMaterializer( } private fun classifySpend( - prevoutScript: Script, + prevoutScript: BitcoinScript, artifact: UtxoInput, publicKey: ByteArray, publicKeyHash: ByteArray ): Spend { - BitcoinUtxoUtils.rejectTaprootMerkleRootUnlessTaproot(artifact, prevoutScript) + val script = BitcoinScriptPolicy.classify(prevoutScript) - return when { - ScriptPattern.isP2PKH(prevoutScript) -> { + return when (script.type()) { + "p2pkh" -> { BitcoinUtxoUtils.rejectExtras(artifact) - val expected = ScriptPattern.extractHashFromP2PKH(prevoutScript) + val expected = BitcoinScriptPolicy.hash(script) BitcoinUtxoUtils.requireBytesEqual(publicKeyHash, expected, "public key does not match P2PKH prevout") Spend.Legacy(prevoutScript) } - ScriptPattern.isP2WPKH(prevoutScript) -> { + "p2wpkh" -> { BitcoinUtxoUtils.rejectExtras(artifact) - val expected = ScriptPattern.extractHashFromP2WH(prevoutScript) + val expected = BitcoinScriptPolicy.hash(script) BitcoinUtxoUtils.requireBytesEqual(publicKeyHash, expected, "public key does not match P2WPKH prevout") Spend.WitnessV0(BitcoinUtxoUtils.p2wpkhScriptCode(expected)) } - ScriptPattern.isP2WSH(prevoutScript) -> { + "p2wsh" -> { + BitcoinUtxoUtils.requireNoRedeemScript(artifact) BitcoinUtxoUtils.requireNoTaprootMerkleRoot(artifact) val witnessScriptBytes = artifact.witnessScript64?.let(BitcoinUtxoUtils::decodeBase64) ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "witnessScript64 required for P2WSH") - val expectedWitnessHash = ScriptPattern.extractHashFromP2WH(prevoutScript) + val expectedWitnessHash = BitcoinScriptPolicy.hash(script) BitcoinUtxoUtils.requireSha256Commitment( script = witnessScriptBytes, expected = expectedWitnessHash, message = "witnessScript64 does not match P2WSH prevout" ) - val witnessScript = Script.parse(witnessScriptBytes) + val witnessScript = BitcoinScript.of(witnessScriptBytes) BitcoinScriptPolicy.validateKnownScriptMembership(witnessScript, publicKey, publicKeyHash) Spend.WitnessV0(witnessScript) } - ScriptPattern.isP2SH(prevoutScript) -> { + "p2sh" -> { classifyP2shSpend(prevoutScript, artifact, publicKey, publicKeyHash) } - ScriptPattern.isP2TR(prevoutScript) -> { + "p2tr" -> { classifyP2trKeyPathSpend(prevoutScript, artifact, publicKey) } else -> { - throw TKeeperException(ErrorType.INVALID_CURSOR, "Unsupported prevout script") + throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Unsupported prevout script") } } } private fun classifyP2shSpend( - scriptPubKey: Script, + scriptPubKey: BitcoinScript, artifact: UtxoInput, publicKey: ByteArray, publicKeyHash: ByteArray @@ -170,7 +167,7 @@ class UtxoInputCommandMaterializer( val redeemScriptBytes = artifact.redeemScript64?.let(BitcoinUtxoUtils::decodeBase64) ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "redeemScript64 required for P2SH") - val expectedP2shHash = ScriptPattern.extractHashFromP2SH(scriptPubKey) + val expectedP2shHash = BitcoinScriptPolicy.hash(BitcoinScriptPolicy.classify(scriptPubKey)) BitcoinUtxoUtils.requireHash160Commitment( script = redeemScriptBytes, @@ -178,42 +175,44 @@ class UtxoInputCommandMaterializer( message = "redeemScript64 does not match P2SH prevout" ) - val redeemScript = Script.parse(redeemScriptBytes) + val redeemScript = BitcoinScript.of(redeemScriptBytes) - return when { - ScriptPattern.isP2WPKH(redeemScript) -> { + val script = BitcoinScriptPolicy.classify(redeemScript) + + return when (script.type()) { + "p2wpkh" -> { BitcoinUtxoUtils.requireNoWitnessScript(artifact) - val expected = ScriptPattern.extractHashFromP2WH(redeemScript) + val expected = BitcoinScriptPolicy.hash(script) BitcoinUtxoUtils.requireBytesEqual(publicKeyHash, expected, "public key does not match P2SH-P2WPKH redeemScript") Spend.WitnessV0(BitcoinUtxoUtils.p2wpkhScriptCode(expected)) } - ScriptPattern.isP2WSH(redeemScript) -> { + "p2wsh" -> { val witnessScriptBytes = artifact.witnessScript64?.let(BitcoinUtxoUtils::decodeBase64) ?: throw TKeeperException( ErrorType.INVALID_AUTHORITY_ARTIFACT, "witnessScript64 required for P2SH-P2WSH" ) - val expectedWitnessHash = ScriptPattern.extractHashFromP2WH(redeemScript) + val expectedWitnessHash = BitcoinScriptPolicy.hash(script) BitcoinUtxoUtils.requireSha256Commitment( script = witnessScriptBytes, expected = expectedWitnessHash, message = "witnessScript64 does not match P2SH-P2WSH redeemScript" ) - val witnessScript = Script.parse(witnessScriptBytes) + val witnessScript = BitcoinScript.of(witnessScriptBytes) BitcoinScriptPolicy.validateKnownScriptMembership(witnessScript, publicKey, publicKeyHash) Spend.WitnessV0(witnessScript) } - ScriptPattern.isP2PKH(redeemScript) -> { + "p2pkh" -> { BitcoinUtxoUtils.requireNoWitnessScript(artifact) - val expected = ScriptPattern.extractHashFromP2PKH(redeemScript) + val expected = BitcoinScriptPolicy.hash(script) BitcoinUtxoUtils.requireBytesEqual(publicKeyHash, expected, "public key does not match P2SH-P2PKH redeemScript") Spend.Legacy(redeemScript) @@ -228,7 +227,7 @@ class UtxoInputCommandMaterializer( } private fun classifyP2trKeyPathSpend( - scriptPubKey: Script, + scriptPubKey: BitcoinScript, artifact: UtxoInput, publicKey: ByteArray ): Spend.TaprootKeyPath { @@ -239,7 +238,7 @@ class UtxoInputCommandMaterializer( val internalXOnly = BitcoinUtxoUtils.xOnlySecp256k1PublicKey(publicKey) val expectedOutputKey = Taproot.taprootTweakPubkey(internalXOnly, merkleRoot ?: ByteArray(0)) .xOnlyPubkey - val actualOutputKey = ScriptPattern.extractOutputKeyFromP2TR(scriptPubKey) + val actualOutputKey = BitcoinScriptPolicy.hash(BitcoinScriptPolicy.classify(scriptPubKey)) BitcoinUtxoUtils.requireBytesEqual( actual = expectedOutputKey, @@ -251,8 +250,8 @@ class UtxoInputCommandMaterializer( } private sealed interface Spend { - data class Legacy(val scriptCode: Script) : Spend - data class WitnessV0(val scriptCode: Script) : Spend + data class Legacy(val scriptCode: BitcoinScript) : Spend + data class WitnessV0(val scriptCode: BitcoinScript) : Spend class TaprootKeyPath(val merkleRoot: ByteArray?) : Spend } } diff --git a/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/artifact/UtxoInput.kt b/features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/artifact/UtxoInput.kt similarity index 100% rename from features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/artifact/UtxoInput.kt rename to features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/artifact/UtxoInput.kt diff --git a/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/deserializer/UtxoInputDeserializer.kt b/features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/deserializer/UtxoInputDeserializer.kt similarity index 72% rename from features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/deserializer/UtxoInputDeserializer.kt rename to features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/deserializer/UtxoInputDeserializer.kt index a32f60c4..a58a1bce 100644 --- a/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/deserializer/UtxoInputDeserializer.kt +++ b/features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/deserializer/UtxoInputDeserializer.kt @@ -12,6 +12,7 @@ import org.exploit.keeper.util.extension.requireOnly import org.exploit.keeper.util.extension.requireText import org.exploit.keeper.model.command.artifact.Artifact import org.exploit.keeper.service.command.deserializer.ArtifactDeserializer +import org.exploit.keeper.service.command.deserializer.ArtifactSchemas import org.exploit.verdict.intent.IntentTypes @Singleton @@ -20,6 +21,21 @@ class UtxoInputDeserializer: ArtifactDeserializer { return IntentTypes.BITCOIN_TRANSACTION } + override fun schema(config: Map): Map = ArtifactSchemas.objectFields( + mapOf( + "transaction64" to mapOf("type" to "string", "contentEncoding" to "base64"), + "previousTransactions64" to mapOf( + "type" to "array", "items" to mapOf("type" to "string", "contentEncoding" to "base64") + ), + "input" to mapOf("type" to "integer", "minimum" to 0), + "merkleRoot64" to mapOf("type" to "string", "contentEncoding" to "base64"), + "redeemScript64" to mapOf("type" to "string", "contentEncoding" to "base64"), + "witnessScript64" to mapOf("type" to "string", "contentEncoding" to "base64") + ), + listOf("transaction64", "previousTransactions64", "input") + ) + + override fun deserialize(node: JsonNode, ctx: DeserializationContext): Artifact { node.requireOnly( ctx, diff --git a/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/intent/BitcoinTransactionIntentBuilder.kt b/features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/intent/BitcoinTransactionIntentBuilder.kt similarity index 84% rename from features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/intent/BitcoinTransactionIntentBuilder.kt rename to features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/intent/BitcoinTransactionIntentBuilder.kt index ad674523..448d7d69 100644 --- a/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/intent/BitcoinTransactionIntentBuilder.kt +++ b/features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/intent/BitcoinTransactionIntentBuilder.kt @@ -11,7 +11,7 @@ import org.exploit.keeper.service.command.intent.IntentBuilder import org.exploit.verdict.authority.model.CompiledAuthority import org.exploit.verdict.intent.Intent import org.exploit.verdict.intent.IntentTypes -import org.exploit.verdict.intent.bitcoin.BitcoinIntentConfig +import org.exploit.verdict.intent.bitcoin.config.BitcoinIntentConfig import org.exploit.verdict.intent.bitcoin.BitcoinSigningIntent @Singleton @@ -32,13 +32,18 @@ class BitcoinTransactionIntentBuilder: IntentBuilder { val intentConfig = BitcoinIntentConfig.fromMap(compiled.authority.config) - return BitcoinSigningIntent.fromBase64(input.transaction64, - input.previousTransactions64, intentConfig) + return BitcoinSigningIntent.builder() + .config(intentConfig) + .unsignedTransactionBase64(input.transaction64) + .previousTransactionsBase64(input.previousTransactions64) + .signingInput(input.input) + .build() } private companion object { val POLICY_SCHEMA: Map = setOf( "protocol", + "network", "asset", "assetDecimals", "txId", diff --git a/features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinScriptPolicy.kt b/features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinScriptPolicy.kt new file mode 100644 index 00000000..abe0d0d6 --- /dev/null +++ b/features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinScriptPolicy.kt @@ -0,0 +1,50 @@ +package org.exploit.keeper.authority.bitcoin.util + +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.signet.bitcoin.constant.BitcoinNetwork +import org.exploit.signet.bitcoin.model.transaction.BitcoinScript +import org.exploit.verdict.intent.bitcoin.model.ScriptInfo +import org.exploit.verdict.intent.bitcoin.util.ScriptClassifier +import java.util.* + +object BitcoinScriptPolicy { + fun classify(script: BitcoinScript): ScriptInfo { + return ScriptClassifier.classify(BitcoinNetwork.MAINNET, script) + } + + fun hash(script: ScriptInfo): ByteArray { + return HexFormat.of().parseHex((script.details()["hashHex"] as String).removePrefix("0x")) + } + + fun validateKnownScriptMembership( + script: BitcoinScript, + publicKey: ByteArray, + publicKeyHash: ByteArray + ) { + val info = classify(script) + + when (info.type()) { + "p2pkh", "p2wpkh" -> { + BitcoinUtxoUtils.requireBytesEqual(publicKeyHash, hash(info), "public key does not match script") + } + + "bare_multisig" -> { + val pubkeys = info.details()["pubKeyHexes"] as List<*> + if ("0x" + HexFormat.of().formatHex(publicKey) !in pubkeys) { + throw TKeeperException( + ErrorType.INVALID_AUTHORITY_ARTIFACT, + "public key is not present in multisig script" + ) + } + } + + else -> { + throw TKeeperException( + ErrorType.INVALID_AUTHORITY_ARTIFACT, + "Unsupported script template; cannot validate key membership" + ) + } + } + } +} diff --git a/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoUtils.kt b/features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoUtils.kt similarity index 67% rename from features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoUtils.kt rename to features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoUtils.kt index 5278b4fe..8ed20aba 100644 --- a/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoUtils.kt +++ b/features/digital-assets/bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoUtils.kt @@ -1,30 +1,22 @@ package org.exploit.keeper.authority.bitcoin.util -import org.bitcoinj.base.Sha256Hash -import org.bitcoinj.core.Transaction -import org.bitcoinj.core.TransactionOutput -import org.bitcoinj.crypto.internal.CryptoUtils -import org.bitcoinj.script.Script -import org.bitcoinj.script.ScriptBuilder -import org.bitcoinj.script.ScriptOpCodes.* import org.exploit.keeper.authority.bitcoin.artifact.UtxoInput import org.exploit.keeper.constant.common.ErrorType import org.exploit.keeper.exception.TKeeperException import org.exploit.keeper.model.error.ErrorCode -import java.nio.ByteBuffer +import org.exploit.signet.bitcoin.model.transaction.BitcoinScript +import org.exploit.signet.bitcoin.model.transaction.BitcoinTransaction +import org.exploit.signet.bitcoin.model.utxo.BitcoinUtxo +import org.exploit.signet.bitcoin.transaction.parser.BitcoinTransactionParser +import org.exploit.signet.crypto.util.HashUtil import java.util.* object BitcoinUtxoUtils { - fun parseTx(reason: ErrorCode, tx64: String): Transaction { + fun parseTx(reason: ErrorCode, tx64: String): BitcoinTransaction { val bytes = decodeBase64(tx64) - val buffer = ByteBuffer.wrap(bytes) return try { - val tx = Transaction.read(buffer) - if (buffer.hasRemaining()) { - throw TKeeperException(reason, "Trailing bytes after transaction") - } - tx + BitcoinTransactionParser().parse(HexFormat.of().formatHex(bytes)) } catch (e: TKeeperException) { throw e } catch (e: RuntimeException) { @@ -32,12 +24,12 @@ object BitcoinUtxoUtils { } } - fun parsePreviousTransactions(previousTransactions64: List): Map { - val out = LinkedHashMap() + fun parsePreviousTransactions(previousTransactions64: List): Map { + val out = LinkedHashMap() for (tx64 in previousTransactions64) { val tx = parseTx(ErrorType.INVALID_AUTHORITY_ARTIFACT, tx64) - val txId = tx.txId.toString() + val txId = tx.hash() if (out.put(txId, tx) != null) { throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, txId) @@ -48,42 +40,43 @@ object BitcoinUtxoUtils { } fun resolvePrevouts( - tx: Transaction, - previousTransactions: Map - ): List { - return tx.inputs.mapIndexed { index, input -> - if (input.isCoinBase) { + tx: BitcoinTransaction, + previousTransactions: Map + ): List { + return tx.inputs().mapIndexed { index, input -> + if (input.outPoint().isCoinbase) { throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Coinbase input at index $index") } - val outpoint = input.outpoint - val prevTxId = outpoint.hash().toString() + val outpoint = input.outPoint() + val prevTxId = outpoint.txid() val prevTx = previousTransactions[prevTxId] ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, prevTxId) - val vout = outpoint.index().toInt() - if (vout < 0 || vout >= prevTx.outputs.size) { + val vout = outpoint.outputIndex() + if (vout < 0 || vout >= prevTx.outputs().size) { throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "$prevTxId:$vout") } - prevTx.outputs[vout] + val output = prevTx.outputs()[vout.toInt()] + BitcoinUtxo(outpoint, output.value(), output.scriptPubKey(), 0) } } - fun validateInputIndex(tx: Transaction, inputIndex: Int) { - if (inputIndex < 0 || inputIndex >= tx.inputs.size) { + fun validateInputIndex(tx: BitcoinTransaction, inputIndex: Int) { + if (inputIndex < 0 || inputIndex >= tx.inputs().size) { throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Invalid input index: $inputIndex") } - if (tx.inputs[inputIndex].isCoinBase) { + if (tx.inputs()[inputIndex].outPoint().isCoinbase) { throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Coinbase input cannot be signed") } } - fun validateUnsigned(tx: Transaction) { - tx.inputs.forEachIndexed { index, input -> - if (input.scriptBytes.isNotEmpty() || input.hasWitness()) { + fun validateUnsigned(tx: BitcoinTransaction) { + tx.inputs().forEachIndexed { index, input -> + if (input.scriptSig().size() != 0 || !input.witness().isEmpty) { throw TKeeperException( ErrorType.INVALID_AUTHORITY_ARTIFACT, "Input $index already has scriptSig or witness" @@ -92,18 +85,13 @@ object BitcoinUtxoUtils { } } - fun p2wpkhScriptCode(pubKeyHash: ByteArray): Script { + fun p2wpkhScriptCode(pubKeyHash: ByteArray): BitcoinScript { if (pubKeyHash.size != 20) { throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Invalid P2WPKH pubkey hash") } - return ScriptBuilder() - .op(OP_DUP) - .op(OP_HASH160) - .data(pubKeyHash) - .op(OP_EQUALVERIFY) - .op(OP_CHECKSIG) - .build() + return BitcoinScript.of(byteArrayOf(0x76, 0xa9.toByte(), 0x14) + pubKeyHash + + byteArrayOf(0x88.toByte(), 0xac.toByte())) } fun rejectExtras(a: UtxoInput) { @@ -130,28 +118,19 @@ object BitcoinUtxoUtils { } } - fun rejectTaprootMerkleRootUnlessTaproot(a: UtxoInput, prevoutScript: Script) { - if (a.merkleRoot64 != null && !org.bitcoinj.script.ScriptPattern.isP2TR(prevoutScript)) { - throw TKeeperException( - ErrorType.INVALID_AUTHORITY_ARTIFACT, - "taprootMerkleRoot64 is only valid for P2TR" - ) - } - } - fun requireHash160Commitment(script: ByteArray, expected: ByteArray, message: String) { - val actual = CryptoUtils.sha256hash160(script) + val actual = HashUtil.hash160(script) requireBytesEqual(actual, expected, message) } fun requireSha256Commitment(script: ByteArray, expected: ByteArray, message: String) { - val actual = Sha256Hash.hash(script) + val actual = HashUtil.sha256(script) requireBytesEqual(actual, expected, message) } fun requireBytesEqual(actual: ByteArray, expected: ByteArray, message: String) { if (!actual.contentEquals(expected)) { - throw TKeeperException(ErrorType.AUTHORITY_VIOLATION, message) + throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, message) } } diff --git a/features/digital-assets/bitcoin/src/test/kotlin/org/exploit/keeper/authority/bitcoin/intent/BitcoinIntentPolicySchemaTest.kt b/features/digital-assets/bitcoin/src/test/kotlin/org/exploit/keeper/authority/bitcoin/intent/BitcoinIntentPolicySchemaTest.kt new file mode 100644 index 00000000..f10816c3 --- /dev/null +++ b/features/digital-assets/bitcoin/src/test/kotlin/org/exploit/keeper/authority/bitcoin/intent/BitcoinIntentPolicySchemaTest.kt @@ -0,0 +1,79 @@ +package org.exploit.keeper.authority.bitcoin.intent + +import dev.cel.common.types.SimpleType +import org.exploit.keeper.authority.bitcoin.artifact.UtxoInput +import org.exploit.keeper.authority.bitcoin.util.BitcoinUtxoUtils +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.model.command.AuthorityCommand +import org.exploit.signet.bitcoin.codec.BitcoinTransactionCodec +import org.exploit.signet.bitcoin.model.transaction.BitcoinOutPoint +import org.exploit.signet.bitcoin.model.transaction.BitcoinTransactionInput +import org.exploit.test.authority.AuthorityPayloads +import org.exploit.verdict.authority.model.Authority +import org.exploit.verdict.authority.model.CompiledAuthority +import org.exploit.verdict.exception.IntentValidationException +import org.exploit.verdict.intent.bitcoin.config.BitcoinIntentConfig +import org.exploit.verdict.intent.bitcoin.BitcoinSigningIntent +import org.exploit.verdict.model.Policy +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class BitcoinIntentPolicySchemaTest { + @Test + fun `policy schema exactly matches runtime intent roots`() { + val config = mapOf("network" to "MAINNET") + val publicKey64 = Base64.getEncoder().encodeToString(ByteArray(33) { 1 }) + val payload = AuthorityPayloads.bitcoinP2pkhSpend(publicKey64, outputSats = 100_000) + val intent = BitcoinSigningIntent.fromBase64( + payload.transaction64, + payload.previousTransactions64, + BitcoinIntentConfig.fromMap(config) + ) + + val schema = BitcoinTransactionIntentBuilder().policySchema(config) + + assertEquals(intent.variables().keys, schema.keys) + assertTrue(schema.values.all { it == SimpleType.DYN }) + } + + @Test + fun `builder exposes the configured network and only the selected signing input`() { + val payload = AuthorityPayloads.bitcoinP2pkhSpend(Base64.getEncoder().encodeToString(ByteArray(33) { 2 }), 100_000) + val tx = BitcoinUtxoUtils.parseTx(ErrorType.INVALID_AUTHORITY_ARTIFACT, payload.transaction64) + val previous = BitcoinUtxoUtils.parseTx(ErrorType.INVALID_AUTHORITY_ARTIFACT, payload.previousTransactions64.single()) + val secondPrevious = org.exploit.signet.bitcoin.model.transaction.BitcoinTransaction( + previous.version(), previous.inputs(), previous.outputs(), 1 + ) + val first = tx.inputs().single() + val second = BitcoinTransactionInput(BitcoinOutPoint(secondPrevious.hash(), 0), first.scriptSig(), first.sequence()) + val transaction64 = Base64.getEncoder().encodeToString(BitcoinTransactionCodec.serialize(tx.withInputs(listOf(first, second)))) + val previous64 = payload.previousTransactions64 + Base64.getEncoder().encodeToString(BitcoinTransactionCodec.serialize(secondPrevious)) + val builder = BitcoinTransactionIntentBuilder() + + for (network in listOf("MAINNET", "TESTNET", "REGTEST", "SIGNET")) { + val config = mapOf("network" to network) + builder.validateConfig(config) + val authority = Authority(Authority.SCHEMA_VERSION, "btc", builder.type(), "1.0.0", emptyMap(), config, + Policy.denyByDefault("btc").build()) + val compiled = CompiledAuthority(authority, null, null) + val command = AuthorityCommand.of("btc", UtxoInput(transaction64, previous64, 1)) + val variables = builder.buildIntent(command, compiled).variables() + + assertEquals(network, variables["network"]) + assertEquals(builder.policySchema(config).keys, variables.keys) + assertEquals(mapOf("inputIndexes" to listOf(1L), "inputIndex" to 1L, "single" to true, "allInputs" to false), + variables["signing"]) + val inputs = variables["inputs"] as List<*> + assertEquals(false, (inputs[0] as Map<*, *>)["willSign"]) + assertEquals(true, (inputs[1] as Map<*, *>)["willSign"]) + + assertFailsWith { + builder.buildIntent(AuthorityCommand.of("btc", UtxoInput(transaction64, previous64, 2)), compiled) + } + } + assertFailsWith { builder.validateConfig(mapOf("protocol" to "BTC")) } + } +} diff --git a/features/digital-assets/bitcoin/src/test/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinScriptPolicyTest.kt b/features/digital-assets/bitcoin/src/test/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinScriptPolicyTest.kt new file mode 100644 index 00000000..99317575 --- /dev/null +++ b/features/digital-assets/bitcoin/src/test/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinScriptPolicyTest.kt @@ -0,0 +1,60 @@ +package org.exploit.keeper.authority.bitcoin.util + +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.signet.bitcoin.model.transaction.BitcoinScript +import org.exploit.signet.crypto.util.HashUtil +import java.util.HexFormat +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class BitcoinScriptPolicyTest { + private val publicKey = HexFormat.of().parseHex("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + private val hash = HashUtil.hash160(publicKey) + private val hashHex = HexFormat.of().formatHex(hash) + + @Test + fun `standard scripts preserve their commitments and key membership`() { + val scripts = mapOf( + "p2pkh" to "76a914${hashHex}88ac", + "p2sh" to "a914${hashHex}87", + "p2wpkh" to "0014$hashHex", + "p2wsh" to "0020${"12".repeat(32)}", + "p2tr" to "5120${"12".repeat(32)}" + ) + for ((type, hex) in scripts) { + val info = BitcoinScriptPolicy.classify(BitcoinScript(hex)) + assertEquals(type, info.type()) + assertContentEquals(if (type in listOf("p2wsh", "p2tr")) ByteArray(32) { 0x12 } else hash, + BitcoinScriptPolicy.hash(info)) + } + assertEquals(scripts["p2pkh"], BitcoinUtxoUtils.p2wpkhScriptCode(hash).hex()) + for (type in listOf("p2pkh", "p2wpkh")) { + val script = BitcoinScript(scripts.getValue(type)) + BitcoinScriptPolicy.validateKnownScriptMembership(script, publicKey, hash) + assertEquals(ErrorType.INVALID_AUTHORITY_ARTIFACT, assertFailsWith { + BitcoinScriptPolicy.validateKnownScriptMembership(script, publicKey, ByteArray(20)) + }.errorType) + } + } + + @Test + fun `multisig accepts member keys and rejects nonmembers and malformed templates`() { + val keyHex = HexFormat.of().formatHex(publicKey) + // Both direct pushes and PUSHDATA1 were accepted by the previous implementation. + for (push in listOf("21", "4c21")) { + val script = BitcoinScript("51${push}${keyHex}51ae") + BitcoinScriptPolicy.validateKnownScriptMembership(script, publicKey, hash) + assertEquals(ErrorType.INVALID_AUTHORITY_ARTIFACT, assertFailsWith { + BitcoinScriptPolicy.validateKnownScriptMembership(script, ByteArray(33) { 3 }, hash) + }.errorType) + } + for (hex in listOf("5121${keyHex}52ae", "5221${keyHex}51ae", "514cffae", "51", "6a")) { + assertEquals(ErrorType.INVALID_AUTHORITY_ARTIFACT, assertFailsWith { + BitcoinScriptPolicy.validateKnownScriptMembership(BitcoinScript(hex), publicKey, hash) + }.errorType) + } + } +} diff --git a/features/digital-assets/bitcoin/src/test/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoUtilsTest.kt b/features/digital-assets/bitcoin/src/test/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoUtilsTest.kt new file mode 100644 index 00000000..2ea170f8 --- /dev/null +++ b/features/digital-assets/bitcoin/src/test/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoUtilsTest.kt @@ -0,0 +1,110 @@ +package org.exploit.keeper.authority.bitcoin.util + +import org.bitcoinj.core.Transaction +import org.bitcoinj.script.Script +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.signet.bitcoin.codec.BitcoinSignatureHash +import org.exploit.signet.bitcoin.constant.BitcoinSigHash +import org.exploit.signet.bitcoin.model.transaction.* +import org.exploit.signet.bitcoin.model.utxo.BitcoinUtxo +import org.exploit.test.authority.AuthorityPayloads +import java.nio.ByteBuffer +import java.util.Base64 +import java.util.HexFormat +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class BitcoinUtxoUtilsTest { + private val payload = AuthorityPayloads.bitcoinP2pkhSpend(base64(ByteArray(33) { 2 }), 100_000) + private val tx = BitcoinUtxoUtils.parseTx(ErrorType.INVALID_AUTHORITY_ARTIFACT, payload.transaction64) + private val previous = BitcoinUtxoUtils.parsePreviousTransactions(payload.previousTransactions64) + + @Test + fun `Signet preserves transaction ids prevouts and ECDSA sighashes`() { + val oldTx = Transaction.read(ByteBuffer.wrap(Base64.getDecoder().decode(payload.transaction64))) + val oldPrevious = Transaction.read(ByteBuffer.wrap(Base64.getDecoder().decode(payload.previousTransactions64.single()))) + val prevout = BitcoinUtxoUtils.resolvePrevouts(tx, previous).single() + val script = prevout.scriptPubKey() + + assertEquals(oldTx.txId.toString(), tx.hash()) + assertEquals(oldPrevious.txId.toString(), prevout.outPoint().txid()) + assertEquals(110_000L, prevout.value()) + assertContentEquals(oldPrevious.outputs.single().scriptBytes, script.bytes()) + assertContentEquals( + oldTx.hashForSignature(0, Script.parse(script.bytes()), Transaction.SigHash.ALL, false).bytes, + BitcoinSignatureHash.legacy(tx, 0, script, BitcoinSigHash.ALL) + ) + assertContentEquals( + oldTx.hashForWitnessSignature(0, Script.parse(script.bytes()), oldPrevious.outputs.single().value, + Transaction.SigHash.ALL, false).bytes, + BitcoinSignatureHash.segwitV0(tx, 0, script, prevout.value(), BitcoinSigHash.ALL) + ) + } + + @Test + fun `Taproot ALL preserves the previous digest for multiple inputs`() { + val script = BitcoinScript("5120" + "12".repeat(32)) + val inputs = listOf( + BitcoinTransactionInput(BitcoinOutPoint("01".repeat(32), 3), BitcoinScript.EMPTY, 0xfffffffdL), + BitcoinTransactionInput(BitcoinOutPoint("02".repeat(32), 7), BitcoinScript.EMPTY, 0xfffffffeL) + ) + val transaction = BitcoinTransaction(2, inputs, listOf(BitcoinTransactionOutput(300_000, script)), 500) + val spent = listOf( + BitcoinUtxo(inputs[0].outPoint(), 110_000, script, 0), + BitcoinUtxo(inputs[1].outPoint(), 220_000, script, 0) + ) + + assertEquals("aabe7ef8f98d81e7d2635c3142186109df24aa7d5ca4ae550f1be91e922d465b", + HexFormat.of().formatHex(BitcoinSignatureHash.taprootKeyPath(transaction, spent, 1, BitcoinSigHash.ALL))) + assertFailsWith { + BitcoinSignatureHash.taprootKeyPath(transaction, spent.reversed(), 1, BitcoinSigHash.ALL) + } + } + + @Test + fun `malformed transactions and ambiguous previous transactions are rejected`() { + val bytes = Base64.getDecoder().decode(payload.transaction64) + for (value in listOf("!", base64(bytes + byteArrayOf(0)), base64(bytes.copyOf(bytes.size - 1)))) { + invalidArtifact { BitcoinUtxoUtils.parseTx(ErrorType.INVALID_AUTHORITY_ARTIFACT, value) } + } + invalidArtifact { BitcoinUtxoUtils.parsePreviousTransactions(payload.previousTransactions64 + payload.previousTransactions64) } + invalidArtifact { BitcoinUtxoUtils.resolvePrevouts(tx, emptyMap()) } + for (index in listOf(-1, tx.inputs().size)) { + invalidArtifact { BitcoinUtxoUtils.validateInputIndex(tx, index) } + } + } + + @Test + fun `unsigned validation rejects both scriptSig and witness`() { + val input = tx.inputs().single() + val scriptSig = BitcoinTransactionInput(input.outPoint(), BitcoinScript("51"), input.sequence()) + val witness = BitcoinTransactionInput(input.outPoint(), BitcoinScript.EMPTY, input.sequence(), + BitcoinWitness(listOf(byteArrayOf(1)))) + + BitcoinUtxoUtils.validateUnsigned(tx) + for (signedInput in listOf(scriptSig, witness)) { + invalidArtifact { BitcoinUtxoUtils.validateUnsigned(tx.withInputs(listOf(signedInput))) } + } + } + + @Test + fun `coinbase and out of range uint32 output indexes are rejected`() { + val coinbase = previous.values.single() + invalidArtifact { BitcoinUtxoUtils.validateInputIndex(coinbase, 0) } + invalidArtifact { BitcoinUtxoUtils.resolvePrevouts(coinbase, previous) } + + for (index in listOf(1L, 0x80000000L, 0xffffffffL)) { + val input = BitcoinTransactionInput(BitcoinOutPoint(previous.keys.single(), index), BitcoinScript.EMPTY, 0) + invalidArtifact { BitcoinUtxoUtils.resolvePrevouts(tx.withInputs(listOf(input)), previous) } + } + } + + private fun invalidArtifact(block: () -> Unit) { + assertEquals(ErrorType.INVALID_AUTHORITY_ARTIFACT, assertFailsWith(block = block).errorType) + } + + private fun base64(bytes: ByteArray) = Base64.getEncoder().encodeToString(bytes) +} diff --git a/features/digital-assets/build.gradle b/features/digital-assets/build.gradle new file mode 100644 index 00000000..d9c871ac --- /dev/null +++ b/features/digital-assets/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'base' +} + +tasks.named('assemble') { + dependsOn ':features:digital-assets:bitcoin:assemble', ':features:digital-assets:evm:assemble', + ':features:digital-assets:tron:assemble', ':features:digital-assets:xrp:assemble', + ':features:digital-assets:solana:assemble' +} + +tasks.named('check') { + dependsOn ':features:digital-assets:bitcoin:check', ':features:digital-assets:evm:check', + ':features:digital-assets:tron:check', ':features:digital-assets:xrp:check', + ':features:digital-assets:solana:check' +} diff --git a/features/authority-bitcoin/build.gradle b/features/digital-assets/evm/build.gradle similarity index 90% rename from features/authority-bitcoin/build.gradle rename to features/digital-assets/evm/build.gradle index 90b72376..b50e6fc3 100644 --- a/features/authority-bitcoin/build.gradle +++ b/features/digital-assets/evm/build.gradle @@ -1,11 +1,11 @@ plugins { id 'org.jetbrains.kotlin.jvm' 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' } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() @@ -35,11 +35,12 @@ dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test' testImplementation testFixtures(project(':integration-tests')) testImplementation(project(':')) + testImplementation(project(':platform-ecc')) compileOnly(project(':')) compileOnly(project(':platform-ecc')) ksp "io.micronaut:micronaut-inject-kotlin:4.10.8" - implementation 'org.exploit:verdict-intent-bitcoin:0.1.2' + implementation 'org.exploit.verdict:evm:0.2.0' } tasks.named('shadowJar') { diff --git a/features/authority-evm/src/main/kotlin/org/exploit/keeper/authority/evm/EvmCommandMaterializer.kt b/features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/EvmCommandMaterializer.kt similarity index 66% rename from features/authority-evm/src/main/kotlin/org/exploit/keeper/authority/evm/EvmCommandMaterializer.kt rename to features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/EvmCommandMaterializer.kt index 43952972..32c19d3f 100644 --- a/features/authority-evm/src/main/kotlin/org/exploit/keeper/authority/evm/EvmCommandMaterializer.kt +++ b/features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/EvmCommandMaterializer.kt @@ -3,7 +3,6 @@ package org.exploit.keeper.authority.evm import jakarta.inject.Singleton import org.exploit.keeper.authority.evm.artifact.UnsignedEvmTransaction 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 @@ -13,9 +12,9 @@ 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.keeper.util.extension.crypto.decodeBase64 +import org.exploit.signet.evm.model.transaction.EvmUnsignedTransaction +import org.exploit.signet.evm.transaction.parser.EvmTransactionParser import org.exploit.verdict.intent.IntentTypes -import org.web3j.crypto.SignedRawTransaction -import org.web3j.crypto.TransactionDecoder import org.web3j.utils.Numeric @Singleton @@ -24,7 +23,7 @@ class EvmCommandMaterializer(private val db: RocksKeyDB): CommandMaterializer { return IntentTypes.EVM_TRANSACTION } - override fun toSigningMaterial(keyId: String, generation: Int, c: Command): SigningMaterial { + override fun toSigningMaterial(keyId: String, generation: Int, c: Command, tweak: String?): SigningMaterial { val artifact = c.artifact as? UnsignedEvmTransaction ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Not EVM unsigned transaction") @@ -36,22 +35,22 @@ class EvmCommandMaterializer(private val db: RocksKeyDB): CommandMaterializer { if (algId != expected) throw TKeeperException(ErrorType.UNSUPPORTED_KEY_ALGORITHM, "$keyId is not secp256k1 curve") - val serialized = artifact.message64.decodeBase64() - - try { - val transaction = TransactionDecoder.decode(Numeric.toHexString(serialized)) - if (transaction is SignedRawTransaction) - throw TKeeperException( - ErrorType.INVALID_AUTHORITY_ARTIFACT, - "Expected unsigned EVM transaction" - ) - } catch (e: TKeeperException) { - throw e - } catch (e: RuntimeException) { - throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Invalid EVM transaction", e) - } + return DefaultSigningMaterial(ECDSASchemeProvider.NAME, parseUnsigned(artifact).signingBytes()) + } + + companion object { + private val parser = EvmTransactionParser() - val txn = HashMethod.KECCAK256.process(serialized) - return DefaultSigningMaterial(ECDSASchemeProvider.NAME, txn) + fun parseUnsigned(artifact: UnsignedEvmTransaction): EvmUnsignedTransaction { + val serialized = artifact.message64.decodeBase64() + + try { + return parser.parseUnsigned(Numeric.toHexString(serialized)) + } catch (e: TKeeperException) { + throw e + } catch (e: RuntimeException) { + throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Invalid unsigned EVM transaction", e) + } + } } } diff --git a/features/authority-evm/src/main/kotlin/org/exploit/keeper/authority/evm/artifact/UnsignedEvmTransaction.kt b/features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/artifact/UnsignedEvmTransaction.kt similarity index 100% rename from features/authority-evm/src/main/kotlin/org/exploit/keeper/authority/evm/artifact/UnsignedEvmTransaction.kt rename to features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/artifact/UnsignedEvmTransaction.kt diff --git a/features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/compose/EvmComposer.kt b/features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/compose/EvmComposer.kt new file mode 100644 index 00000000..5d478ee1 --- /dev/null +++ b/features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/compose/EvmComposer.kt @@ -0,0 +1,65 @@ +package org.exploit.keeper.authority.evm.compose + +import jakarta.inject.Singleton +import org.exploit.ecc.secp256k1.key.Secp256k1PublicKey +import org.exploit.ecc.secp256k1.params.Secp256k1CurveParams +import org.exploit.ecc.secp256k1.signature.Secp256k1RecoverableECDSASignature +import org.exploit.keeper.authority.evm.EvmCommandMaterializer +import org.exploit.keeper.authority.evm.artifact.UnsignedEvmTransaction +import org.exploit.keeper.authority.evm.outcome.SignedEvmTransaction +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.platform.ecc.algorithm.Secp256k1AlgorithmProvider +import org.exploit.keeper.platform.ecc.scheme.ECDSASchemeProvider +import org.exploit.keeper.service.compose.spi.Composer +import org.exploit.keeper.service.pub.PublicKeyService +import org.exploit.signet.evm.model.transaction.signature.EvmSignature +import org.exploit.verdict.intent.IntentTypes +import java.util.Base64 + +@Singleton +class EvmComposer( + private val publicKeys: PublicKeyService +): Composer { + override val type: String = IntentTypes.EVM_TRANSACTION + + override fun compose(request: Sign, signature: CalculatedSignature): SignedEvmTransaction { + val artifact = request.command.artifact as? UnsignedEvmTransaction + ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Not EVM unsigned transaction") + + val unsigned = EvmCommandMaterializer.parseUnsigned(artifact) + val publicKey = publicKeys.forAlgorithm(Secp256k1AlgorithmProvider.NAME) + .getPublicKey(request.keyId, signature.generation, request.tweak) as? Secp256k1PublicKey + ?: throw TKeeperException(ErrorType.KEY_ALGORITHM_MISMATCH, "Expected Secp256k1PublicKey") + + val signed = unsigned.sign(evmSignature(signature, unsigned.signingBytes(), publicKey)) + + return SignedEvmTransaction(type, signed.dump(), signed.hash(), signature.generation, signature.imposters) + } + + private fun evmSignature( + result: CalculatedSignature, + digest: ByteArray, + publicKey: Secp256k1PublicKey + ): EvmSignature { + if (result.type != ECDSASchemeProvider.NAME || result.signature64 == null) + throw TKeeperException(ErrorType.INVALID_SIGNATURE, "Recoverable ECDSA signature is missing") + + return try { + val raw = Base64.getDecoder().decode(result.signature64) + val signature = Secp256k1RecoverableECDSASignature.decode(raw) + val recId = signature.recId().toInt() + val expected = Secp256k1CurveParams.getInstance() + .findRecId(signature.r(), signature.s(), digest, publicKey.Y()) + + if (expected != recId) + throw IllegalArgumentException("Signature does not match the Keeper key") + + EvmSignature(signature.r().toJavaInt(), signature.s().toJavaInt(), recId) + } catch (e: IllegalArgumentException) { + throw TKeeperException(ErrorType.INVALID_SIGNATURE, "Invalid EVM signature", e) + } + } +} diff --git a/features/authority-evm/src/main/kotlin/org/exploit/keeper/authority/evm/deserializer/UnsignedEvmTransactionDeserializer.kt b/features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/deserializer/UnsignedEvmTransactionDeserializer.kt similarity index 80% rename from features/authority-evm/src/main/kotlin/org/exploit/keeper/authority/evm/deserializer/UnsignedEvmTransactionDeserializer.kt rename to features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/deserializer/UnsignedEvmTransactionDeserializer.kt index 9f59e575..dc1c0da7 100644 --- a/features/authority-evm/src/main/kotlin/org/exploit/keeper/authority/evm/deserializer/UnsignedEvmTransactionDeserializer.kt +++ b/features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/deserializer/UnsignedEvmTransactionDeserializer.kt @@ -8,6 +8,7 @@ import org.exploit.keeper.util.extension.requireOnly import org.exploit.keeper.util.extension.requireText import org.exploit.keeper.model.command.artifact.Artifact import org.exploit.keeper.service.command.deserializer.ArtifactDeserializer +import org.exploit.keeper.service.command.deserializer.ArtifactSchemas import org.exploit.verdict.intent.IntentTypes @Singleton @@ -16,6 +17,10 @@ class UnsignedEvmTransactionDeserializer: ArtifactDeserializer { return IntentTypes.EVM_TRANSACTION } + override fun schema(config: Map): Map = + ArtifactSchemas.stringField("message64", "Base64 encoded unsigned EVM transaction") + + override fun deserialize(node: JsonNode, ctx: DeserializationContext): Artifact { node.requireOnly(ctx, "message64") return UnsignedEvmTransaction( diff --git a/features/authority-evm/src/main/kotlin/org/exploit/keeper/authority/evm/intent/EvmIntentBuilder.kt b/features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/intent/EvmIntentBuilder.kt similarity index 96% rename from features/authority-evm/src/main/kotlin/org/exploit/keeper/authority/evm/intent/EvmIntentBuilder.kt rename to features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/intent/EvmIntentBuilder.kt index 0914a9a3..a32bb9a5 100644 --- a/features/authority-evm/src/main/kotlin/org/exploit/keeper/authority/evm/intent/EvmIntentBuilder.kt +++ b/features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/intent/EvmIntentBuilder.kt @@ -11,8 +11,8 @@ import org.exploit.keeper.service.command.intent.IntentBuilder import org.exploit.verdict.authority.model.CompiledAuthority import org.exploit.verdict.intent.Intent import org.exploit.verdict.intent.IntentTypes -import org.exploit.verdict.intent.evm.EvmIntentConfig import org.exploit.verdict.intent.evm.EvmTransactionIntent +import org.exploit.verdict.intent.evm.config.EvmIntentConfig @Singleton class EvmIntentBuilder: IntentBuilder { diff --git a/features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/outcome/SignedEvmTransaction.kt b/features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/outcome/SignedEvmTransaction.kt new file mode 100644 index 00000000..0c6f69b1 --- /dev/null +++ b/features/digital-assets/evm/src/main/kotlin/org/exploit/keeper/authority/evm/outcome/SignedEvmTransaction.kt @@ -0,0 +1,12 @@ +package org.exploit.keeper.authority.evm.outcome + +import org.exploit.keeper.model.capability.HasImposters +import org.exploit.keeper.model.outcome.Outcome + +data class SignedEvmTransaction( + val type: String, + val rawTransaction: String, + val transactionHash: String, + val generation: Int, + override val imposters: List +): Outcome, HasImposters diff --git a/features/digital-assets/evm/src/test/kotlin/org/exploit/keeper/authority/evm/compose/EvmComposerTest.kt b/features/digital-assets/evm/src/test/kotlin/org/exploit/keeper/authority/evm/compose/EvmComposerTest.kt new file mode 100644 index 00000000..ee50fcd1 --- /dev/null +++ b/features/digital-assets/evm/src/test/kotlin/org/exploit/keeper/authority/evm/compose/EvmComposerTest.kt @@ -0,0 +1,145 @@ +package org.exploit.keeper.authority.evm.compose + +import org.exploit.bigint.LibGMP +import org.exploit.crypto.spi.key.PublicKey +import org.exploit.ecc.secp256k1.LibSecp256k1 +import org.exploit.ecc.secp256k1.key.Secp256k1PrivateKey +import org.exploit.ecc.secp256k1.key.Secp256k1PublicKey +import org.exploit.ecc.secp256k1.signer.ecdsa.Secp256k1ECDSASigner +import org.exploit.keeper.authority.evm.artifact.UnsignedEvmTransaction +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.model.command.AuthorityCommand +import org.exploit.keeper.model.internal.Round +import org.exploit.keeper.platform.ecc.algorithm.Secp256k1AlgorithmProvider +import org.exploit.keeper.service.pub.PublicKeyService +import org.exploit.keeper.service.pub.PublicKeyServiceProvider +import org.exploit.signet.evm.model.auth.EvmAuthorizationTuple +import org.exploit.signet.evm.model.rpc.HexData +import org.exploit.signet.evm.model.transaction.EvmUnsignedTransaction +import org.exploit.signet.evm.model.transaction.options.EvmTransactionOptions +import org.exploit.signet.evm.transaction.parser.EvmTransactionParser +import org.junit.jupiter.api.BeforeAll +import java.math.BigInteger +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class EvmComposerTest { + @Test + fun `composes every Signet transaction type`() { + Secp256k1PrivateKey.fromBytes(ByteArray(32).also { it[31] = 0x46 }).use { key -> + val composer = composer(key.publicKey()) + val gas = EvmTransactionOptions.DynamicFee(BigInteger.ONE, BigInteger.TWO, emptyList()) + val options = listOf( + EvmTransactionOptions.Legacy(BigInteger.TWO), + EvmTransactionOptions.AccessList(BigInteger.TWO, emptyList()), + gas, + EvmTransactionOptions.Blob(gas, BigInteger.ONE, listOf("0x01" + "00".repeat(31))), + EvmTransactionOptions.SetCode(gas, listOf(EvmAuthorizationTuple( + BigInteger.ONE, RECIPIENT, BigInteger.ZERO, 0, BigInteger.ONE, BigInteger.ONE + ))) + ) + + for (option in options) { + val unsigned = unsigned(option) + val signature = Secp256k1ECDSASigner().sign(unsigned.signingBytes(), key) + val result = composer.compose(request(unsigned), result(signature.encode())) + val parsed = EvmTransactionParser().parseSigned(result.rawTransaction) + + assertEquals(option.type(), parsed.type()) + assertEquals(parsed.hash(), result.transactionHash) + assertEquals("evm.transaction", result.type) + assertEquals(2, result.generation) + assertEquals(listOf("peer-3"), result.imposters) + + } + } + } + + @Test + fun `rejects malformed signatures and signatures from another key`() { + Secp256k1PrivateKey.fromBytes(ByteArray(32).also { it[31] = 0x46 }).use { key -> + Secp256k1PrivateKey.fromBytes(ByteArray(32).also { it[31] = 0x47 }).use { other -> + val unsigned = unsigned(EvmTransactionOptions.Legacy(BigInteger.TWO)) + val request = request(unsigned) + val signature = Secp256k1ECDSASigner().sign(unsigned.signingBytes(), key) + val base = result(signature.encode()) + + val invalid = listOf( + base.copy(type = "EdDSA"), + base.copy(signature64 = null), + base.copy(signature64 = "!"), + result(signature.encode().copyOf(64)), + result(signature.encode().also { it[64] = 3 }) + ) + + for (value in invalid) { + assertEquals(ErrorType.INVALID_SIGNATURE, assertFailsWith { + composer(key.publicKey()).compose(request, value) + }.errorType) + } + + assertEquals(ErrorType.INVALID_SIGNATURE, assertFailsWith { + composer(other.publicKey()).compose(request, base) + }.errorType) + } + } + } + + private fun unsigned(options: EvmTransactionOptions) = EvmUnsignedTransaction( + BigInteger.ZERO, + BigInteger.valueOf(21_000), + RECIPIENT, + BigInteger.ONE, + HexData("0x"), + BigInteger.ONE, + 0, + options + ) + + private fun request(unsigned: EvmUnsignedTransaction): Sign { + val artifact = UnsignedEvmTransaction(Base64.getEncoder().encodeToString(unsigned.signingData())) + return Sign("evm-test", AuthorityCommand.of("evm-authority", artifact)) + } + + private fun result(bytes: ByteArray) = CalculatedSignature( + "ECDSA", Base64.getEncoder().encodeToString(bytes), 2, listOf("peer-3") + ) + + private fun composer(publicKey: Secp256k1PublicKey): EvmComposer { + val provider = object: PublicKeyServiceProvider { + override fun algorithms() = setOf(Secp256k1AlgorithmProvider.NAME) + + override fun getPublicKey( + keyId: String, + version: Int?, + tweak: String?, + lastRound: Round? + ): PublicKey = publicKey + + override fun fetchRound( + keyId: String, + version: Int?, + tweak: String?, + lastRound: Round? + ): Round = Round(publicKey) + } + + return EvmComposer(PublicKeyService(listOf(provider))) + } + + companion object { + private const val RECIPIENT = "0x3535353535353535353535353535353535353535" + + @BeforeAll + @JvmStatic + fun loadNative() { + LibGMP.load() + LibSecp256k1.load() + } + } +} diff --git a/features/authority-evm/src/test/kotlin/org/exploit/keeper/authority/evm/intent/EvmIntentPolicySchemaTest.kt b/features/digital-assets/evm/src/test/kotlin/org/exploit/keeper/authority/evm/intent/EvmIntentPolicySchemaTest.kt similarity index 93% rename from features/authority-evm/src/test/kotlin/org/exploit/keeper/authority/evm/intent/EvmIntentPolicySchemaTest.kt rename to features/digital-assets/evm/src/test/kotlin/org/exploit/keeper/authority/evm/intent/EvmIntentPolicySchemaTest.kt index 72f87faa..5c005e62 100644 --- a/features/authority-evm/src/test/kotlin/org/exploit/keeper/authority/evm/intent/EvmIntentPolicySchemaTest.kt +++ b/features/digital-assets/evm/src/test/kotlin/org/exploit/keeper/authority/evm/intent/EvmIntentPolicySchemaTest.kt @@ -2,8 +2,8 @@ package org.exploit.keeper.authority.evm.intent import dev.cel.common.types.SimpleType import org.exploit.test.authority.AuthorityPayloads -import org.exploit.verdict.intent.evm.EvmIntentConfig import org.exploit.verdict.intent.evm.EvmTransactionIntent +import org.exploit.verdict.intent.evm.config.EvmIntentConfig import java.math.BigInteger import kotlin.test.Test import kotlin.test.assertEquals diff --git a/features/digital-assets/solana/build.gradle b/features/digital-assets/solana/build.gradle new file mode 100644 index 00000000..06a04d45 --- /dev/null +++ b/features/digital-assets/solana/build.gradle @@ -0,0 +1,66 @@ +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.toLowerCase(java.util.Locale.ROOT).contains('runtime') }.configureEach { + jacksonGroups.each { groupName -> exclude group: groupName } +} + +dependencies { + testImplementation 'org.jetbrains.kotlin:kotlin-test' + testImplementation testFixtures(project(':integration-tests')) + testImplementation(project(':')) + testImplementation(project(':platform-ecc')) + compileOnly(project(':')) + compileOnly(project(':platform-ecc')) + + ksp "io.micronaut:micronaut-inject-kotlin:4.10.8" + implementation 'org.exploit.verdict:solana: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/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/SolanaCommandMaterializer.kt b/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/SolanaCommandMaterializer.kt new file mode 100644 index 00000000..be2e8e7d --- /dev/null +++ b/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/SolanaCommandMaterializer.kt @@ -0,0 +1,53 @@ +package org.exploit.keeper.authority.solana + +import jakarta.inject.Singleton +import org.exploit.ecc.ed25519.key.Ed25519PublicKey +import org.exploit.keeper.authority.solana.artifact.UnsignedSolanaTransaction +import org.exploit.keeper.constant.common.ErrorType +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.platform.ecc.algorithm.Ed25519AlgorithmProvider +import org.exploit.keeper.platform.ecc.scheme.EdDSASchemeProvider +import org.exploit.keeper.service.command.CommandMaterializer +import org.exploit.keeper.service.db.RocksKeyDB +import org.exploit.keeper.service.pub.PublicKeyService +import org.exploit.signet.solana.util.SolanaEncoding +import org.exploit.verdict.exception.IntentValidationException +import org.exploit.verdict.intent.IntentTypes +import org.exploit.verdict.intent.solana.util.SolanaTransactionReader + +@Singleton +class SolanaCommandMaterializer( + private val db: RocksKeyDB, + private val publicKeys: PublicKeyService +): CommandMaterializer { + override fun type(): String = IntentTypes.SOLANA_TRANSACTION + + override fun toSigningMaterial(keyId: String, generation: Int, c: Command, tweak: String?): SigningMaterial { + val artifact = c.artifact as? UnsignedSolanaTransaction + ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT) + val algorithm = db.vault.extractAlgorithm(keyId, generation) + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, keyId) + + if (algorithm != Ed25519AlgorithmProvider.ID) + throw TKeeperException(ErrorType.UNSUPPORTED_KEY_ALGORITHM, "Solana requires Ed25519") + + val publicKey = publicKeys.forAlgorithm(Ed25519AlgorithmProvider.NAME) + .getPublicKey(keyId, generation, tweak) as? Ed25519PublicKey + ?: throw TKeeperException(ErrorType.KEY_ALGORITHM_MISMATCH) + val transaction = try { + SolanaTransactionReader.parse(artifact.transaction) + } catch (e: IntentValidationException) { + throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, e.reason(), e) + } + + val message = transaction.message() + val signers = message.accountKeys().take(message.header().requiredSignatures()) + if (SolanaEncoding.base58(publicKey.encode()) !in signers) + throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Solana key is not a required signer") + + return DefaultSigningMaterial(EdDSASchemeProvider.NAME, transaction.signingBytes()) + } +} diff --git a/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/artifact/UnsignedSolanaTransaction.kt b/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/artifact/UnsignedSolanaTransaction.kt new file mode 100644 index 00000000..23ea1c85 --- /dev/null +++ b/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/artifact/UnsignedSolanaTransaction.kt @@ -0,0 +1,8 @@ +package org.exploit.keeper.authority.solana.artifact + +import org.exploit.keeper.model.command.artifact.Artifact +import org.exploit.verdict.intent.IntentTypes + +class UnsignedSolanaTransaction(val transaction: String): Artifact { + override fun type(): String = IntentTypes.SOLANA_TRANSACTION +} diff --git a/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/compose/SolanaComposer.kt b/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/compose/SolanaComposer.kt new file mode 100644 index 00000000..6eb8a87c --- /dev/null +++ b/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/compose/SolanaComposer.kt @@ -0,0 +1,48 @@ +package org.exploit.keeper.authority.solana.compose + +import jakarta.inject.Singleton +import org.exploit.ecc.ed25519.key.Ed25519PublicKey +import org.exploit.keeper.authority.solana.artifact.UnsignedSolanaTransaction +import org.exploit.keeper.authority.solana.outcome.SignedSolanaTransaction +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.platform.ecc.algorithm.Ed25519AlgorithmProvider +import org.exploit.keeper.platform.ecc.scheme.EdDSASchemeProvider +import org.exploit.keeper.service.compose.spi.Composer +import org.exploit.keeper.service.pub.PublicKeyService +import org.exploit.signet.solana.model.transaction.SolanaSignedTransaction +import org.exploit.signet.solana.util.SolanaEncoding +import org.exploit.verdict.intent.IntentTypes +import org.exploit.verdict.intent.solana.util.SolanaTransactionReader +import java.util.Base64 + +@Singleton +class SolanaComposer(private val publicKeys: PublicKeyService): Composer { + override val type: String = IntentTypes.SOLANA_TRANSACTION + + override fun compose(request: Sign, signature: CalculatedSignature): SignedSolanaTransaction { + val artifact = request.command.artifact as? UnsignedSolanaTransaction + ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT) + val unsigned = SolanaTransactionReader.parse(artifact.transaction) + val publicKey = publicKeys.forAlgorithm(Ed25519AlgorithmProvider.NAME) + .getPublicKey(request.keyId, signature.generation, request.tweak) as? Ed25519PublicKey + ?: throw TKeeperException(ErrorType.KEY_ALGORITHM_MISMATCH) + val signer = SolanaEncoding.base58(publicKey.encode()) + + val signed = try { + if (signature.type != EdDSASchemeProvider.NAME || signature.signature64 == null) + throw IllegalArgumentException("Ed25519 signature is missing") + + unsigned.withSignature(signer, Base64.getDecoder().decode(signature.signature64)) + } catch (e: IllegalArgumentException) { + throw TKeeperException(ErrorType.INVALID_SIGNATURE, "Invalid Solana signature", e) + } + + return SignedSolanaTransaction( + type, signed.dump(), signed.hash(), signed is SolanaSignedTransaction, + signature.generation, signature.imposters + ) + } +} diff --git a/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/deserializer/UnsignedSolanaTransactionDeserializer.kt b/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/deserializer/UnsignedSolanaTransactionDeserializer.kt new file mode 100644 index 00000000..c88c0da5 --- /dev/null +++ b/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/deserializer/UnsignedSolanaTransactionDeserializer.kt @@ -0,0 +1,26 @@ +package org.exploit.keeper.authority.solana.deserializer + +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode +import jakarta.inject.Singleton +import org.exploit.keeper.authority.solana.artifact.UnsignedSolanaTransaction +import org.exploit.keeper.model.command.artifact.Artifact +import org.exploit.keeper.service.command.deserializer.ArtifactDeserializer +import org.exploit.keeper.service.command.deserializer.ArtifactSchemas +import org.exploit.keeper.util.extension.requireOnly +import org.exploit.keeper.util.extension.requireText +import org.exploit.verdict.intent.IntentTypes + +@Singleton +class UnsignedSolanaTransactionDeserializer: ArtifactDeserializer { + override fun type(): String = IntentTypes.SOLANA_TRANSACTION + + override fun schema(config: Map): Map = + ArtifactSchemas.stringField("transaction", "Base64 encoded unsigned Solana transaction") + + + override fun deserialize(node: JsonNode, ctx: DeserializationContext): Artifact { + node.requireOnly(ctx, "transaction") + return UnsignedSolanaTransaction(node.requireText(ctx, "transaction")) + } +} diff --git a/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/intent/SolanaIntentBuilder.kt b/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/intent/SolanaIntentBuilder.kt new file mode 100644 index 00000000..eb5524be --- /dev/null +++ b/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/intent/SolanaIntentBuilder.kt @@ -0,0 +1,40 @@ +package org.exploit.keeper.authority.solana.intent + +import dev.cel.common.types.CelType +import dev.cel.common.types.SimpleType +import jakarta.inject.Singleton +import org.exploit.keeper.authority.solana.artifact.UnsignedSolanaTransaction +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.model.command.AuthorityCommand +import org.exploit.keeper.service.command.intent.IntentBuilder +import org.exploit.verdict.authority.model.CompiledAuthority +import org.exploit.verdict.intent.Intent +import org.exploit.verdict.intent.IntentTypes +import org.exploit.verdict.intent.solana.SolanaTransactionIntent + +@Singleton +class SolanaIntentBuilder: IntentBuilder { + override fun type(): String = IntentTypes.SOLANA_TRANSACTION + + override fun validateConfig(config: Map) { + if (config.isNotEmpty()) + throw TKeeperException(ErrorType.INVALID_AUTHORITY, "Solana intent has no config") + } + + override fun policySchema(config: Map): Map = POLICY_SCHEMA + + override fun buildIntent(command: AuthorityCommand, compiled: CompiledAuthority): Intent { + val artifact = command.artifact as? UnsignedSolanaTransaction + ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT) + + return SolanaTransactionIntent.fromSerialized(artifact.transaction) + } + + private companion object { + val POLICY_SCHEMA: Map = setOf( + "version", "feePayer", "recentBlockhash", "requiredSignatures", + "computeUnitLimit", "computeUnitPrice", "instructions", "transaction", "effects" + ).associateWith { SimpleType.DYN } + } +} diff --git a/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/outcome/SignedSolanaTransaction.kt b/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/outcome/SignedSolanaTransaction.kt new file mode 100644 index 00000000..c8f512f9 --- /dev/null +++ b/features/digital-assets/solana/src/main/kotlin/org/exploit/keeper/authority/solana/outcome/SignedSolanaTransaction.kt @@ -0,0 +1,13 @@ +package org.exploit.keeper.authority.solana.outcome + +import org.exploit.keeper.model.capability.HasImposters +import org.exploit.keeper.model.outcome.Outcome + +data class SignedSolanaTransaction( + val type: String, + val rawTransaction: String, + val transactionHash: String?, + val complete: Boolean, + val generation: Int, + override val imposters: List +): Outcome, HasImposters diff --git a/features/digital-assets/tron/build.gradle b/features/digital-assets/tron/build.gradle new file mode 100644 index 00000000..c54a6b85 --- /dev/null +++ b/features/digital-assets/tron/build.gradle @@ -0,0 +1,66 @@ +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.toLowerCase(java.util.Locale.ROOT).contains('runtime') }.configureEach { + jacksonGroups.each { groupName -> exclude group: groupName } +} + +dependencies { + testImplementation 'org.jetbrains.kotlin:kotlin-test' + testImplementation testFixtures(project(':integration-tests')) + testImplementation(project(':')) + testImplementation(project(':platform-ecc')) + compileOnly(project(':')) + compileOnly(project(':platform-ecc')) + + ksp "io.micronaut:micronaut-inject-kotlin:4.10.8" + implementation 'org.exploit.verdict:tron: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/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/TronCommandMaterializer.kt b/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/TronCommandMaterializer.kt new file mode 100644 index 00000000..24ea02a3 --- /dev/null +++ b/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/TronCommandMaterializer.kt @@ -0,0 +1,69 @@ +package org.exploit.keeper.authority.tron + +import jakarta.inject.Singleton +import org.exploit.ecc.secp256k1.key.Secp256k1PublicKey +import org.exploit.keeper.authority.tron.artifact.UnsignedTronTransaction +import org.exploit.keeper.constant.common.ErrorType +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.platform.ecc.algorithm.Secp256k1AlgorithmProvider +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.keeper.service.pub.PublicKeyService +import org.exploit.signet.tron.accounts.address.TronAddresses +import org.exploit.signet.tron.model.transaction.TronUnsignedTransaction +import org.exploit.verdict.exception.IntentValidationException +import org.exploit.verdict.intent.IntentTypes +import org.exploit.verdict.intent.tron.util.TronTransactionReader + +@Singleton +class TronCommandMaterializer( + private val db: RocksKeyDB, + private val publicKeys: PublicKeyService +): CommandMaterializer { + override fun type(): String = IntentTypes.TRON_TRANSACTION + + override fun toSigningMaterial(keyId: String, generation: Int, c: Command, tweak: String?): SigningMaterial { + val artifact = c.artifact as? UnsignedTronTransaction + ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT) + val algorithm = db.vault.extractAlgorithm(keyId, generation) + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, keyId) + + if (algorithm != Secp256k1AlgorithmProvider.ID) + throw TKeeperException(ErrorType.UNSUPPORTED_KEY_ALGORITHM, "TRON requires secp256k1") + + val publicKey = publicKeys.forAlgorithm(Secp256k1AlgorithmProvider.NAME) + .getPublicKey(keyId, generation, tweak) as? Secp256k1PublicKey + ?: throw TKeeperException(ErrorType.KEY_ALGORITHM_MISMATCH) + val transaction = parseUnsigned(artifact) + requireOwner(transaction, publicKey) + + return DefaultSigningMaterial(ECDSASchemeProvider.NAME, transaction.signingBytes()) + } + + companion object { + fun parseUnsigned(artifact: UnsignedTronTransaction): TronUnsignedTransaction = try { + TronTransactionReader.parse(artifact.transaction) + } catch (e: IntentValidationException) { + throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, e.reason(), e) + } + + fun requireOwner(transaction: TronUnsignedTransaction, publicKey: Secp256k1PublicKey) { + val raw = transaction.rawData() + if (raw.contract().size != 1 || raw.auths().isNotEmpty()) + throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "TRON requires one owner-authorized contract") + + val contract = raw.contract().single() + if (contract.permissionId() != 0 || !contract.parameter().isSupported) + throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Unsupported TRON permission") + + val owner = TronAddresses.fromHex(contract.parameter().value().path("owner_address").asText()) + val expected = TronAddresses.fromPublicKey(publicKey, TronAddresses.descriptorOf(owner)) + if (owner != expected) + throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "TRON owner does not match signing key") + } + } +} diff --git a/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/artifact/UnsignedTronTransaction.kt b/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/artifact/UnsignedTronTransaction.kt new file mode 100644 index 00000000..84a5cc46 --- /dev/null +++ b/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/artifact/UnsignedTronTransaction.kt @@ -0,0 +1,8 @@ +package org.exploit.keeper.authority.tron.artifact + +import org.exploit.keeper.model.command.artifact.Artifact +import org.exploit.verdict.intent.IntentTypes + +class UnsignedTronTransaction(val transaction: String): Artifact { + override fun type(): String = IntentTypes.TRON_TRANSACTION +} diff --git a/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/compose/TronComposer.kt b/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/compose/TronComposer.kt new file mode 100644 index 00000000..b6e272d5 --- /dev/null +++ b/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/compose/TronComposer.kt @@ -0,0 +1,65 @@ +package org.exploit.keeper.authority.tron.compose + +import jakarta.inject.Singleton +import org.exploit.ecc.secp256k1.key.Secp256k1PublicKey +import org.exploit.ecc.secp256k1.params.Secp256k1CurveParams +import org.exploit.ecc.secp256k1.signature.Secp256k1RecoverableECDSASignature +import org.exploit.ecc.secp256k1.signer.ecdsa.Secp256k1ECDSAVerifier +import org.exploit.keeper.authority.tron.TronCommandMaterializer +import org.exploit.keeper.authority.tron.artifact.UnsignedTronTransaction +import org.exploit.keeper.authority.tron.outcome.SignedTronTransaction +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.platform.ecc.algorithm.Secp256k1AlgorithmProvider +import org.exploit.keeper.platform.ecc.scheme.ECDSASchemeProvider +import org.exploit.keeper.service.compose.spi.Composer +import org.exploit.keeper.service.pub.PublicKeyService +import org.exploit.signet.tron.model.transaction.TronUnsignedTransaction +import org.exploit.verdict.intent.IntentTypes +import java.util.Base64 + +@Singleton +class TronComposer(private val publicKeys: PublicKeyService): Composer { + override val type: String = IntentTypes.TRON_TRANSACTION + + override fun compose(request: Sign, signature: CalculatedSignature): SignedTronTransaction { + val artifact = request.command.artifact as? UnsignedTronTransaction + ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT) + val unsigned = TronCommandMaterializer.parseUnsigned(artifact) + val publicKey = publicKeys.forAlgorithm(Secp256k1AlgorithmProvider.NAME) + .getPublicKey(request.keyId, signature.generation, request.tweak) as? Secp256k1PublicKey + ?: throw TKeeperException(ErrorType.KEY_ALGORITHM_MISMATCH) + + TronCommandMaterializer.requireOwner(unsigned, publicKey) + val signed = unsigned.sign(validSignature(signature, unsigned, publicKey)) + + return SignedTronTransaction(type, signed.dump(), signed.hash(), signature.generation, signature.imposters) + } + + private fun validSignature( + result: CalculatedSignature, + transaction: TronUnsignedTransaction, + publicKey: Secp256k1PublicKey + ): ByteArray { + if (result.type != ECDSASchemeProvider.NAME || result.signature64 == null) + throw TKeeperException(ErrorType.INVALID_SIGNATURE, "Recoverable ECDSA signature is missing") + + try { + val raw = Base64.getDecoder().decode(result.signature64) + val signature = Secp256k1RecoverableECDSASignature.decode(raw) + val digest = transaction.signingBytes() + val recoveryId = Secp256k1CurveParams.getInstance() + .findRecId(signature.r(), signature.s(), digest, publicKey.Y()) + + if (recoveryId != signature.recId().toInt() + || !Secp256k1ECDSAVerifier().verify(digest, signature, publicKey)) + throw IllegalArgumentException("Signature does not match the TRON owner") + + return raw + } catch (e: IllegalArgumentException) { + throw TKeeperException(ErrorType.INVALID_SIGNATURE, "Invalid TRON signature", e) + } + } +} diff --git a/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/deserializer/UnsignedTronTransactionDeserializer.kt b/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/deserializer/UnsignedTronTransactionDeserializer.kt new file mode 100644 index 00000000..4e366883 --- /dev/null +++ b/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/deserializer/UnsignedTronTransactionDeserializer.kt @@ -0,0 +1,26 @@ +package org.exploit.keeper.authority.tron.deserializer + +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode +import jakarta.inject.Singleton +import org.exploit.keeper.authority.tron.artifact.UnsignedTronTransaction +import org.exploit.keeper.model.command.artifact.Artifact +import org.exploit.keeper.service.command.deserializer.ArtifactDeserializer +import org.exploit.keeper.service.command.deserializer.ArtifactSchemas +import org.exploit.keeper.util.extension.requireOnly +import org.exploit.keeper.util.extension.requireText +import org.exploit.verdict.intent.IntentTypes + +@Singleton +class UnsignedTronTransactionDeserializer: ArtifactDeserializer { + override fun type(): String = IntentTypes.TRON_TRANSACTION + + override fun schema(config: Map): Map = + ArtifactSchemas.stringField("transaction", "Unsigned TRON transaction JSON") + + + override fun deserialize(node: JsonNode, ctx: DeserializationContext): Artifact { + node.requireOnly(ctx, "transaction") + return UnsignedTronTransaction(node.requireText(ctx, "transaction")) + } +} diff --git a/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/intent/TronIntentBuilder.kt b/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/intent/TronIntentBuilder.kt new file mode 100644 index 00000000..6c11456d --- /dev/null +++ b/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/intent/TronIntentBuilder.kt @@ -0,0 +1,41 @@ +package org.exploit.keeper.authority.tron.intent + +import dev.cel.common.types.CelType +import dev.cel.common.types.SimpleType +import jakarta.inject.Singleton +import org.exploit.keeper.authority.tron.artifact.UnsignedTronTransaction +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.model.command.AuthorityCommand +import org.exploit.keeper.service.command.intent.IntentBuilder +import org.exploit.verdict.authority.model.CompiledAuthority +import org.exploit.verdict.intent.Intent +import org.exploit.verdict.intent.IntentTypes +import org.exploit.verdict.intent.tron.TronTransactionIntent +import org.exploit.verdict.intent.tron.config.TronIntentConfig + +@Singleton +class TronIntentBuilder: IntentBuilder { + override fun type(): String = IntentTypes.TRON_TRANSACTION + + override fun validateConfig(config: Map) { + TronIntentConfig.fromMap(config) + } + + override fun policySchema(config: Map): Map = POLICY_SCHEMA + + override fun buildIntent(command: AuthorityCommand, compiled: CompiledAuthority): Intent { + val artifact = command.artifact as? UnsignedTronTransaction + ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT) + + val config = TronIntentConfig.fromMap(compiled.authority.config) + return TronTransactionIntent.fromSerialized(artifact.transaction, config) + } + + private companion object { + val POLICY_SCHEMA: Map = setOf( + "hash", "timestamp", "expiration", "feeLimit", "refBlockBytes", "refBlockHash", + "memoHex", "contracts", "transaction", "effects" + ).associateWith { SimpleType.DYN } + } +} diff --git a/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/outcome/SignedTronTransaction.kt b/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/outcome/SignedTronTransaction.kt new file mode 100644 index 00000000..b78ae0d7 --- /dev/null +++ b/features/digital-assets/tron/src/main/kotlin/org/exploit/keeper/authority/tron/outcome/SignedTronTransaction.kt @@ -0,0 +1,12 @@ +package org.exploit.keeper.authority.tron.outcome + +import org.exploit.keeper.model.capability.HasImposters +import org.exploit.keeper.model.outcome.Outcome + +data class SignedTronTransaction( + val type: String, + val rawTransaction: String, + val transactionHash: String, + val generation: Int, + override val imposters: List +): Outcome, HasImposters diff --git a/features/authority-evm/build.gradle b/features/digital-assets/xrp/build.gradle similarity index 90% rename from features/authority-evm/build.gradle rename to features/digital-assets/xrp/build.gradle index cc98ec8a..0107224d 100644 --- a/features/authority-evm/build.gradle +++ b/features/digital-assets/xrp/build.gradle @@ -1,11 +1,11 @@ plugins { id 'org.jetbrains.kotlin.jvm' 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' } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() @@ -35,11 +35,12 @@ dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test' testImplementation testFixtures(project(':integration-tests')) testImplementation(project(':')) + testImplementation(project(':platform-ecc')) compileOnly(project(':')) compileOnly(project(':platform-ecc')) ksp "io.micronaut:micronaut-inject-kotlin:4.10.8" - implementation 'org.exploit:verdict-intent-evm:0.1.2' + implementation 'org.exploit.verdict:xrp:0.2.0' } tasks.named('shadowJar') { diff --git a/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/XrpCommandMaterializer.kt b/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/XrpCommandMaterializer.kt new file mode 100644 index 00000000..5afaaf47 --- /dev/null +++ b/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/XrpCommandMaterializer.kt @@ -0,0 +1,60 @@ +package org.exploit.keeper.authority.xrp + +import jakarta.inject.Singleton +import org.exploit.ecc.spi.key.ECPublicKey +import org.exploit.keeper.authority.xrp.artifact.UnsignedXrpTransaction +import org.exploit.keeper.constant.common.ErrorType +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.platform.ecc.algorithm.Ed25519AlgorithmProvider +import org.exploit.keeper.platform.ecc.algorithm.Secp256k1AlgorithmProvider +import org.exploit.keeper.platform.ecc.scheme.ECDSASchemeProvider +import org.exploit.keeper.platform.ecc.scheme.EdDSASchemeProvider +import org.exploit.keeper.service.command.CommandMaterializer +import org.exploit.keeper.service.db.RocksKeyDB +import org.exploit.keeper.service.pub.PublicKeyService +import org.exploit.signet.xrp.accounts.address.XrpAddresses +import org.exploit.verdict.exception.IntentValidationException +import org.exploit.verdict.intent.IntentTypes +import org.exploit.verdict.intent.xrp.util.XrpTransactionReader + +@Singleton +class XrpCommandMaterializer( + private val db: RocksKeyDB, + private val publicKeys: PublicKeyService +): CommandMaterializer { + override fun type(): String = IntentTypes.XRP_TRANSACTION + + override fun toSigningMaterial(keyId: String, generation: Int, c: Command, tweak: String?): SigningMaterial { + val artifact = c.artifact as? UnsignedXrpTransaction + ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT) + val algorithm = db.vault.extractAlgorithm(keyId, generation) + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, keyId) + + val (algorithmName, scheme) = when (algorithm) { + Secp256k1AlgorithmProvider.ID -> Secp256k1AlgorithmProvider.NAME to ECDSASchemeProvider.NAME + Ed25519AlgorithmProvider.ID -> Ed25519AlgorithmProvider.NAME to EdDSASchemeProvider.NAME + else -> throw TKeeperException(ErrorType.UNSUPPORTED_KEY_ALGORITHM, "XRP requires secp256k1 or Ed25519") + } + + val publicKey = publicKeys.forAlgorithm(algorithmName) + .getPublicKey(keyId, generation, tweak) as? ECPublicKey<*> + ?: throw TKeeperException(ErrorType.KEY_ALGORITHM_MISMATCH) + + val transaction = try { + XrpTransactionReader.parse(artifact.transaction) + } catch (e: IntentValidationException) { + throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, e.reason(), e) + } + + val data = transaction.data() + + if (data.account() != XrpAddresses.fromPublicKey(publicKey) + || data.signingPublicKey() != XrpAddresses.publicKeyHex(publicKey)) + throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "XRP account or SigningPubKey does not match signing key") + + return DefaultSigningMaterial(scheme, transaction.signingBytes()) + } +} diff --git a/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/artifact/UnsignedXrpTransaction.kt b/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/artifact/UnsignedXrpTransaction.kt new file mode 100644 index 00000000..72e3fdc7 --- /dev/null +++ b/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/artifact/UnsignedXrpTransaction.kt @@ -0,0 +1,8 @@ +package org.exploit.keeper.authority.xrp.artifact + +import org.exploit.keeper.model.command.artifact.Artifact +import org.exploit.verdict.intent.IntentTypes + +class UnsignedXrpTransaction(val transaction: String): Artifact { + override fun type(): String = IntentTypes.XRP_TRANSACTION +} diff --git a/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/compose/XrpComposer.kt b/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/compose/XrpComposer.kt new file mode 100644 index 00000000..3db05913 --- /dev/null +++ b/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/compose/XrpComposer.kt @@ -0,0 +1,55 @@ +package org.exploit.keeper.authority.xrp.compose + +import jakarta.inject.Singleton +import org.exploit.ecc.ed25519.signature.Ed25519Signature +import org.exploit.ecc.secp256k1.signature.Secp256k1RecoverableECDSASignature +import org.exploit.keeper.authority.xrp.artifact.UnsignedXrpTransaction +import org.exploit.keeper.authority.xrp.outcome.SignedXrpTransaction +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.platform.ecc.scheme.ECDSASchemeProvider +import org.exploit.keeper.platform.ecc.scheme.EdDSASchemeProvider +import org.exploit.keeper.service.compose.spi.Composer +import org.exploit.signet.xrp.codec.XrpSignatureCodec +import org.exploit.verdict.intent.IntentTypes +import org.exploit.verdict.intent.xrp.util.XrpTransactionReader +import java.util.Base64 + +@Singleton +class XrpComposer: Composer { + override val type: String = IntentTypes.XRP_TRANSACTION + + override fun compose(request: Sign, signature: CalculatedSignature): SignedXrpTransaction { + val artifact = request.command.artifact as? UnsignedXrpTransaction + ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT) + val unsigned = XrpTransactionReader.parse(artifact.transaction) + + val signed = try { + unsigned.sign(encodedSignature(signature)) + } catch (e: IllegalArgumentException) { + throw TKeeperException(ErrorType.INVALID_SIGNATURE, "Invalid XRP signature", e) + } + + return SignedXrpTransaction(type, signed.dump(), signed.hash(), signature.generation, signature.imposters) + } + + private fun encodedSignature(result: CalculatedSignature): String { + val signature64 = result.signature64 + ?: throw TKeeperException(ErrorType.INVALID_SIGNATURE, "XRP signature is missing") + + try { + val raw = Base64.getDecoder().decode(signature64) + return when (result.type) { + ECDSASchemeProvider.NAME -> + XrpSignatureCodec.encode(Secp256k1RecoverableECDSASignature.decode(raw)) + EdDSASchemeProvider.NAME -> + XrpSignatureCodec.encode(Ed25519Signature.decode(raw)) + else -> throw IllegalArgumentException("Unsupported XRP signature scheme") + } + } catch (e: IllegalArgumentException) { + throw TKeeperException(ErrorType.INVALID_SIGNATURE, "Invalid XRP signature", e) + } + } +} diff --git a/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/deserializer/UnsignedXrpTransactionDeserializer.kt b/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/deserializer/UnsignedXrpTransactionDeserializer.kt new file mode 100644 index 00000000..c6ea8d32 --- /dev/null +++ b/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/deserializer/UnsignedXrpTransactionDeserializer.kt @@ -0,0 +1,26 @@ +package org.exploit.keeper.authority.xrp.deserializer + +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode +import jakarta.inject.Singleton +import org.exploit.keeper.authority.xrp.artifact.UnsignedXrpTransaction +import org.exploit.keeper.model.command.artifact.Artifact +import org.exploit.keeper.service.command.deserializer.ArtifactDeserializer +import org.exploit.keeper.service.command.deserializer.ArtifactSchemas +import org.exploit.keeper.util.extension.requireOnly +import org.exploit.keeper.util.extension.requireText +import org.exploit.verdict.intent.IntentTypes + +@Singleton +class UnsignedXrpTransactionDeserializer: ArtifactDeserializer { + override fun type(): String = IntentTypes.XRP_TRANSACTION + + override fun schema(config: Map): Map = + ArtifactSchemas.stringField("transaction", "Unsigned XRP transaction hex") + + + override fun deserialize(node: JsonNode, ctx: DeserializationContext): Artifact { + node.requireOnly(ctx, "transaction") + return UnsignedXrpTransaction(node.requireText(ctx, "transaction")) + } +} diff --git a/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/intent/XrpIntentBuilder.kt b/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/intent/XrpIntentBuilder.kt new file mode 100644 index 00000000..7335a86f --- /dev/null +++ b/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/intent/XrpIntentBuilder.kt @@ -0,0 +1,41 @@ +package org.exploit.keeper.authority.xrp.intent + +import dev.cel.common.types.CelType +import dev.cel.common.types.SimpleType +import jakarta.inject.Singleton +import org.exploit.keeper.authority.xrp.artifact.UnsignedXrpTransaction +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.model.command.AuthorityCommand +import org.exploit.keeper.service.command.intent.IntentBuilder +import org.exploit.verdict.authority.model.CompiledAuthority +import org.exploit.verdict.intent.Intent +import org.exploit.verdict.intent.IntentTypes +import org.exploit.verdict.intent.xrp.XrpTransactionIntent + +@Singleton +class XrpIntentBuilder: IntentBuilder { + override fun type(): String = IntentTypes.XRP_TRANSACTION + + override fun validateConfig(config: Map) { + if (config.isNotEmpty()) + throw TKeeperException(ErrorType.INVALID_AUTHORITY, "XRP intent has no config") + } + + override fun policySchema(config: Map): Map = POLICY_SCHEMA + + override fun buildIntent(command: AuthorityCommand, compiled: CompiledAuthority): Intent { + val artifact = command.artifact as? UnsignedXrpTransaction + ?: throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT) + + return XrpTransactionIntent.fromSerialized(artifact.transaction) + } + + private companion object { + val POLICY_SCHEMA: Map = setOf( + "type", "account", "fee", "sequence", "lastLedgerSequence", "sourceTag", + "flags", "signingPublicKey", "destination", "amount", "destinationTag", + "payments", "transaction", "effects" + ).associateWith { SimpleType.DYN } + } +} diff --git a/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/outcome/SignedXrpTransaction.kt b/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/outcome/SignedXrpTransaction.kt new file mode 100644 index 00000000..e59bec9e --- /dev/null +++ b/features/digital-assets/xrp/src/main/kotlin/org/exploit/keeper/authority/xrp/outcome/SignedXrpTransaction.kt @@ -0,0 +1,12 @@ +package org.exploit.keeper.authority.xrp.outcome + +import org.exploit.keeper.model.capability.HasImposters +import org.exploit.keeper.model.outcome.Outcome + +data class SignedXrpTransaction( + val type: String, + val rawTransaction: String, + val transactionHash: String, + val generation: Int, + override val imposters: List +): Outcome, HasImposters diff --git a/features/dry-run/build.gradle b/features/dry-run/build.gradle index 3b31cfee..e5845fe9 100644 --- a/features/dry-run/build.gradle +++ b/features/dry-run/build.gradle @@ -1,14 +1,21 @@ plugins { - 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' } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() + maven { + name = "sonatypeCentralSnapshots" + url = uri("https://central.sonatype.com/repository/maven-snapshots/") + mavenContent { + snapshotsOnly() + } + } } dependencies { diff --git a/features/ecies/build.gradle b/features/ecies/build.gradle index 946d2343..e1912fa8 100644 --- a/features/ecies/build.gradle +++ b/features/ecies/build.gradle @@ -1,11 +1,11 @@ plugins { id 'org.jetbrains.kotlin.jvm' 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' } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() diff --git a/features/mcp/README.md b/features/mcp/README.md new file mode 100644 index 00000000..cc8fe686 --- /dev/null +++ b/features/mcp/README.md @@ -0,0 +1,34 @@ +# MCP tools + +Build with `-Pkeeper.features=mcp` to add `POST /mcp` to Keeper's existing public HTTP server. It uses the same authentication provider and token as the other public endpoints. The agent host supplies the token; this module does not issue credentials. + +The endpoint supports MCP `2026-07-28` over Streamable HTTP with JSON responses. Tools are grouped by name: + +- `identity.list`: paged active keys the caller can sign with, their algorithms, generations, and authority IDs. +- `identity.describe_authority`: an authority's `metadata.description` and the JSON Schema of its `{type, authorityId, artifact}` command. +- `utility.keeper_status`: sealed and ready state. Requires `tkeeper.system.status`. +- `utility.get_public_key`: public key for an identity. Requires `tkeeper.key..public`. +- `utility.verify_signature`: verify a signature for an identity and command. Requires `tkeeper.key..verify`. +- `action.sign`: sign an authority command. Requires `tkeeper.key..sign`. +- `action.compose`: sign and compose a transaction. Requires `tkeeper.key..sign`. + +`tools/list` only advertises tools for which the caller has an applicable permission. Every call checks permission again for its target key. Signing and compose run the same authority and approval guards as the regular API. Each artifact deserializer supplies its own artifact schema; the schema describes command shape, while the authority policy decides whether a particular transaction is allowed. + +`server/discover` and `tools/list` return `ttlMs: 0` and `cacheScope: private`. Refresh the catalog after a permission, identity, or authority change. + +Example request with Keeper's production JWT header: + +```http +POST /mcp HTTP/1.1 +Content-Type: application/json +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: tools/call +Mcp-Name: identity.describe_authority +X-JWT-TOKEN: + +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"identity.describe_authority","arguments":{"keyId":"example-key","authorityId":"example-authority"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}} +``` + +`server/discover` and `tools/list` are also supported. Browser requests with an `Origin` header are rejected; connect from a server-side agent host. + +See [MCP connection and tools](../../docs/ai/mcp.md) for setup and [For AI](../../docs/use-cases/for-ai.md#govern-an-mcp-action) for a governed tool call. diff --git a/features/mcp/build.gradle b/features/mcp/build.gradle new file mode 100644 index 00000000..2bc04ae6 --- /dev/null +++ b/features/mcp/build.gradle @@ -0,0 +1,43 @@ +plugins { + 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.10' +} + +group = 'org.exploit' +version = '2.5.0' + +repositories { + mavenCentral() +} + +dependencies { + compileOnly project(':') + testImplementation project(':') + testImplementation 'org.jetbrains.kotlin:kotlin-test' + testImplementation 'org.junit.jupiter:junit-jupiter:5.14.3' + + ksp 'io.micronaut:micronaut-inject-kotlin:4.10.8' +} + +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/mcp/src/main/kotlin/org/exploit/keeper/mcp/controller/McpController.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/controller/McpController.kt new file mode 100644 index 00000000..60dc7dc9 --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/controller/McpController.kt @@ -0,0 +1,69 @@ +package org.exploit.keeper.mcp.controller + +import com.linecorp.armeria.common.HttpData +import com.linecorp.armeria.common.HttpHeaderNames +import com.linecorp.armeria.common.HttpResponse +import com.linecorp.armeria.common.HttpStatus +import com.linecorp.armeria.common.MediaType +import com.linecorp.armeria.common.ResponseHeaders +import com.linecorp.armeria.server.ServerBuilder +import jakarta.inject.Singleton +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.controller.external.ExternalController +import org.exploit.keeper.controller.support.ControllerSupport +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.mcp.protocol.McpProtocol +import org.exploit.keeper.service.scope.AuditScope +import org.exploit.keeper.util.extension.http.authData + +@Singleton +class McpController(private val support: ControllerSupport, private val protocol: McpProtocol): ExternalController { + override fun register(sb: ServerBuilder) = with(support) { + sb.publicPostRaw(PATH, ensureReady = false) { ctx -> + val auth = ctx.authData() ?: throw TKeeperException(ErrorType.UNAUTHENTICATED) + + val request = ctx.request() + val reply = if (request.headers().get(HttpHeaderNames.ORIGIN) != null) { + protocol.forbidden() + } else { + val body = runCatching { + val bytes = request.aggregate().join().content().array() + AuditScope.currentOrNull()?.update { it.reqBody = bytes } + json.mapper.readTree(bytes) + }.getOrNull() + + if (body == null) protocol.parseError() + else protocol.handle( + ctx, + auth, + request.headers().get("MCP-Protocol-Version"), + request.headers().get("Mcp-Method"), + request.headers().get("Mcp-Name"), + body + ) + } + + if (!finishAndAudit( + reply.status, + reply.auditError, + reply.auditDetails, + reply.auditImposters, + reply.auditDead, + reply.auditApprovals + )) + return@publicPostRaw auditFailedJson() + + HttpResponse.of( + ResponseHeaders.builder(HttpStatus.valueOf(reply.status)) + .contentType(MediaType.JSON_UTF_8) + .set(HttpHeaderNames.CACHE_CONTROL, "no-store") + .build(), + HttpData.copyOf(json.mapper.writeValueAsBytes(reply.body)) + ) + } + } + + private companion object { + const val PATH = "/mcp" + } +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/model/AuthorityCatalogItem.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/model/AuthorityCatalogItem.kt new file mode 100644 index 00000000..00463dc4 --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/model/AuthorityCatalogItem.kt @@ -0,0 +1,9 @@ +package org.exploit.keeper.mcp.model + +data class AuthorityCatalogItem( + val id: String, + val type: String, + val version: String?, + val description: String?, + val commandSchema: Map +) diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/model/IdentityCatalogItem.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/model/IdentityCatalogItem.kt new file mode 100644 index 00000000..c75b7d2c --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/model/IdentityCatalogItem.kt @@ -0,0 +1,8 @@ +package org.exploit.keeper.mcp.model + +data class IdentityCatalogItem( + val keyId: String, + val algorithm: String, + val generation: Int, + val authorities: List +) \ No newline at end of file diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/model/IdentityCatalogPage.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/model/IdentityCatalogPage.kt new file mode 100644 index 00000000..7b587415 --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/model/IdentityCatalogPage.kt @@ -0,0 +1,3 @@ +package org.exploit.keeper.mcp.model + +data class IdentityCatalogPage(val items: List, val nextCursor: String?) \ No newline at end of file diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/model/McpReply.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/model/McpReply.kt new file mode 100644 index 00000000..77e49b13 --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/model/McpReply.kt @@ -0,0 +1,13 @@ +package org.exploit.keeper.mcp.model + +import org.exploit.keeper.model.approval.RequiredApprovalDetails + +data class McpReply( + val status: Int, + val body: Map, + val auditError: String? = null, + val auditDetails: String? = null, + val auditImposters: Collection? = null, + val auditDead: Collection? = null, + val auditApprovals: List? = null +) diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/protocol/McpProtocol.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/protocol/McpProtocol.kt new file mode 100644 index 00000000..3ec7346d --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/protocol/McpProtocol.kt @@ -0,0 +1,130 @@ +package org.exploit.keeper.mcp.protocol + +import com.fasterxml.jackson.databind.JsonNode +import com.linecorp.armeria.server.ServiceRequestContext +import jakarta.inject.Singleton +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.mcp.model.McpReply +import org.exploit.keeper.mcp.support.McpHeader +import org.exploit.keeper.mcp.support.McpResponses +import org.exploit.keeper.mcp.support.McpToolSupport +import org.exploit.keeper.mcp.service.tool.ToolCall +import org.exploit.keeper.model.auth.AuthData + +@Singleton +class McpProtocol( + tools: List, + private val support: McpToolSupport, + private val responses: McpResponses +) { + private val tools = tools.associateBy(ToolCall::name) + + init { + require(this.tools.size == tools.size) { "Duplicate MCP tool name" } + } + + fun handle( + ctx: ServiceRequestContext, + auth: AuthData, + version: String?, + methodHeader: String?, + nameHeader: String?, + request: JsonNode + ): McpReply { + if (request.path("jsonrpc").asText() != JSON_RPC_VERSION) + return responses.error(null, INVALID_REQUEST, "Invalid request", 400) + + val id = request.get("id")?.takeIf { it.isTextual || it.isIntegralNumber } + ?: return responses.error(null, INVALID_REQUEST, "Invalid request", 400) + val method = request.path("method").takeIf { it.isTextual }?.asText() + ?: return responses.error(id, INVALID_REQUEST, "Invalid request", 400) + val params = request.path("params") + if (!params.isObject) + return responses.error(id, INVALID_PARAMS, "Params must be an object", 400) + + val meta = params.path("_meta") + val requestedVersion = meta.path("io.modelcontextprotocol/protocolVersion") + .takeIf { it.isTextual }?.asText() + ?: return responses.error(id, INVALID_PARAMS, "Missing protocol version", 400) + + if (version != requestedVersion) + return responses.error(id, HEADER_MISMATCH, "MCP headers do not match request", 400) + if (version != VERSION) + return responses.error(id, UNSUPPORTED_VERSION, "Unsupported protocol version", 400, mapOf( + "requested" to requestedVersion, + "supported" to listOf(VERSION) + )) + if (!meta.path("io.modelcontextprotocol/clientCapabilities").isObject) + return responses.error(id, INVALID_PARAMS, "Missing client capabilities", 400) + if (methodHeader != method || method == CALL_METHOD && McpHeader.decode(nameHeader) != params.path("name").asText()) + return responses.error(id, HEADER_MISMATCH, "MCP headers do not match request", 400) + + return when (method) { + DISCOVER_METHOD -> responses.success(id, mapOf( + "supportedVersions" to listOf(VERSION), + "capabilities" to mapOf("tools" to emptyMap()), + "ttlMs" to 0, + "cacheScope" to "private" + )) + LIST_METHOD -> responses.success(id, mapOf("tools" to tools.values + .filter { available(it, auth) } + .sortedBy(ToolCall::name) + .map(ToolCall::definition), + "ttlMs" to 0, + "cacheScope" to "private" + )) + CALL_METHOD -> call(ctx, auth, id, params) + else -> responses.error(id, METHOD_NOT_FOUND, "Method not found", 404) + } + } + + fun parseError(): McpReply = responses.error(null, PARSE_ERROR, "Parse error", 400) + + fun forbidden(): McpReply = responses.error(null, INVALID_REQUEST, "Origin is not allowed", 403) + + private fun available(tool: ToolCall, auth: AuthData): Boolean = + try { + tool.available(auth) + } catch (_: TKeeperException) { + false + } + + private fun call(ctx: ServiceRequestContext, auth: AuthData, id: JsonNode, params: JsonNode): McpReply { + val name = params.path("name").takeIf { it.isTextual }?.asText() + ?: return responses.error(id, INVALID_PARAMS, "Tool name is required") + val tool = tools[name] ?: return responses.error(id, INVALID_PARAMS, "Unknown tool") + val arguments = params.path("arguments") + if (!arguments.isObject) + return responses.error(id, INVALID_PARAMS, "Tool arguments must be an object") + + val output = try { + if (!tool.available(auth)) + throw TKeeperException(ErrorType.ACCESS_DENIED) + + support.preflight(tool) + tool.validate(arguments) + tool.execute(ctx, auth, arguments) + } catch (e: TKeeperException) { + return responses.toolError(id, e) + } catch (e: IllegalArgumentException) { + return responses.toolResult(id, mapOf("error" to "INVALID_ARGUMENT", "details" to e.message), true) + } + + return responses.toolResult(id, output) + } + + private companion object { + const val JSON_RPC_VERSION = "2.0" + const val VERSION = "2026-07-28" + const val DISCOVER_METHOD = "server/discover" + const val LIST_METHOD = "tools/list" + const val CALL_METHOD = "tools/call" + const val PARSE_ERROR = -32700 + const val INVALID_REQUEST = -32600 + const val METHOD_NOT_FOUND = -32601 + const val INVALID_PARAMS = -32602 + const val HEADER_MISMATCH = -32020 + const val UNSUPPORTED_VERSION = -32022 + } +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/IdentityCatalogService.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/IdentityCatalogService.kt new file mode 100644 index 00000000..483796bd --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/IdentityCatalogService.kt @@ -0,0 +1,113 @@ +package org.exploit.keeper.mcp.service + +import jakarta.inject.Singleton +import org.exploit.keeper.constant.authority.OtherKeySetAuthorityId +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.constant.permission.Permission +import org.exploit.keeper.constant.status.KeyStatus +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.model.api.outgoing.inventory.AssetInventoryItem +import org.exploit.keeper.model.auth.AuthData +import org.exploit.keeper.mcp.model.AuthorityCatalogItem +import org.exploit.keeper.mcp.model.IdentityCatalogItem +import org.exploit.keeper.mcp.model.IdentityCatalogPage +import org.exploit.keeper.service.authority.KeyAuthorityCompiler +import org.exploit.keeper.service.command.deserializer.ArtifactDeserializer +import org.exploit.keeper.service.command.deserializer.ArtifactSchemas +import org.exploit.keeper.service.plane.AssetInventoryService + +@Singleton +class IdentityCatalogService( + private val inventory: AssetInventoryService, + private val compiler: KeyAuthorityCompiler, + private val deserializers: List +) { + fun list(auth: AuthData, lastSeen: String?, limit: Int): IdentityCatalogPage { + val items = ArrayList(limit + 1) + var cursor = lastSeen + + while (true) { + val page = inventory.loadAssetInventory(false, lastSeen = cursor, limit = PAGE_LIMIT) + + for (item in page.inventory.items) { + if (!available(auth, item)) continue + + items += IdentityCatalogItem( + item.logicalId, + requireNotNull(item.algorithm), + item.currentGeneration, + item.authorities.map { it.id } + ) + + if (items.size > limit) + return IdentityCatalogPage(items.take(limit), items[limit - 1].keyId) + } + + if (!page.hasMore || page.nextCursor == null) + return IdentityCatalogPage(items, null) + + cursor = page.nextCursor + } + } + + fun describe(auth: AuthData, keyId: String, authorityId: String): AuthorityCatalogItem { + if (!auth.hasPermission(Permission.keySign(keyId))) + throw TKeeperException(ErrorType.ACCESS_DENIED) + + val item = inventory.loadAssetInventory(false, logicalId = keyId, limit = 1) + .inventory.items.firstOrNull { it.logicalId == keyId } + + if (item == null || !available(auth, item)) + throw TKeeperException(ErrorType.KEY_NOT_FOUND) + + val reference = item.authorities.find { it.id == authorityId } + ?: throw TKeeperException(ErrorType.AUTHORITY_VIOLATION) + + val authority = if (reference.id == OtherKeySetAuthorityId.ARBITRARY) null + else compiler.compile(keyId, item.currentGeneration, reference).authority + val type = authority?.type ?: OtherKeySetAuthorityId.ARBITRARY + val artifactSchema = deserializers.single { it.type() == type }.schema(authority?.config ?: emptyMap()) + val commandSchema = ArtifactSchemas.objectFields( + mapOf( + "type" to mapOf("const" to type), + "authorityId" to mapOf("const" to authorityId), + "artifact" to artifactSchema + ), + listOf("type", "authorityId", "artifact") + ) + + return AuthorityCatalogItem( + authorityId, + type, + authority?.version, + authority?.metadata?.get("description") as? String, + commandSchema + ) + } + + fun hasAccessibleKey(auth: AuthData, permission: (String) -> String): Boolean { + var cursor: String? = null + + while (true) { + val page = inventory.loadAssetInventory(false, lastSeen = cursor, limit = PAGE_LIMIT) + if (page.inventory.items.any { available(auth, it, permission) }) + return true + + if (!page.hasMore || page.nextCursor == null) + return false + + cursor = page.nextCursor + } + } + + private fun available(auth: AuthData, item: AssetInventoryItem): Boolean = + available(auth, item, Permission::keySign) + + private fun available(auth: AuthData, item: AssetInventoryItem, permission: (String) -> String): Boolean = + auth.hasPermission(permission(item.logicalId)) && + item.status == KeyStatus.ACTIVE && item.hasActiveKey && !item.tampered && item.algorithm != null + + private companion object { + const val PAGE_LIMIT = 200 + } +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/ToolCall.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/ToolCall.kt new file mode 100644 index 00000000..9a33b0d9 --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/ToolCall.kt @@ -0,0 +1,28 @@ +package org.exploit.keeper.mcp.service.tool + +import com.fasterxml.jackson.databind.JsonNode +import com.linecorp.armeria.server.ServiceRequestContext +import org.exploit.keeper.mcp.support.McpArguments +import org.exploit.keeper.model.auth.AuthData + +interface ToolCall { + val name: String + val description: String + val inputSchema: Map + val readOnly: Boolean get() = true + val requiresReady: Boolean get() = true + val requiresCoordinator: Boolean get() = false + + fun available(auth: AuthData): Boolean + + fun validate(arguments: JsonNode) = McpArguments.requireOnly(arguments, inputSchema) + + fun execute(ctx: ServiceRequestContext, auth: AuthData, arguments: JsonNode): Any + + fun definition(): Map = mapOf( + "name" to name, + "description" to description, + "inputSchema" to inputSchema, + "annotations" to mapOf("readOnlyHint" to readOnly) + ) +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/action/ComposeToolCall.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/action/ComposeToolCall.kt new file mode 100644 index 00000000..37a311ef --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/action/ComposeToolCall.kt @@ -0,0 +1,34 @@ +package org.exploit.keeper.mcp.service.tool.action + +import com.fasterxml.jackson.databind.JsonNode +import com.linecorp.armeria.server.ServiceRequestContext +import jakarta.inject.Singleton +import org.exploit.keeper.constant.permission.Permission +import org.exploit.keeper.mcp.service.IdentityCatalogService +import org.exploit.keeper.mcp.service.tool.ToolCall +import org.exploit.keeper.mcp.support.McpActionSupport +import org.exploit.keeper.mcp.support.McpSchemas +import org.exploit.keeper.model.auth.AuthData +import org.exploit.keeper.service.compose.KeeperComposer + +@Singleton +class ComposeToolCall( + private val catalog: IdentityCatalogService, + private val support: McpActionSupport, + private val composer: KeeperComposer +): ToolCall { + override val name: String = NAME + override val description: String = "Sign and compose a transaction; use identity.describe_authority for its command schema" + override val inputSchema: Map = McpSchemas.sign() + override val readOnly: Boolean = false + + override fun available(auth: AuthData): Boolean = + catalog.hasAccessibleKey(auth, Permission::keySign) + + override fun execute(ctx: ServiceRequestContext, auth: AuthData, arguments: JsonNode): Any = + composer.compose(support.signRequest(ctx, auth, arguments)) + + private companion object { + const val NAME = "action.compose" + } +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/action/SignToolCall.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/action/SignToolCall.kt new file mode 100644 index 00000000..6c2704e8 --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/action/SignToolCall.kt @@ -0,0 +1,35 @@ +package org.exploit.keeper.mcp.service.tool.action + +import com.fasterxml.jackson.databind.JsonNode +import com.linecorp.armeria.server.ServiceRequestContext +import jakarta.inject.Singleton +import org.exploit.keeper.constant.permission.Permission +import org.exploit.keeper.mcp.service.IdentityCatalogService +import org.exploit.keeper.mcp.service.tool.ToolCall +import org.exploit.keeper.mcp.support.McpActionSupport +import org.exploit.keeper.mcp.support.McpSchemas +import org.exploit.keeper.model.auth.AuthData +import org.exploit.keeper.service.signature.registry.SigningSessionManager + +@Singleton +class SignToolCall( + private val catalog: IdentityCatalogService, + private val support: McpActionSupport, + private val manager: SigningSessionManager +): ToolCall { + override val name: String = NAME + override val description: String = "Sign a command; use identity.describe_authority for its exact command schema" + override val inputSchema: Map = McpSchemas.sign() + override val readOnly: Boolean = false + override val requiresCoordinator: Boolean = true + + override fun available(auth: AuthData): Boolean = + catalog.hasAccessibleKey(auth, Permission::keySign) + + override fun execute(ctx: ServiceRequestContext, auth: AuthData, arguments: JsonNode): Any = + manager.coordinate(support.signRequest(ctx, auth, arguments)) + + private companion object { + const val NAME = "action.sign" + } +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/identity/DescribeAuthorityToolCall.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/identity/DescribeAuthorityToolCall.kt new file mode 100644 index 00000000..8d60a4e0 --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/identity/DescribeAuthorityToolCall.kt @@ -0,0 +1,35 @@ +package org.exploit.keeper.mcp.service.tool.identity + +import com.fasterxml.jackson.databind.JsonNode +import com.linecorp.armeria.server.ServiceRequestContext +import jakarta.inject.Singleton +import org.exploit.keeper.constant.permission.Permission +import org.exploit.keeper.mcp.service.IdentityCatalogService +import org.exploit.keeper.mcp.service.tool.ToolCall +import org.exploit.keeper.mcp.support.McpArguments +import org.exploit.keeper.mcp.support.McpSchemas +import org.exploit.keeper.model.auth.AuthData + +@Singleton +class DescribeAuthorityToolCall(private val catalog: IdentityCatalogService): ToolCall { + override val name: String = NAME + override val description: String = "Get an authority description and command JSON Schema for one identity" + override val inputSchema: Map = McpSchemas.objectFields(mapOf( + "keyId" to McpSchemas.string(), + "authorityId" to McpSchemas.string() + ), listOf("keyId", "authorityId")) + + override fun available(auth: AuthData): Boolean = + catalog.hasAccessibleKey(auth, Permission::keySign) + + override fun execute(ctx: ServiceRequestContext, auth: AuthData, arguments: JsonNode): Any = + catalog.describe( + auth, + McpArguments.text(arguments, "keyId"), + McpArguments.text(arguments, "authorityId") + ) + + private companion object { + const val NAME = "identity.describe_authority" + } +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/identity/ListIdentitiesToolCall.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/identity/ListIdentitiesToolCall.kt new file mode 100644 index 00000000..2528eb38 --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/identity/ListIdentitiesToolCall.kt @@ -0,0 +1,37 @@ +package org.exploit.keeper.mcp.service.tool.identity + +import com.fasterxml.jackson.databind.JsonNode +import com.linecorp.armeria.server.ServiceRequestContext +import jakarta.inject.Singleton +import org.exploit.keeper.constant.permission.Permission +import org.exploit.keeper.mcp.service.IdentityCatalogService +import org.exploit.keeper.mcp.service.tool.ToolCall +import org.exploit.keeper.mcp.support.McpArguments +import org.exploit.keeper.mcp.support.McpSchemas +import org.exploit.keeper.model.auth.AuthData + +@Singleton +class ListIdentitiesToolCall(private val catalog: IdentityCatalogService): ToolCall { + override val name: String = NAME + override val description: String = "List active identities this caller can sign with and their authority IDs" + override val inputSchema: Map = McpSchemas.objectFields(mapOf( + "lastSeen" to McpSchemas.string(), + "limit" to mapOf("type" to "integer", "minimum" to 1, "maximum" to MAX_LIMIT) + )) + + override fun available(auth: AuthData): Boolean = + catalog.hasAccessibleKey(auth, Permission::keySign) + + override fun execute(ctx: ServiceRequestContext, auth: AuthData, arguments: JsonNode): Any = + catalog.list( + auth, + McpArguments.optionalText(arguments, "lastSeen"), + McpArguments.integer(arguments, "limit", 1..MAX_LIMIT, DEFAULT_LIMIT) + ) + + private companion object { + const val NAME = "identity.list" + const val DEFAULT_LIMIT = 50 + const val MAX_LIMIT = 100 + } +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/utility/GetPublicKeyToolCall.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/utility/GetPublicKeyToolCall.kt new file mode 100644 index 00000000..6dea343b --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/utility/GetPublicKeyToolCall.kt @@ -0,0 +1,50 @@ +package org.exploit.keeper.mcp.service.tool.utility + +import com.fasterxml.jackson.databind.JsonNode +import com.linecorp.armeria.server.ServiceRequestContext +import jakarta.inject.Singleton +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.constant.permission.Permission +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.mcp.service.IdentityCatalogService +import org.exploit.keeper.mcp.service.tool.ToolCall +import org.exploit.keeper.mcp.support.McpArguments +import org.exploit.keeper.mcp.support.McpSchemas +import org.exploit.keeper.model.api.outgoing.PublicKeyDto +import org.exploit.keeper.model.auth.AuthData +import org.exploit.keeper.service.pub.KeeperPublicKeyService +import org.exploit.keeper.util.extension.crypto.toBase64 +import org.exploit.keeper.util.validation.InputValidator + +@Singleton +class GetPublicKeyToolCall( + private val catalog: IdentityCatalogService, + private val publicKeys: KeeperPublicKeyService +): ToolCall { + override val name: String = NAME + override val description: String = "Get an identity's public key with the same permission check as Keeper's public API" + override val inputSchema: Map = McpSchemas.objectFields(mapOf( + "keyId" to McpSchemas.string(), + "generation" to mapOf("type" to "integer", "minimum" to 1), + "tweak" to McpSchemas.string() + ), listOf("keyId")) + + override fun available(auth: AuthData): Boolean = + catalog.hasAccessibleKey(auth, Permission::keyGetPublicKey) + + override fun execute(ctx: ServiceRequestContext, auth: AuthData, arguments: JsonNode): Any { + val keyId = McpArguments.text(arguments, "keyId") + if (!auth.hasPermission(Permission.keyGetPublicKey(keyId))) + throw TKeeperException(ErrorType.ACCESS_DENIED) + if (!InputValidator.isValidKeyId(keyId)) + throw TKeeperException(ErrorType.INVALID_KEY_ID) + + val generation = McpArguments.optionalInteger(arguments, "generation", 1..Int.MAX_VALUE) + val tweak = McpArguments.optionalText(arguments, "tweak") + return PublicKeyDto(publicKeys.publicKey(keyId, generation, tweak).encode().toBase64()) + } + + private companion object { + const val NAME = "utility.get_public_key" + } +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/utility/KeeperStatusToolCall.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/utility/KeeperStatusToolCall.kt new file mode 100644 index 00000000..97c2d8b7 --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/utility/KeeperStatusToolCall.kt @@ -0,0 +1,37 @@ +package org.exploit.keeper.mcp.service.tool.utility + +import com.fasterxml.jackson.databind.JsonNode +import com.linecorp.armeria.server.ServiceRequestContext +import jakarta.inject.Singleton +import org.exploit.keeper.constant.common.StoreState +import org.exploit.keeper.mcp.service.tool.ToolCall +import org.exploit.keeper.mcp.support.McpSchemas +import org.exploit.keeper.model.auth.AuthData +import org.exploit.keeper.service.core.KeeperService + +@Singleton +class KeeperStatusToolCall(private val keeper: KeeperService): ToolCall { + override val name: String = NAME + override val description: String = "Check whether this Keeper is initialized, sealed, and ready to serve operations" + override val inputSchema: Map = McpSchemas.objectFields(emptyMap()) + override val requiresReady: Boolean = false + + override fun available(auth: AuthData): Boolean = + auth.hasPermission(STATUS_PERMISSION) + + override fun execute(ctx: ServiceRequestContext, auth: AuthData, arguments: JsonNode): Any { + val state = keeper.status().state + val restartRequired = state == StoreState.UNSEALED && keeper.restartRequired() + + return mapOf( + "state" to state.name, + "ready" to (state == StoreState.UNSEALED && !restartRequired), + "restartRequired" to restartRequired + ) + } + + private companion object { + const val NAME = "utility.keeper_status" + const val STATUS_PERMISSION = "tkeeper.system.status" + } +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/utility/VerifySignatureToolCall.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/utility/VerifySignatureToolCall.kt new file mode 100644 index 00000000..916f6719 --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/service/tool/utility/VerifySignatureToolCall.kt @@ -0,0 +1,58 @@ +package org.exploit.keeper.mcp.service.tool.utility + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.linecorp.armeria.server.ServiceRequestContext +import jakarta.inject.Named +import jakarta.inject.Singleton +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.constant.permission.Permission +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.factory.JacksonFactory +import org.exploit.keeper.mcp.service.IdentityCatalogService +import org.exploit.keeper.mcp.service.tool.ToolCall +import org.exploit.keeper.mcp.support.McpArguments +import org.exploit.keeper.mcp.support.McpSchemas +import org.exploit.keeper.model.api.incoming.Verify +import org.exploit.keeper.model.api.outgoing.VerifyResult +import org.exploit.keeper.model.auth.AuthData +import org.exploit.keeper.service.signature.verify.SignatureVerifyManager +import org.exploit.keeper.util.validation.InputValidator + +@Singleton +class VerifySignatureToolCall( + private val catalog: IdentityCatalogService, + private val manager: SignatureVerifyManager, + @param:Named(JacksonFactory.PUBLIC) private val mapper: ObjectMapper +): ToolCall { + override val name: String = NAME + override val description: String = "Verify a signature for an identity and command" + override val inputSchema: Map = McpSchemas.objectFields(mapOf( + "keyId" to McpSchemas.string(), + "generation" to mapOf("type" to "integer", "minimum" to 1), + "command" to McpSchemas.objectFields(mapOf( + "type" to McpSchemas.string(), + "artifact" to mapOf("type" to "object") + ), listOf("type", "artifact")), + "signature64" to McpSchemas.string(), + "tweak" to McpSchemas.string() + ), listOf("keyId", "command", "signature64")) + + override fun available(auth: AuthData): Boolean = + catalog.hasAccessibleKey(auth, Permission::keyVerify) + + override fun execute(ctx: ServiceRequestContext, auth: AuthData, arguments: JsonNode): Any { + val keyId = McpArguments.text(arguments, "keyId") + if (!auth.hasPermission(Permission.keyVerify(keyId))) + throw TKeeperException(ErrorType.ACCESS_DENIED) + if (!InputValidator.isValidKeyId(keyId)) + throw TKeeperException(ErrorType.INVALID_KEY_ID) + + val request = McpArguments.body(mapper, arguments, Verify::class.java) + return VerifyResult(manager.verify(request)) + } + + private companion object { + const val NAME = "utility.verify_signature" + } +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpActionSupport.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpActionSupport.kt new file mode 100644 index 00000000..ffec06af --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpActionSupport.kt @@ -0,0 +1,35 @@ +package org.exploit.keeper.mcp.support + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.linecorp.armeria.server.ServiceRequestContext +import jakarta.inject.Named +import jakarta.inject.Singleton +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.constant.permission.Permission +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.factory.JacksonFactory +import org.exploit.keeper.model.api.incoming.Sign +import org.exploit.keeper.model.auth.AuthData +import org.exploit.keeper.service.guard.OpGuardChain +import org.exploit.keeper.service.scope.AuditScope +import org.exploit.keeper.util.validation.InputValidator + +@Singleton +class McpActionSupport( + private val guards: OpGuardChain, + @param:Named(JacksonFactory.PUBLIC) private val mapper: ObjectMapper +) { + fun signRequest(ctx: ServiceRequestContext, auth: AuthData, arguments: JsonNode): Sign { + val keyId = McpArguments.text(arguments, "keyId") + if (!auth.hasPermission(Permission.keySign(keyId))) + throw TKeeperException(ErrorType.ACCESS_DENIED) + if (!InputValidator.isValidKeyId(keyId)) + throw TKeeperException(ErrorType.INVALID_KEY_ID) + + val body = McpArguments.body(mapper, arguments, Sign::class.java) + guards.guard(ctx, body) + AuditScope.currentOrNull()?.update { it.crypto = body.crypto() } + return body + } +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpArguments.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpArguments.kt new file mode 100644 index 00000000..17386806 --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpArguments.kt @@ -0,0 +1,41 @@ +package org.exploit.keeper.mcp.support + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper + +object McpArguments { + fun requireOnly(arguments: JsonNode, schema: Map) { + val properties = schema["properties"] as? Map<*, *> ?: return + arguments.fieldNames().forEach { name -> + require(properties.containsKey(name)) { "Unknown argument: $name" } + } + } + + fun text(arguments: JsonNode, name: String): String = + optionalText(arguments, name) ?: throw IllegalArgumentException("$name is required") + + fun optionalText(arguments: JsonNode, name: String): String? { + val value = arguments.get(name) ?: return null + require(value.isTextual && value.asText().isNotBlank()) { "$name must be a nonempty string" } + return value.asText() + } + + fun integer(arguments: JsonNode, name: String, range: IntRange, default: Int): Int { + val value = arguments.get(name) ?: return default + require(value.isInt && value.intValue() in range) { "$name must be between ${range.first} and ${range.last}" } + return value.intValue() + } + + fun optionalInteger(arguments: JsonNode, name: String, range: IntRange): Int? { + val value = arguments.get(name) ?: return null + require(value.isInt && value.intValue() in range) { "$name must be between ${range.first} and ${range.last}" } + return value.intValue() + } + + fun body(mapper: ObjectMapper, arguments: JsonNode, type: Class): T = + try { + mapper.treeToValue(arguments, type) + } catch (e: Exception) { + throw IllegalArgumentException("Invalid ${type.simpleName} arguments", e) + } +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpHeader.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpHeader.kt new file mode 100644 index 00000000..7616aa35 --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpHeader.kt @@ -0,0 +1,18 @@ +package org.exploit.keeper.mcp.support + +import java.util.Base64 + +object McpHeader { + fun decode(value: String?): String? { + if (value == null || !value.startsWith(PREFIX) || !value.endsWith(SUFFIX)) + return value + + return runCatching { + val encoded = value.substring(PREFIX.length, value.length - SUFFIX.length) + String(Base64.getDecoder().decode(encoded), Charsets.UTF_8) + }.getOrNull() + } + + private const val PREFIX = "=?base64?" + private const val SUFFIX = "?=" +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpResponses.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpResponses.kt new file mode 100644 index 00000000..6b8e9ee0 --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpResponses.kt @@ -0,0 +1,86 @@ +package org.exploit.keeper.mcp.support + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.inject.Named +import jakarta.inject.Singleton +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.factory.JacksonFactory +import org.exploit.keeper.mcp.model.McpReply +import org.exploit.keeper.model.capability.HasDead +import org.exploit.keeper.model.capability.HasDeadIds +import org.exploit.keeper.model.capability.HasImposterIds +import org.exploit.keeper.model.capability.HasImposters +import org.exploit.keeper.util.extension.toKeeperSet + +@Singleton +class McpResponses(@param:Named(JacksonFactory.PUBLIC) private val mapper: ObjectMapper) { + fun success(id: JsonNode, result: Map): McpReply = McpReply(200, mapOf( + "jsonrpc" to JSON_RPC_VERSION, + "id" to id, + "result" to (result + mapOf( + "resultType" to COMPLETE, + "_meta" to mapOf("io.modelcontextprotocol/serverInfo" to mapOf("name" to SERVER_NAME, "version" to SERVER_VERSION)) + )) + )) + + fun error( + id: JsonNode?, + code: Int, + message: String, + status: Int = 200, + data: Map? = null + ): McpReply = McpReply(status, mapOf( + "jsonrpc" to JSON_RPC_VERSION, + "id" to id, + "error" to (mapOf("code" to code, "message" to message) + + (data?.let { mapOf("data" to it) } ?: emptyMap())) + ), auditError = PROTOCOL_ERROR) + + fun toolResult(id: JsonNode, output: Any, isError: Boolean = false): McpReply { + val content = mapper.valueToTree(output) + val imposters = when (output) { + is HasImposterIds -> output.imposters.toKeeperSet() + is HasImposters -> output.imposters.toSet() + else -> null + } + val dead = when (output) { + is HasDeadIds -> output.dead.toKeeperSet() + is HasDead -> output.dead.toSet() + else -> null + } + + return success(id, mapOf( + "content" to listOf(mapOf("type" to "text", "text" to mapper.writeValueAsString(content))), + "structuredContent" to content, + "isError" to isError + )).copy( + auditError = if (isError) TOOL_ERROR else null, + auditImposters = imposters, + auditDead = dead + ) + } + + fun toolError(id: JsonNode, error: TKeeperException): McpReply = toolResult(id, buildMap { + put("error", error.errorType.name) + error.details?.let { put("details", it) } + error.approvals?.let { put("approvals", it) } + if (error.dead.isNotEmpty()) put("dead", error.dead) + if (error.imposters.isNotEmpty()) put("imposters", error.imposters) + }, true).copy( + auditError = error.errorType.name, + auditDetails = error.details, + auditImposters = error.imposters.toKeeperSet(), + auditDead = error.dead.toKeeperSet(), + auditApprovals = error.approvals + ) + + private companion object { + const val JSON_RPC_VERSION = "2.0" + const val COMPLETE = "complete" + const val SERVER_NAME = "tkeeper" + const val SERVER_VERSION = "2.5.0" + const val TOOL_ERROR = "MCP_TOOL_ERROR" + const val PROTOCOL_ERROR = "MCP_PROTOCOL_ERROR" + } +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpSchemas.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpSchemas.kt new file mode 100644 index 00000000..dbdbe189 --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpSchemas.kt @@ -0,0 +1,33 @@ +package org.exploit.keeper.mcp.support + +object McpSchemas { + fun objectFields(properties: Map, required: List = emptyList()): Map = mapOf( + "type" to "object", + "properties" to properties, + "required" to required, + "additionalProperties" to false + ) + + fun string(): Map = mapOf("type" to "string") + + fun command(): Map = objectFields(mapOf( + "type" to string(), + "authorityId" to string(), + "artifact" to mapOf("type" to "object") + ), listOf("type", "authorityId", "artifact")) + + fun sign(): Map = objectFields(mapOf( + "keyId" to string(), + "command" to command(), + "tweak" to string(), + "approvals" to objectFields(mapOf( + "keeperId" to mapOf("type" to "integer"), + "nonce" to string(), + "timestamp" to mapOf("type" to "integer"), + "proofs" to mapOf("type" to "array", "items" to objectFields(mapOf( + "fingerprint" to string(), + "signature64" to string() + ), listOf("fingerprint", "signature64"))) + )) + ), listOf("keyId", "command")) +} diff --git a/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpToolSupport.kt b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpToolSupport.kt new file mode 100644 index 00000000..eca72cea --- /dev/null +++ b/features/mcp/src/main/kotlin/org/exploit/keeper/mcp/support/McpToolSupport.kt @@ -0,0 +1,22 @@ +package org.exploit.keeper.mcp.support + +import jakarta.inject.Singleton +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.mcp.service.tool.ToolCall +import org.exploit.keeper.service.core.KeeperService +import org.exploit.keeper.util.extension.isCoordinatorEligible + +@Singleton +class McpToolSupport(private val keeper: KeeperService) { + fun preflight(tool: ToolCall) { + if (tool.requiresReady) { + keeper.ensureReady() + if (keeper.restartRequired()) + throw TKeeperException(ErrorType.RESTART_REQUIRED) + } + + if (tool.requiresCoordinator && !isCoordinatorEligible()) + throw TKeeperException(ErrorType.NOT_COORDINATOR) + } +} diff --git a/features/recovery/build.gradle b/features/recovery/build.gradle index 3b31cfee..e5845fe9 100644 --- a/features/recovery/build.gradle +++ b/features/recovery/build.gradle @@ -1,14 +1,21 @@ plugins { - 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' } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() + maven { + name = "sonatypeCentralSnapshots" + url = uri("https://central.sonatype.com/repository/maven-snapshots/") + mavenContent { + snapshotsOnly() + } + } } dependencies { diff --git a/features/recovery/ecc/build.gradle b/features/recovery/ecc/build.gradle index cf67a98a..c873024c 100644 --- a/features/recovery/ecc/build.gradle +++ b/features/recovery/ecc/build.gradle @@ -1,14 +1,21 @@ plugins { id 'org.jetbrains.kotlin.jvm' 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' } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() + maven { + name = "sonatypeCentralSnapshots" + url = uri("https://central.sonatype.com/repository/maven-snapshots/") + mavenContent { + snapshotsOnly() + } + } } dependencies { diff --git a/features/recovery/pqc/build.gradle b/features/recovery/pqc/build.gradle index fe35930b..1f7d0825 100644 --- a/features/recovery/pqc/build.gradle +++ b/features/recovery/pqc/build.gradle @@ -1,14 +1,21 @@ plugins { id 'org.jetbrains.kotlin.jvm' 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' } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() + maven { + name = "sonatypeCentralSnapshots" + url = uri("https://central.sonatype.com/repository/maven-snapshots/") + mavenContent { + snapshotsOnly() + } + } } dependencies { diff --git a/features/seal-aws/build.gradle b/features/seal-aws/build.gradle index c1515fe2..c2f24bbf 100644 --- a/features/seal-aws/build.gradle +++ b/features/seal-aws/build.gradle @@ -1,11 +1,11 @@ plugins { id 'org.jetbrains.kotlin.jvm' 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' } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() diff --git a/features/seal-gcloud/build.gradle b/features/seal-gcloud/build.gradle index 67cfbdd1..e4d753ab 100644 --- a/features/seal-gcloud/build.gradle +++ b/features/seal-gcloud/build.gradle @@ -1,11 +1,11 @@ plugins { id 'org.jetbrains.kotlin.jvm' 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' } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() @@ -39,13 +39,6 @@ dependencies { implementation('com.google.cloud:google-cloud-kms:2.88.0') } -configurations.configureEach { - resolutionStrategy.force( - "com.google.protobuf:protobuf-java:4.33.5", - "com.google.protobuf:protobuf-java-util:4.33.5" - ) -} - tasks.named('shadowJar') { archiveClassifier.set('') mergeServiceFiles() diff --git a/features/ui/build.gradle b/features/ui/build.gradle index 48047ada..c6a9d76a 100644 --- a/features/ui/build.gradle +++ b/features/ui/build.gradle @@ -1,11 +1,11 @@ plugins { id 'org.jetbrains.kotlin.jvm' 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' } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() diff --git a/gradle.properties b/gradle.properties index 85f4a24e..6539210a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,11 +1,19 @@ kotlin.code.style=official +org.gradle.jvmargs=-Xmx4g +org.gradle.workers.max=2 # Runtime feature modules for the root application build. # Examples: -# ./gradlew shadowJar -Pkeeper.features=authority-evm,seal-aws -Pkeeper.platforms=ecc -# ./gradlew dockerBuild -Pkeeper.docker.features=authority-evm,seal-aws -Pkeeper.docker.platforms=ecc +# ./gradlew shadowJar -Pkeeper.features=agentic-payments,digital-assets -Pkeeper.platforms=ecc +# ./gradlew dockerBuild -Pkeeper.docker.features=digital-assets,seal-aws -Pkeeper.docker.platforms=ecc # ./gradlew shadowJar -Pkeeper.features=all -Pkeeper.platforms=all # ./gradlew shadowJar -Pkeeper.feature.ecies=true -Pkeeper.platform.ecc=true +# Native OS/CPU target for the production fat jar: all (default), linux-amd64, +# linux-arm64, macos-amd64, macos-aarch64, windows-amd64. +# This is separate from keeper.platforms=ecc,pqc (cryptographic modules). +# ./gradlew shadowJar -Pkeeper.features=all -Pkeeper.platforms=all -Ptarget=linux-amd64 +# ./gradlew shadowJar -Pkeeper.features=all -Pkeeper.platforms=all -Ptarget=all +# linux-arm64 and macos-amd64 use system GMP, secp256k1, and libsodium libraries. keeper.features= keeper.platforms= # keeper.docker.features= @@ -15,9 +23,13 @@ keeper.platforms= # keeper.platform.ecc=false # keeper.feature.seal.aws=false # keeper.feature.seal.gcloud=false -# keeper.feature.authority.bitcoin=false -# keeper.feature.authority.evm=false -# keeper.feature.authority.tron=false +# keeper.feature.digital-assets.bitcoin=false +# keeper.feature.digital-assets.evm=false +# keeper.feature.digital-assets.tron=false +# keeper.feature.digital-assets.xrp=false +# keeper.feature.digital-assets.solana=false # keeper.feature.authority.x509=false # keeper.feature.ecies=false # keeper.feature.auth.dev=false +# keeper.feature.agentic-payments.ap2=false +# keeper.feature.agentic-payments.mc-vi=false diff --git a/integration-tests/README.md b/integration-tests/README.md index 7e24091c..36dedfa5 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -55,7 +55,7 @@ Performance tests are separate and do not use the automatic functional-suite ima Do not pass `keeper.features` or `keeper.platforms`. The dedicated integration classpath always includes: - every default production feature -- the explicit opt-in `:features:auth-dev`, `:features:dry-run`, and `:features:recovery` modules +- the explicit opt-in `:features:auth-dev`, `:features:dry-run`, `:features:mcp`, and `:features:recovery` modules - both recovery platform modules - `platform-ecc` and `platform-pqc` - the test-only `:integration-tests:failure-injection` module diff --git a/integration-tests/build.gradle b/integration-tests/build.gradle index 51ebcc87..e5698ed2 100644 --- a/integration-tests/build.gradle +++ b/integration-tests/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' ext { projectName = "Integration Tests" @@ -37,8 +37,15 @@ dependencies { testFixturesApi 'org.testcontainers:junit-jupiter:1.21.4' testFixturesApi(project(":sdk")) - testFixturesApi('org.bitcoinj:bitcoinj-core:0.17.1') + testFixturesApi('org.bitcoinj:bitcoinj-core:0.17.1') { + exclude group: 'com.google.protobuf', module: 'protobuf-javalite' + } testFixturesApi('org.web3j:core:4.12.3') + testFixturesApi platform('org.exploit.signet:bom:0.1.0') + testFixturesApi 'org.exploit.signet:tron' + testFixturesApi 'org.exploit.signet:xrp' + testFixturesApi 'org.exploit.signet:solana' + testFixturesApi 'org.exploit.signet:evm' testFixturesApi('com.fasterxml.jackson.core:jackson-databind:2.22.0') testFixturesApi('com.fasterxml.jackson.module:jackson-module-kotlin:2.22.0') diff --git a/integration-tests/docker/Dockerfile b/integration-tests/docker/Dockerfile index f4887295..50619321 100644 --- a/integration-tests/docker/Dockerfile +++ b/integration-tests/docker/Dockerfile @@ -1,7 +1,7 @@ FROM eclipse-temurin:25-jdk-jammy AS native-build RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates git wget xz-utils \ + ca-certificates git xz-utils \ build-essential autoconf automake libtool pkg-config m4 \ libssl-dev opensc \ && rm -rf /var/lib/apt/lists/* @@ -29,8 +29,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)" \ @@ -95,6 +97,7 @@ ENV JAVA_TOOL_OPTIONS="\ -Dcom.sun.management.jmxremote=false \ -Djdk.serialFilter=!* \ -Djdk.tls.client.protocols=TLSv1.3,TLSv1.2 \ +-Dmsgpack.universal-buffer=true \ -XX:+UseCompactObjectHeaders" ENTRYPOINT ["/opt/tkeeper/entrypoint.sh"] diff --git a/integration-tests/docker/conf/keeper-1/dev.conf b/integration-tests/docker/conf/keeper-1/dev.conf index 3096a637..e7f91cb7 100644 --- a/integration-tests/docker/conf/keeper-1/dev.conf +++ b/integration-tests/docker/conf/keeper-1/dev.conf @@ -5,6 +5,7 @@ keeper { "tkeeper.system.init", "tkeeper.system.seal", "tkeeper.system.unseal", + "tkeeper.system.status", "tkeeper.dkg.create", "tkeeper.dkg.rotate", "tkeeper.dkg.refresh", diff --git a/integration-tests/docker/conf/keeper-8/application.conf b/integration-tests/docker/conf/keeper-8/application.conf new file mode 100644 index 00000000..01753ab2 --- /dev/null +++ b/integration-tests/docker/conf/keeper-8/application.conf @@ -0,0 +1,34 @@ +auth { type = "dev" } + +oras { + insecure = true + allowed-registries = ["registry:5000"] +} + +boot { token = "changeme" } + +keeper { + instance-id = 8 + parallelism = 4 + + database { path = "/tmp/tkeeper/db" } + + audit { + enabled = true + file { directory = "/tmp/tkeeper/audit" } + } + + authority { arbitrary { enabled = true } } + + providers { + selected = "shamir" + shamir { total = 1, threshold = 1 } + } + + server { + public { host = "0.0.0.0", port = 8080 } + internal { host = "0.0.0.0", port = 9090 } + } + + peers = [] +} diff --git a/integration-tests/docker/conf/keeper-8/dev.conf b/integration-tests/docker/conf/keeper-8/dev.conf new file mode 100644 index 00000000..7cdac724 --- /dev/null +++ b/integration-tests/docker/conf/keeper-8/dev.conf @@ -0,0 +1,12 @@ +keeper { + dev { + token = "integration-tests-dev-token" + permissions = [ + "tkeeper.system.init", + "tkeeper.dkg.create", + "tkeeper.key.mcp-allowed.public", + "tkeeper.key.mcp-allowed.sign", + "tkeeper.key.mcp-allowed.verify" + ] + } +} diff --git a/integration-tests/docker/docker-compose.yml b/integration-tests/docker/docker-compose.yml index 36841c86..6a141be0 100644 --- a/integration-tests/docker/docker-compose.yml +++ b/integration-tests/docker/docker-compose.yml @@ -103,6 +103,20 @@ services: volumes: - ./conf/keeper-7:/etc/tkeeper:ro + keeper-8: + image: exploit/tkeeper:dev + ports: + - "8080" + environment: + JAVA_TOOL_OPTIONS: >- + -Djava.library.path=/usr/local/lib:/usr/lib64:/lib64:/usr/lib + --enable-native-access=ALL-UNNAMED + LD_LIBRARY_PATH: /usr/local/lib:/usr/lib64:/lib64:/lib:/usr/lib + volumes: + - ./conf/keeper-8:/etc/tkeeper:ro + tmpfs: + - /var/lib/tkeeper:rw,exec,nosuid,size=512m,uid=185,gid=0 + volumes: keeper-1-data: keeper-2-data: diff --git a/integration-tests/failure-injection/build.gradle b/integration-tests/failure-injection/build.gradle index 86bb73c9..41a968e7 100644 --- a/integration-tests/failure-injection/build.gradle +++ b/integration-tests/failure-injection/build.gradle @@ -1,11 +1,11 @@ plugins { id 'org.jetbrains.kotlin.jvm' 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' } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() @@ -28,7 +28,7 @@ dependencies { compileOnly 'org.exploit.anvil:t-ecdsa:0.2.2' compileOnly 'org.exploit.anvil:t-schnorr:0.2.2' compileOnly 'org.exploit.anvil:t-mldsa:0.2.2' - compileOnly 'org.bouncycastle:bcprov-jdk18on:1.83' + compileOnly 'org.bouncycastle:bcprov-jdk18on:1.85.2' ksp "io.micronaut:micronaut-inject-kotlin:4.10.8" } diff --git a/integration-tests/functional/build.gradle b/integration-tests/functional/build.gradle index 5d9979af..16c346e8 100644 --- a/integration-tests/functional/build.gradle +++ b/integration-tests/functional/build.gradle @@ -4,7 +4,7 @@ plugins { } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() diff --git a/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/AgenticPaymentTests.kt b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/AgenticPaymentTests.kt new file mode 100644 index 00000000..b9e1de98 --- /dev/null +++ b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/AgenticPaymentTests.kt @@ -0,0 +1,311 @@ +package org.exploit.test.functional + +import org.exploit.test.ComposeEnvironment +import org.exploit.test.KeeperComposeFactory +import org.exploit.test.TKeeperComposeEnvironment +import org.exploit.test.authority.* +import org.exploit.tkeeper.sdk.exception.TKeeperException +import org.exploit.tkeeper.sdk.model.* +import org.exploit.tkeeper.sdk.model.command.Command +import org.exploit.tkeeper.sdk.model.command.artifact.* +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import org.bouncycastle.asn1.sec.SECNamedCurves +import java.security.AlgorithmParameters +import java.security.KeyFactory +import java.security.Signature +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECParameterSpec +import java.security.spec.ECPoint +import java.security.spec.ECPublicKeySpec +import java.util.Base64 +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@Testcontainers +class AgenticPaymentTests: TKeeperComposeEnvironment() { + override val env: ComposeEnvironment = dockerEnvironment + private val fixtures = AuthorityFixtures.agenticPayments + private val algorithms = listOf(KeyAlgorithms.SECP256K1, KeyAlgorithms.P256, KeyAlgorithms.ED25519) + private lateinit var publisher: OciAuthorityPublisher + + @BeforeAll + fun publishAuthoritiesAndGenerateKeys() { + publisher = OciAuthorityPublisher(env.getServiceHost("registry", 5000), env.getServicePort("registry", 5000)) + val authorities = AuthorityFixtures.publish(publisher, fixtures) + + for (peer in listOf(1, 4)) { + for (algorithm in algorithms) { + client(peer).dkg().generate(Generate(keyId(algorithm), algorithm, + authorities.authorities(*fixtures.map { it.id }.toTypedArray()), KeyGenMode.CREATE)) + } + } + } + + @Test + fun signsAndVerifiesEveryMandateWithThresholdAndMonoKeys() { + for (peer in listOf(1, 4)) { + for (algorithm in listOf(KeyAlgorithms.P256)) { + for (fixture in fixtures) { + val command = command(fixture) + val signature = client(peer).signature().sign(Sign.of(keyId(algorithm), command)) + + assertTrue(client(peer).signature().verify(Verify.of(keyId(algorithm), command, signature.signature64())).valid()) + assertEquals(SignatureSchemes.ECDSA, signature.type()) + val input = when (val artifact = command.artifact()) { + is Ap2Mandates -> artifact.signingInput() + is McMandates -> artifact.signingInput() + else -> error("Unexpected artifact") + } + assertValidJws(peer, input, signature.signature64()) + } + } + } + } + + @Test + fun deniesEntireRequestWhenAnyActionOrTotalViolatesPolicy() { + val wrongCard = PaymentPayloads.pair("order-2").toMutableList().apply { + this[1] = PaymentMandate.of(this[1].json().replace("card-1", "card-2")) + } + val cases = listOf( + PaymentPayloads.pair(amount = 10001), + PaymentPayloads.pair(amount = 8000) + PaymentPayloads.pair("order-2", 8000), + PaymentPayloads.pair() + wrongCard + ) + for (fixture in fixtures) { + for (mandates in cases) { + val error = assertThrows { + client(1).signature().sign(Sign.of(keyId(), command(fixture, mandates))) + } + assertEquals(ErrorType.POLICY_VIOLATION, error.errorType) + } + val mixed = PaymentPayloads.delegation(type(fixture)) + assertEquals(ErrorType.POLICY_VIOLATION, assertThrows { + client(1).signature().sign(Sign.of(keyId(), command(fixture, mixed))) + }.errorType) + } + } + + @Test + fun rejectsIncompleteDuplicateAndWrongStageRequests() { + for (fixture in fixtures) { + val pair = PaymentPayloads.pair() + for (inputs in listOf(pair + pair, listOf(pair[1]), listOf(pair[0], PaymentPayloads.payment("other")))) { + val error = assertThrows { + client(1).signature().sign(Sign.of(keyId(), command(fixture, inputs))) + } + assertTrue(error.errorType in setOf(ErrorType.INVALID_INTENT, ErrorType.INVALID_AUTHORITY_ARTIFACT)) + } + + val error = assertThrows { + client(1).signature().sign(Sign.of(keyId(), command(fixture, listOf(pair[1]), mode = PaymentRequestMode.PAYMENTS))) + } + assertEquals(ErrorType.INVALID_INTENT, error.errorType) + } + } + + @Test + fun signatureBindsEveryDisclosedActionAndProtectedHeader() { + for (fixture in fixtures) { + val inputs = PaymentPayloads.pair() + PaymentPayloads.pair("order-2") + val command = command(fixture, inputs) + val signature = client(1).signature().sign(Sign.of(keyId(), command)).signature64() + val variants = listOf( + command(fixture, PaymentPayloads.pair()), + command(fixture, PaymentPayloads.pair() + PaymentPayloads.pair("order-2", 7400)), + command(fixtures.first { it.id != fixture.id }, inputs) + ) + for (variant in variants) { + assertFalse(client(1).signature().verify(Verify.of(keyId(), variant, signature)).valid()) + } + } + } + + @Test + fun acceptsExplicitPaymentStageAndRejectsInvalidAuthorityConfiguration() { + for (fixture in fixtures) { + val yaml = fixture.readYaml().replace("config:", "config:\n mode: PAYMENTS") + .replace("action.kind == 'purchase'", "action.kind == 'payment'") + .replace(" - \"checkout.merchantIs(merchants.officeShop)\"\n", "") + val reference = publisher.publish(fixture.repository + "-payments", yaml) + val key = "payment-stage-${type(fixture).substringBefore('.')}" + client(1).dkg().generate(Generate(key, KeyAlgorithms.P256, + KeySetAuthorities.of(KeySetAuthority.oci(fixture.id, reference)), KeyGenMode.CREATE)) + + val command = command(fixture, listOf(PaymentPayloads.payment()), mode = PaymentRequestMode.PAYMENTS) + val signature = client(1).signature().sign(Sign.of(key, command)) + assertTrue(client(1).signature().verify(Verify.of(key, command, signature.signature64())).valid()) + val composed = client(1).signature().compose(Sign.of(key, command), PaymentCredential::class.java) + assertCredential(1, command, composed.credential(), key) + + val invalid = publisher.publish(fixture.repository + "-invalid", yaml.replace("mode: PAYMENTS", "mode: AUTO")) + assertEquals(ErrorType.INVALID_AUTHORITY, assertThrows { + client(1).dkg().generate(Generate("invalid-${type(fixture).substringBefore('.')}", KeyAlgorithms.SECP256K1, + KeySetAuthorities.of(KeySetAuthority.oci(fixture.id, invalid)), KeyGenMode.CREATE)) + }.errorType) + } + } + + @Test + fun rejectsNonP256KeysAndUncommittedDisclosures() { + for (fixture in fixtures) { + for (algorithm in listOf(KeyAlgorithms.SECP256K1, KeyAlgorithms.ED25519)) { + assertEquals(ErrorType.UNSUPPORTED_KEY_ALGORITHM, assertThrows { + client(1).signature().sign(Sign.of(keyId(algorithm), command(fixture))) + }.errorType) + } + val original = PaymentPayloads.signingInput(type(fixture), PaymentPayloads.pair()) + val substituted = PaymentPayloads.signingInput(type(fixture), PaymentPayloads.pair(amount = 100)) + val artifact = if (fixture == AuthorityFixtures.ap2Purchases) + Ap2Mandates(PaymentRequestMode.PAIRED, original.value, substituted.disclosures) + else McMandates(PaymentRequestMode.PAIRED, original.value, substituted.disclosures) + + assertEquals(ErrorType.INVALID_INTENT, assertThrows { + client(1).signature().sign(Sign.of(keyId(), Command.of(fixture.id, artifact))) + }.errorType) + } + } + + @Test + fun composesReadyCredentialsForThresholdAndMonoKeys() { + for (peer in listOf(1, 4)) { + for (fixture in fixtures) { + val command = command(fixture, PaymentPayloads.pair() + PaymentPayloads.pair("order-2", 100)) + val result = client(peer).signature().compose(Sign.of(keyId(), command), PaymentCredential::class.java) + assertEquals(type(fixture), result.type()) + assertEquals(1, result.generation()) + assertTrue(result.imposters().isEmpty()) + assertCredential(peer, command, result.credential()) + } + } + } + + @Test + fun composeEnforcesPolicyAndDisclosureCommitments() { + for (fixture in fixtures) { + assertEquals(ErrorType.POLICY_VIOLATION, assertThrows { + client(1).signature().compose(Sign.of(keyId(), command(fixture, PaymentPayloads.pair(amount = 10001)))) + }.errorType) + val input = PaymentPayloads.signingInput(type(fixture), PaymentPayloads.pair()) + val artifact = if (fixture == AuthorityFixtures.ap2Purchases) + Ap2Mandates(PaymentRequestMode.PAIRED, input.value, input.disclosures.dropLast(1)) + else McMandates(PaymentRequestMode.PAIRED, input.value, input.disclosures.dropLast(1)) + assertEquals(ErrorType.INVALID_INTENT, assertThrows { + client(1).signature().compose(Sign.of(keyId(), Command.of(fixture.id, artifact))) + }.errorType) + } + } + + @Test + fun composeRetainsRawSignatureFallback() { + val key = "compose-fallback" + client(4).dkg().generate(Generate(key, KeyAlgorithms.P256, KeySetAuthorities.arbitrary(), KeyGenMode.CREATE)) + val command = Command.arbitrary(ArbitraryData(SignatureSchemes.ECDSA, HashMethod.SHA256, + Base64.getEncoder().encodeToString("compose fallback".toByteArray()))) + val result = client(4).signature().compose(Sign.of(key, command), ThresholdSignature::class.java) + + assertEquals(SignatureSchemes.ECDSA, result.type()) + assertTrue(client(4).signature().verify(Verify.of(key, command, result.signature64())).valid()) + } + + @Test + fun composeRequiresTrustedCheckoutSignatureBeforePolicyEvaluation() { + val mapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() + val keys = mapper.writeValueAsString(mapOf("officeShop" to listOf(PaymentPayloads.merchantJwk()))) + for (fixture in fixtures) { + val yaml = fixture.readYaml().replace("config:", "config:\n checkoutKeys: $keys") + val reference = publisher.publish(fixture.repository + "-verified", yaml) + val key = "verified-checkout-${type(fixture).substringBefore('.')}" + client(1).dkg().generate(Generate(key, KeyAlgorithms.P256, + KeySetAuthorities.of(KeySetAuthority.oci(fixture.id, reference)), KeyGenMode.CREATE)) + val command = command(fixture) + val result = client(1).signature().compose(Sign.of(key, command), PaymentCredential::class.java) + assertCredential(1, command, result.credential(), key) + + val pair = PaymentPayloads.pair() + val mandate = mapper.readTree(pair[0].json()) as com.fasterxml.jackson.databind.node.ObjectNode + val parts = mandate.get("checkout_jwt").textValue().split('.') + val checkout = mapper.readTree(Base64.getUrlDecoder().decode(parts[1])) as com.fasterxml.jackson.databind.node.ObjectNode + checkout.put("id", "changed-after-signing") + val jwt = parts[0] + "." + PaymentPayloads.encoded(checkout.toString()) + "." + parts[2] + mandate.put("checkout_jwt", jwt).put("checkout_hash", PaymentPayloads.hash(jwt)) + val inputs = listOf(PaymentMandate.of(mandate.toString()), PaymentPayloads.payment(PaymentPayloads.hash(jwt))) + assertEquals(ErrorType.INVALID_INTENT, assertThrows { + client(1).signature().compose(Sign.of(key, command(fixture, inputs))) + }.errorType) + } + } + + private fun assertCredential(peer: Int, command: Command, credential: String, key: String = keyId()) { + val pieces = credential.split('~') + val (input, disclosures) = when (val artifact = command.artifact()) { + is Ap2Mandates -> artifact.signingInput() to artifact.disclosures() + is McMandates -> artifact.signingInput() to artifact.disclosures() + else -> error("Unexpected artifact") + } + assertEquals("", pieces.last()) + assertEquals(disclosures, pieces.subList(1, pieces.lastIndex)) + val jwt = pieces.first() + assertEquals(input, jwt.substringBeforeLast('.')) + val raw = Base64.getUrlDecoder().decode(jwt.substringAfterLast('.')) + assertEquals(64, raw.size) + assertFalse(jwt.substringAfterLast('.').contains('=')) + assertCompactJws(peer, jwt, key) + assertTrue(client(peer).signature().verify(Verify.of(key, command, + Base64.getEncoder().encodeToString(raw))).valid()) + + val payload = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper().readTree( + Base64.getUrlDecoder().decode(jwt.split('.')[1])) + assertEquals(disclosures.map(PaymentPayloads::hash), payload.get("delegate_payload").map { it.get("...").textValue() }) + } + + private fun assertValidJws(peer: Int, input: String, signature64: String) { + val raw = Base64.getDecoder().decode(signature64) + val jwt = input + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(raw.copyOf(64)) + assertCompactJws(peer, jwt) + } + + private fun assertCompactJws(peer: Int, jwt: String, key: String = keyId()) { + val parts = jwt.split('.') + val encodedKey = Base64.getDecoder().decode(client(peer).central().getPublicKey(key).data64()) + val point = SECNamedCurves.getByName("secp256r1").curve.decodePoint(encodedKey).normalize() + val parameters = AlgorithmParameters.getInstance("EC").apply { init(ECGenParameterSpec("secp256r1")) } + .getParameterSpec(ECParameterSpec::class.java) + val publicKey = KeyFactory.getInstance("EC").generatePublic(ECPublicKeySpec( + ECPoint(point.affineXCoord.toBigInteger(), point.affineYCoord.toBigInteger()), parameters)) + val verifier = Signature.getInstance("SHA256withECDSAinP1363Format") + verifier.initVerify(publicKey) + verifier.update((parts[0] + "." + parts[1]).toByteArray(Charsets.US_ASCII)) + assertTrue(verifier.verify(Base64.getUrlDecoder().decode(parts[2]))) + } + + private fun command( + fixture: AuthorityFixture, + mandates: List = PaymentPayloads.pair(), + mode: PaymentRequestMode = PaymentRequestMode.PAIRED + ): Command { + val input = PaymentPayloads.signingInput(type(fixture), mandates) + val artifact = if (fixture == AuthorityFixtures.ap2Purchases) + Ap2Mandates(mode, input.value, input.disclosures) + else + McMandates(mode, input.value, input.disclosures) + + return Command.of(fixture.id, artifact) + } + + private fun keyId(algorithm: String = KeyAlgorithms.P256): String = "agentic-${algorithm.lowercase()}" + + private fun type(fixture: AuthorityFixture): String = + if (fixture == AuthorityFixtures.ap2Purchases) IntentTypes.AP2_MANDATE else IntentTypes.MCINTENT_MANDATE + + companion object { + @Container + @JvmField + val dockerEnvironment = KeeperComposeFactory.newEnv() + } +} diff --git a/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/DigitalAssetAuthorityTests.kt b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/DigitalAssetAuthorityTests.kt new file mode 100644 index 00000000..a509c0ea --- /dev/null +++ b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/DigitalAssetAuthorityTests.kt @@ -0,0 +1,355 @@ +package org.exploit.test.functional + +import org.exploit.ecc.secp256k1.LibSecp256k1 +import org.exploit.ecc.ed25519.signature.Ed25519Signature +import org.exploit.ecc.secp256k1.key.Secp256k1PublicKey +import org.exploit.ecc.secp256k1.params.Secp256k1CurveParams +import org.exploit.ecc.secp256k1.signature.Secp256k1RecoverableECDSASignature +import org.exploit.ecc.secp256k1.signer.ecdsa.Secp256k1ECDSAVerifier +import org.exploit.signet.evm.transaction.parser.EvmTransactionParser +import org.exploit.signet.solana.codec.SolanaTransactionCodec +import org.exploit.signet.solana.model.transaction.SolanaUnsignedTransaction +import org.exploit.signet.solana.model.transaction.SolanaSignedTransaction +import org.exploit.signet.solana.util.SolanaEncoding +import org.exploit.signet.tron.codec.TronTransactionCodec +import org.exploit.signet.tron.model.transaction.TronUnsignedTransaction +import org.exploit.signet.tron.model.transaction.TronSignedTransaction +import org.exploit.signet.xrp.codec.XrpSignatureCodec +import org.exploit.signet.xrp.model.transaction.XrpUnsignedTransaction +import org.exploit.signet.xrp.model.transaction.XrpSignedTransaction +import org.exploit.signet.xrp.transaction.parser.XrpTransactionParser +import org.exploit.sodium.LibSodium +import org.exploit.test.ComposeEnvironment +import org.exploit.test.KeeperComposeFactory +import org.exploit.test.TKeeperComposeEnvironment +import org.exploit.test.authority.AuthorityFixtures +import org.exploit.test.authority.AuthorityPayloads +import org.exploit.test.authority.DigitalAssetPayloads +import org.exploit.test.authority.OciAuthorityPublisher +import org.exploit.test.authority.PublishedAuthorities +import org.exploit.tkeeper.sdk.exception.TKeeperException +import org.exploit.tkeeper.sdk.model.ErrorType +import org.exploit.tkeeper.sdk.model.Generate +import org.exploit.tkeeper.sdk.model.KeyAlgorithms +import org.exploit.tkeeper.sdk.model.KeyGenMode +import org.exploit.tkeeper.sdk.model.Sign +import org.exploit.tkeeper.sdk.model.SignatureSchemes +import org.exploit.tkeeper.sdk.model.SignedEvmTransaction +import org.exploit.tkeeper.sdk.model.SignedSolanaTransaction as SolanaOutcome +import org.exploit.tkeeper.sdk.model.SignedTronTransaction as TronOutcome +import org.exploit.tkeeper.sdk.model.SignedXrpTransaction as XrpOutcome +import org.exploit.tkeeper.sdk.model.Verify +import org.exploit.tkeeper.sdk.model.command.Command +import org.exploit.tkeeper.sdk.model.command.artifact.Artifact +import org.exploit.tkeeper.sdk.model.command.artifact.UnsignedSolanaTransaction as SolanaArtifact +import org.exploit.tkeeper.sdk.model.command.artifact.UnsignedTronTransaction as TronArtifact +import org.exploit.tkeeper.sdk.model.command.artifact.UnsignedXrpTransaction as XrpArtifact +import org.exploit.tss.TSS +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestMethodOrder +import org.junit.jupiter.api.assertThrows +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import java.util.Base64 +import java.util.HexFormat +import java.math.BigInteger +import kotlin.test.assertEquals +import kotlin.test.assertContentEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@Testcontainers +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +class DigitalAssetAuthorityTests : TKeeperComposeEnvironment() { + override val env: ComposeEnvironment = dockerEnvironment + + private lateinit var authorities: PublishedAuthorities + + @BeforeAll + fun publishAuthorities() { + TSS.loadLibraries() + LibSecp256k1.load() + LibSodium.load() + + val publisher = OciAuthorityPublisher( + registryHost = env.getServiceHost(REGISTRY_SERVICE, REGISTRY_PORT), + registryPort = env.getServicePort(REGISTRY_SERVICE, REGISTRY_PORT) + ) + authorities = AuthorityFixtures.publish( + publisher, + listOf( + AuthorityFixtures.tronNativeTransfer, + AuthorityFixtures.xrpNativePayment, + AuthorityFixtures.solanaNativeTransfer, + AuthorityFixtures.evmMainnetUsdcTransfer, + AuthorityFixtures.bitcoinBtcSmallOutput + ) + ) + } + + @Test + @Order(1) + fun generateKeysWithChainAuthorities() { + generate(TRON_KEY, KeyAlgorithms.SECP256K1, AuthorityFixtures.TRON_NATIVE_TRANSFER) + generate(XRP_KEY, KeyAlgorithms.SECP256K1, AuthorityFixtures.XRP_NATIVE_PAYMENT) + generate(XRP_ED_KEY, KeyAlgorithms.ED25519, AuthorityFixtures.XRP_NATIVE_PAYMENT) + generate(SOLANA_KEY, KeyAlgorithms.ED25519, AuthorityFixtures.SOLANA_NATIVE_TRANSFER) + generate(EVM_KEY, KeyAlgorithms.SECP256K1, AuthorityFixtures.EVM_MAINNET_USDC_TRANSFER) + generate(BITCOIN_KEY, KeyAlgorithms.SECP256K1, AuthorityFixtures.BITCOIN_BTC_SMALL_OUTPUT) + } + + @Test + @Order(2) + fun tronPolicyAndOwnerAreEnforced() { + assertAssetFlow( + TRON_KEY, AuthorityFixtures.TRON_NATIVE_TRANSFER, SignatureSchemes.ECDSA, + publicKey64(XRP_KEY), 100_000_000, DigitalAssetPayloads::tronTransfer, ::verifyTronSignature + ) + } + + @Test + @Order(3) + fun xrpPolicyAndSigningPublicKeyAreEnforced() { + assertAssetFlow( + XRP_KEY, AuthorityFixtures.XRP_NATIVE_PAYMENT, SignatureSchemes.ECDSA, + publicKey64(TRON_KEY), 100, DigitalAssetPayloads::xrpPayment, ::verifyXrpEcdsaSignature + ) + } + + @Test + @Order(4) + fun solanaPolicyAndRequiredSignerAreEnforced() { + val unrelated = Base64.getEncoder().encodeToString(ByteArray(32) { 1 }) + assertAssetFlow( + SOLANA_KEY, AuthorityFixtures.SOLANA_NATIVE_TRANSFER, SignatureSchemes.EDDSA, + unrelated, 100, DigitalAssetPayloads::solanaTransfer, ::verifySolanaSignature + ) + } + + @Test + @Order(5) + fun xrpEd25519PolicyAndSigningPublicKeyAreEnforced() { + assertAssetFlow( + XRP_ED_KEY, AuthorityFixtures.XRP_NATIVE_PAYMENT, SignatureSchemes.EDDSA, + publicKey64(XRP_KEY), 100, DigitalAssetPayloads::xrpPayment, ::verifyXrpEd25519Signature + ) + } + + @Test + @Order(6) + fun composesBroadcastableTronTransaction() { + val publicKey64 = publicKey64(TRON_KEY) + val artifact = DigitalAssetPayloads.tronTransfer(publicKey64, 100_000_000) + val result = client(1).signature().compose( + Sign.of(TRON_KEY, Command.of(AuthorityFixtures.TRON_NATIVE_TRANSFER, artifact)), + TronOutcome::class.java + ) + + val unsigned = TronTransactionCodec.decode(artifact.transaction()) as TronUnsignedTransaction + val signed = TronTransactionCodec.decode(result.rawTransaction()) as TronSignedTransaction + + assertEquals("tron.transaction", result.type()) + assertEquals(unsigned.rawDataHex(), signed.rawDataHex()) + assertEquals(signed.hash(), result.transactionHash()) + assertEquals(1, signed.signatures().size) + verifyTronSignature(publicKey64, artifact, HexFormat.of().parseHex(signed.signatures().single())) + } + + @Test + @Order(7) + fun composesBroadcastableXrpTransactions() { + assertXrpComposed(XRP_KEY) + assertXrpComposed(XRP_ED_KEY) + } + + @Test + @Order(8) + fun composesBroadcastableSolanaTransaction() { + val artifact = DigitalAssetPayloads.solanaTransfer(publicKey64(SOLANA_KEY), 100) + val result = client(1).signature().compose( + Sign.of(SOLANA_KEY, Command.of(AuthorityFixtures.SOLANA_NATIVE_TRANSFER, artifact)), + SolanaOutcome::class.java + ) + + val unsigned = SolanaTransactionCodec.decode(artifact.transaction()) as SolanaUnsignedTransaction + val signed = SolanaTransactionCodec.decode(result.rawTransaction()) as SolanaSignedTransaction + + assertEquals("solana.transaction", result.type()) + assertTrue(result.complete()) + assertContentEquals(unsigned.message().bytes(), signed.message().bytes()) + assertEquals(signed.hash(), result.transactionHash()) + } + + @Test + @Order(9) + fun composesSignedEvmTransaction() { + val artifact = AuthorityPayloads.evmUsdcTransfer(BigInteger("100000000")) + val transaction = client(1).signature().compose( + Sign.of(EVM_KEY, Command.of(AuthorityFixtures.EVM_MAINNET_USDC_TRANSFER, artifact)), + SignedEvmTransaction::class.java + ) + + val parser = EvmTransactionParser() + val unsigned = parser.parseUnsigned( + "0x" + HexFormat.of().formatHex(Base64.getDecoder().decode(artifact.message64())) + ) + val signed = parser.parseSigned(transaction.rawTransaction()) + + assertEquals("evm.transaction", transaction.type()) + assertEquals(unsigned.type(), signed.type()) + assertEquals(unsigned.chainId(), signed.chainId()) + assertEquals(unsigned.to(), signed.to()) + assertEquals(unsigned.value(), signed.value()) + assertEquals(unsigned.data(), signed.data()) + assertEquals(signed.hash(), transaction.transactionHash()) + } + + @Test + @Order(10) + fun transactionOwnerChecksUseTheTweakedSigningKey() { + assertTweakedSigner(TRON_KEY, AuthorityFixtures.TRON_NATIVE_TRANSFER, 100_000_000, + DigitalAssetPayloads::tronTransfer) + assertTweakedSigner(XRP_KEY, AuthorityFixtures.XRP_NATIVE_PAYMENT, 100, + DigitalAssetPayloads::xrpPayment) + assertTweakedSigner(XRP_ED_KEY, AuthorityFixtures.XRP_NATIVE_PAYMENT, 100, + DigitalAssetPayloads::xrpPayment) + assertTweakedSigner(SOLANA_KEY, AuthorityFixtures.SOLANA_NATIVE_TRANSFER, 100, + DigitalAssetPayloads::solanaTransfer) + assertTweakedSigner(BITCOIN_KEY, AuthorityFixtures.BITCOIN_BTC_SMALL_OUTPUT, 100_000) { key, amount -> + AuthorityPayloads.bitcoinP2pkhSpend(key, amount) + } + } + + private fun assertTweakedSigner( + keyId: String, + authorityId: String, + amount: Long, + payload: (String, Long) -> A + ) { + val tweakedKey64 = client(1).central().getPublicKey(keyId, TWEAK).data64() + val command = Command.of(authorityId, payload(tweakedKey64, amount)) + val signature = client(1).signature().sign(Sign.withTweak(keyId, command, TWEAK)) + assertTrue(client(1).signature().verify( + Verify.withTweak(keyId, command, signature.signature64(), TWEAK) + ).valid()) + + val baseKey64 = publicKey64(keyId) + val wrongOwner = Command.of(authorityId, payload(baseKey64, amount)) + val error = assertThrows { + client(1).signature().sign(Sign.withTweak(keyId, wrongOwner, TWEAK)) + } + assertEquals(ErrorType.INVALID_AUTHORITY_ARTIFACT, error.errorType) + } + + private fun assertXrpComposed(keyId: String) { + val artifact = DigitalAssetPayloads.xrpPayment(publicKey64(keyId), 100) + val result = client(1).signature().compose( + Sign.of(keyId, Command.of(AuthorityFixtures.XRP_NATIVE_PAYMENT, artifact)), + XrpOutcome::class.java + ) + + val unsigned = XrpTransactionParser().parse(artifact.transaction()) as XrpUnsignedTransaction + val signed = XrpTransactionParser().parse(result.rawTransaction()) as XrpSignedTransaction + + assertEquals("xrp.transaction", result.type()) + assertEquals(unsigned.data(), signed.data()) + assertEquals(signed.hash(), result.transactionHash()) + } + + private fun generate(keyId: String, algorithm: String, authorityId: String) { + client(1).dkg().generate( + Generate(keyId, algorithm, authorities.authorities(authorityId), KeyGenMode.CREATE) + ) + } + + private fun publicKey64(keyId: String): String = + client(1).central().getPublicKey(keyId).data64() + + private fun assertAssetFlow( + keyId: String, + authorityId: String, + scheme: String, + unrelatedKey64: String, + maxAmount: Long, + payload: (String, Long) -> A, + verifyProtocolSignature: (String, A, ByteArray) -> Unit + ) { + val publicKey64 = publicKey64(keyId) + val artifact = payload(publicKey64, maxAmount) + val allowed = Command.of(authorityId, artifact) + val signature = client(1).signature().sign(Sign.of(keyId, allowed)) + + assertEquals(scheme, signature.type()) + val signature64 = assertNotNull(signature.signature64()) + assertTrue(client(1).signature().verify( + Verify.of(keyId, allowed, signature64) + ).valid()) + verifyProtocolSignature(publicKey64, artifact, Base64.getDecoder().decode(signature64)) + + val denied = Command.of(authorityId, payload(publicKey64, maxAmount + 1)) + val policyError = assertThrows { + client(1).signature().sign(Sign.of(keyId, denied)) + } + assertEquals(ErrorType.POLICY_VIOLATION, policyError.errorType) + + val wrongSigner = Command.of(authorityId, payload(unrelatedKey64, maxAmount)) + val signerError = assertThrows { + client(1).signature().sign(Sign.of(keyId, wrongSigner)) + } + assertEquals(ErrorType.INVALID_AUTHORITY_ARTIFACT, signerError.errorType) + } + + private fun verifyTronSignature(publicKey64: String, artifact: TronArtifact, raw: ByteArray) { + val transaction = TronTransactionCodec.decode(artifact.transaction()) as TronUnsignedTransaction + val publicKey = Secp256k1PublicKey.fromBytes(Base64.getDecoder().decode(publicKey64)) + val signature = Secp256k1RecoverableECDSASignature.decode(raw) + + assertTrue(Secp256k1ECDSAVerifier().verify(transaction.signingBytes(), signature, publicKey)) + assertEquals( + Secp256k1CurveParams.getInstance().findRecId( + signature.r(), signature.s(), transaction.signingBytes(), publicKey.Y() + ), + signature.recId().toInt() + ) + } + + private fun verifyXrpEcdsaSignature(publicKey64: String, artifact: XrpArtifact, raw: ByteArray) { + val transaction = XrpTransactionParser().parse(artifact.transaction()) as XrpUnsignedTransaction + val signature = Secp256k1RecoverableECDSASignature.decode(raw) + + assertNotNull(transaction.sign(XrpSignatureCodec.encode(signature)).hash()) + } + + private fun verifyXrpEd25519Signature(publicKey64: String, artifact: XrpArtifact, raw: ByteArray) { + val transaction = XrpTransactionParser().parse(artifact.transaction()) as XrpUnsignedTransaction + val signature = Ed25519Signature.decode(raw) + + assertNotNull(transaction.sign(XrpSignatureCodec.encode(signature)).hash()) + } + + private fun verifySolanaSignature(publicKey64: String, artifact: SolanaArtifact, raw: ByteArray) { + val transaction = SolanaTransactionCodec.decode(artifact.transaction()) as SolanaUnsignedTransaction + val signer = SolanaEncoding.base58(Base64.getDecoder().decode(publicKey64)) + + assertNotNull(transaction.withSignature(signer, raw).hash()) + } + + companion object { + private const val REGISTRY_SERVICE = "registry" + private const val REGISTRY_PORT = 5000 + private const val TRON_KEY = "asset-tron" + private const val XRP_KEY = "asset-xrp" + private const val XRP_ED_KEY = "asset-xrp-ed25519" + private const val SOLANA_KEY = "asset-solana" + private const val EVM_KEY = "asset-evm" + private const val BITCOIN_KEY = "asset-bitcoin" + private const val TWEAK = "user:2091029520/digital-assets-v1" + + @Container + @JvmField + val dockerEnvironment = KeeperComposeFactory.newEnv() + } +} diff --git a/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/MCPTests.kt b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/MCPTests.kt new file mode 100644 index 00000000..0d7fae36 --- /dev/null +++ b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/MCPTests.kt @@ -0,0 +1,375 @@ +package org.exploit.test.functional + +import org.exploit.ecc.secp256k1.LibSecp256k1 +import org.exploit.signet.tron.codec.TronTransactionCodec +import org.exploit.signet.tron.model.transaction.TronSignedTransaction +import org.exploit.sodium.LibSodium +import org.exploit.test.ComposeEnvironment +import org.exploit.test.KeeperComposeFactory +import org.exploit.test.TKeeperComposeEnvironment +import org.exploit.test.authority.AuthorityFixtures +import org.exploit.test.authority.DigitalAssetPayloads +import org.exploit.test.authority.OciAuthorityPublisher +import org.exploit.test.authority.PublishedAuthorities +import org.exploit.test.mcp.McpTestClient +import org.exploit.tkeeper.sdk.model.Generate +import org.exploit.tkeeper.sdk.model.KeyAlgorithms +import org.exploit.tkeeper.sdk.model.KeyGenMode +import org.exploit.tkeeper.sdk.model.KeySetAuthorities +import org.exploit.tss.TSS +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestMethodOrder +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import java.util.Base64 +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@Testcontainers +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +class MCPTests : TKeeperComposeEnvironment() { + override val env: ComposeEnvironment = dockerEnvironment + override val bootstrapKeepers: List = super.bootstrapKeepers + + KeeperBootstrap(service = "keeper-8", peerId = 1, threshold = 1, total = 1) + + private lateinit var authorities: PublishedAuthorities + private val mcp by lazy { mcpClient() } + + @BeforeAll + fun publishAndGenerateKeys() { + TSS.loadLibraries() + LibSecp256k1.load() + LibSodium.load() + + val publisher = OciAuthorityPublisher( + registryHost = env.getServiceHost(REGISTRY_SERVICE, REGISTRY_PORT), + registryPort = env.getServicePort(REGISTRY_SERVICE, REGISTRY_PORT) + ) + authorities = AuthorityFixtures.publish(publisher, listOf( + AuthorityFixtures.tronNativeTransfer, + AuthorityFixtures.xrpNativePayment, + AuthorityFixtures.solanaNativeTransfer, + AuthorityFixtures.paymentsSmall, + AuthorityFixtures.evmMainnetNativeSmall, + AuthorityFixtures.x509ServiceLeaf, + AuthorityFixtures.ap2Purchases, + AuthorityFixtures.mcPurchases + )) + + generate(TRON_KEY, KeyAlgorithms.SECP256K1, AuthorityFixtures.TRON_NATIVE_TRANSFER) + generate(XRP_KEY, KeyAlgorithms.SECP256K1, AuthorityFixtures.XRP_NATIVE_PAYMENT) + generate(SOLANA_KEY, KeyAlgorithms.ED25519, AuthorityFixtures.SOLANA_NATIVE_TRANSFER) + generate(TYPED_KEY, KeyAlgorithms.SECP256K1, AuthorityFixtures.CUSTOM_PAYMENTS_SMALL) + generate(EVM_KEY, KeyAlgorithms.SECP256K1, AuthorityFixtures.EVM_MAINNET_NATIVE_SMALL) + generate(X509_KEY, KeyAlgorithms.P256, AuthorityFixtures.X509_SERVICE_LEAF) + generate(AP2_KEY, KeyAlgorithms.P256, AuthorityFixtures.AP2_PURCHASES) + generate(MC_KEY, KeyAlgorithms.P256, AuthorityFixtures.MCINTENT_PURCHASES) + + for (keyId in listOf(ALLOWED_KEY, DENIED_KEY)) { + client("keeper-8").dkg().generate(Generate( + keyId, + KeyAlgorithms.SECP256K1, + KeySetAuthorities.arbitrary(), + KeyGenMode.CREATE + )) + } + } + + @Test + @Order(1) + fun toolsAreSeparatedAndVisibleOnlyWithPermission() { + val discovery = mcp.request("server/discover") + assertEquals(200, discovery.statusCode()) + val server = mcp.json.readTree(discovery.body()).path("result") + assertEquals("2026-07-28", server.path("supportedVersions").first().asText()) + assertTrue(server.has("ttlMs")) + assertEquals(0, server.path("ttlMs").asInt()) + assertEquals("private", server.path("cacheScope").asText()) + + val catalog = mcp.json.readTree(mcp.request("tools/list").body()).path("result") + assertTrue(catalog.has("ttlMs")) + assertEquals(0, catalog.path("ttlMs").asInt()) + assertEquals("private", catalog.path("cacheScope").asText()) + + assertEquals(setOf( + "identity.list", "identity.describe_authority", + "utility.keeper_status", "utility.get_public_key", "utility.verify_signature", + "action.sign", "action.compose" + ), toolNames(mcp)) + + val restricted = mcpClient("keeper-2") + assertFalse("utility.keeper_status" in toolNames(restricted)) + assertEquals("ACCESS_DENIED", restricted.toolError("utility.keeper_status", emptyMap()).path("error").asText()) + + assertEquals(401, mcp.request("tools/list", authenticated = false).statusCode()) + } + + @Test + @Order(2) + fun identitiesArePagedWithoutDuplicates() { + val keys = mutableListOf() + var cursor: String? = null + + do { + val arguments = if (cursor == null) mapOf("limit" to 2) else mapOf("limit" to 2, "lastSeen" to cursor) + val page = mcp.result("identity.list", arguments) + keys += page.path("items").map { it.path("keyId").asText() } + cursor = page.path("nextCursor").takeUnless { it.isNull }?.asText() + } while (cursor != null) + + assertEquals(setOf(TRON_KEY, XRP_KEY, SOLANA_KEY, TYPED_KEY, EVM_KEY, X509_KEY, AP2_KEY, MC_KEY), keys.toSet()) + assertEquals(keys.size, keys.toSet().size) + assertEquals("INVALID_ARGUMENT", mcp.toolError("identity.list", mapOf("limit" to 0)) + .path("error").asText()) + } + + @Test + @Order(3) + fun chainAuthoritiesExposeCommandSchemasAndDescriptions() { + for ((keyId, authorityId, type, field) in listOf( + listOf(TRON_KEY, AuthorityFixtures.TRON_NATIVE_TRANSFER, "tron.transaction", "transaction"), + listOf(XRP_KEY, AuthorityFixtures.XRP_NATIVE_PAYMENT, "xrp.transaction", "transaction"), + listOf(SOLANA_KEY, AuthorityFixtures.SOLANA_NATIVE_TRANSFER, "solana.transaction", "transaction"), + listOf(EVM_KEY, AuthorityFixtures.EVM_MAINNET_NATIVE_SMALL, "evm.transaction", "message64"), + listOf(X509_KEY, AuthorityFixtures.X509_SERVICE_LEAF, "x509.tbs-certificate", "der64") + )) { + val authority = describe(keyId, authorityId) + val command = authority.path("commandSchema") + + assertEquals(type, authority.path("type").asText()) + assertEquals(type, command.path("properties").path("type").path("const").asText()) + assertEquals(authorityId, command.path("properties").path("authorityId").path("const").asText()) + assertEquals("string", command.path("properties").path("artifact") + .path("properties").path(field).path("type").asText()) + } + + assertEquals( + "Sign a TRON native transfer of up to 100 TRX to the configured recipient.", + describe(TRON_KEY, AuthorityFixtures.TRON_NATIVE_TRANSFER).path("description").asText() + ) + assertEquals("AUTHORITY_VIOLATION", mcp.toolError("identity.describe_authority", mapOf( + "keyId" to TRON_KEY, "authorityId" to "missing" + )).path("error").asText()) + + for ((keyId, authorityId, type) in listOf( + Triple(AP2_KEY, AuthorityFixtures.AP2_PURCHASES, "ap2.mandate"), + Triple(MC_KEY, AuthorityFixtures.MCINTENT_PURCHASES, "mcintent.mandate") + )) { + val authority = describe(keyId, authorityId) + val artifact = authority.path("commandSchema").path("properties").path("artifact") + + assertEquals(type, authority.path("type").asText()) + assertEquals(listOf("PAIRED", "PAYMENTS", "CHECKOUTS"), artifact.path("properties") + .path("mode").path("enum").map { it.asText() }) + assertEquals("array", artifact.path("properties").path("disclosures").path("type").asText()) + } + } + + @Test + @Order(4) + fun typedAuthoritySchemaReflectsItsConfiguredFields() { + val typed = describe(TYPED_KEY, AuthorityFixtures.CUSTOM_PAYMENTS_SMALL) + val fields = typed.path("commandSchema").path("properties").path("artifact") + .path("properties").path("typed") + + assertEquals("Sign USD payment requests of up to 1000 units.", typed.path("description").asText()) + assertEquals(setOf("purpose", "amount", "currency", "payload"), + fields.path("properties").fieldNames().asSequence().toSet()) + assertEquals(setOf("purpose", "amount", "currency"), fields.path("required").map { it.asText() }.toSet()) + assertEquals(listOf("string", "null"), fields.path("properties").path("payload") + .path("type").map { it.asText() }) + } + + @Test + @Order(5) + fun utilitiesReportReadyStateAndReturnTheSamePublicKeyAsTheApi() { + val status = mcp.result("utility.keeper_status", emptyMap()) + assertEquals("UNSEALED", status.path("state").asText()) + assertTrue(status.path("ready").asBoolean()) + + val key = mcp.result("utility.get_public_key", mapOf("keyId" to TRON_KEY)) + assertEquals(client(1).central().getPublicKey(TRON_KEY).data64(), key.path("data64").asText()) + assertEquals(key.path("data64").asText(), mcp.result("utility.get_public_key", mapOf( + "keyId" to TRON_KEY, "generation" to 1 + )).path("data64").asText()) + } + + @Test + @Order(6) + fun signVerifyAndComposeUseTheExistingPolicyAndTransactionPipeline() { + val publicKey64 = client(1).central().getPublicKey(TRON_KEY).data64() + val transaction = DigitalAssetPayloads.tronTransfer(publicKey64, 100_000_000).transaction() + val command = mapOf( + "type" to "tron.transaction", + "authorityId" to AuthorityFixtures.TRON_NATIVE_TRANSFER, + "artifact" to mapOf("transaction" to transaction) + ) + val signing = mapOf("keyId" to TRON_KEY, "command" to command) + + val signed = mcp.result("action.sign", signing) + val signature64 = signed.path("signature64").asText() + assertEquals("ECDSA", signed.path("type").asText()) + assertTrue(signature64.isNotBlank()) + + val verified = mcp.result("utility.verify_signature", mapOf( + "keyId" to TRON_KEY, + "command" to mapOf("type" to "tron.transaction", "artifact" to mapOf("transaction" to transaction)), + "signature64" to signature64 + )) + assertTrue(verified.path("valid").asBoolean()) + + val composed = mcp.result("action.compose", signing) + val parsed = TronTransactionCodec.decode(composed.path("rawTransaction").asText()) as TronSignedTransaction + assertEquals("tron.transaction", composed.path("type").asText()) + assertEquals(parsed.hash(), composed.path("transactionHash").asText()) + assertEquals(1, parsed.signatures().size) + + val denied = signing + ("command" to (command + ("artifact" to mapOf( + "transaction" to DigitalAssetPayloads.tronTransfer(publicKey64, 100_000_001).transaction() + )))) + assertEquals("POLICY_VIOLATION", mcp.toolError("action.sign", denied).path("error").asText()) + } + + @Test + @Order(7) + fun customMcpSigningEnforcesTheAuthorityPolicy() { + fun request(amount: Int): Map { + val artifact = mapOf( + "scheme" to "ECDSA", "hash" to "SHA256", + "typed" to mapOf("purpose" to "payments", "amount" to amount, "currency" to "USD") + ) + return mapOf( + "keyId" to TYPED_KEY, + "command" to mapOf( + "type" to "custom", + "authorityId" to AuthorityFixtures.CUSTOM_PAYMENTS_SMALL, + "artifact" to artifact + ) + ) + } + + val allowed = request(1000) + val signature = mcp.result("action.sign", allowed).path("signature64").asText() + assertTrue(signature.isNotBlank()) + assertTrue(mcp.result("utility.verify_signature", mapOf( + "keyId" to TYPED_KEY, + "command" to mapOf("type" to "custom", "artifact" to + (allowed["command"] as Map<*, *>)["artifact"]!!), + "signature64" to signature + )).path("valid").asBoolean()) + + assertEquals("POLICY_VIOLATION", mcp.toolError("action.sign", request(1001)) + .path("error").asText()) + } + + @Test + @Order(8) + fun protocolRejectsMismatchedHeadersAndUnknownVersion() { + val unsupported = mcp.request("tools/list", protocolVersion = "2025-11-25") + assertEquals(400, unsupported.statusCode()) + assertEquals("2026-07-28", mcp.json.readTree(unsupported.body()) + .path("error").path("data").path("supported").first().asText()) + + val mismatch = mcp.request("tools/call", mapOf( + "name" to "identity.list", "arguments" to emptyMap() + ), nameHeader = "utility.keeper_status") + assertEquals(400, mismatch.statusCode()) + assertEquals(-32020, mcp.json.readTree(mismatch.body()).path("error").path("code").asInt()) + + val encodedName = Base64.getEncoder().encodeToString("identity.list".toByteArray()) + val encoded = mcp.request("tools/call", mapOf( + "name" to "identity.list", "arguments" to emptyMap() + ), nameHeader = "=?base64?$encodedName?=") + assertEquals(200, encoded.statusCode()) + assertFalse(mcp.json.readTree(encoded.body()).path("result").path("isError").asBoolean()) + + assertEquals(403, mcp.request("tools/list", origin = "https://example.invalid").statusCode()) + assertEquals("INVALID_ARGUMENT", mcp.toolError("utility.keeper_status", mapOf( + "unexpected" to true + )).path("error").asText()) + } + + @Test + @Order(9) + fun keyPermissionsFilterTheCatalogAndEveryDirectCall() { + val restricted = mcpClient("keeper-8") + assertFalse("utility.keeper_status" in toolNames(restricted)) + + val identities = restricted.result("identity.list", emptyMap()).path("items") + assertEquals(listOf(ALLOWED_KEY), identities.map { it.path("keyId").asText() }) + assertEquals("ACCESS_DENIED", restricted.toolError("identity.describe_authority", mapOf( + "keyId" to DENIED_KEY, "authorityId" to "ARBITRARY" + )).path("error").asText()) + assertEquals("ACCESS_DENIED", restricted.toolError("utility.get_public_key", mapOf( + "keyId" to DENIED_KEY + )).path("error").asText()) + assertEquals("ACCESS_DENIED", restricted.toolError("utility.verify_signature", mapOf( + "keyId" to DENIED_KEY + )).path("error").asText()) + assertEquals("ACCESS_DENIED", restricted.toolError("action.sign", mapOf( + "keyId" to DENIED_KEY + )).path("error").asText()) + assertEquals("ACCESS_DENIED", restricted.toolError("action.compose", mapOf( + "keyId" to DENIED_KEY + )).path("error").asText()) + } + + @Test + @Order(10) + fun statusRemainsAvailableWhenKeeperIsSealed() { + client(1).system().seal() + + val status = mcp.result("utility.keeper_status", emptyMap()) + assertEquals("SEALED", status.path("state").asText()) + assertFalse(status.path("ready").asBoolean()) + assertEquals(setOf("utility.keeper_status"), toolNames(mcp)) + assertEquals("ACCESS_DENIED", mcp.toolError("identity.list", emptyMap()).path("error").asText()) + } + + private fun generate(keyId: String, algorithm: String, authorityId: String) { + client(1).dkg().generate(Generate( + keyId, + algorithm, + authorities.authorities(authorityId), + KeyGenMode.CREATE + )) + } + + private fun describe(keyId: String, authorityId: String) = + mcp.result("identity.describe_authority", mapOf("keyId" to keyId, "authorityId" to authorityId)) + + private fun toolNames(client: McpTestClient): Set { + val response = client.request("tools/list") + assertEquals(200, response.statusCode()) + val result = client.json.readTree(response.body()).path("result") + assertEquals(0, result.path("ttlMs").asInt()) + assertEquals("private", result.path("cacheScope").asText()) + return result.path("tools") + .map { it.path("name").asText() }.toSet() + } + + companion object { + private const val REGISTRY_SERVICE = "registry" + private const val REGISTRY_PORT = 5000 + private const val TRON_KEY = "mcp-tron" + private const val XRP_KEY = "mcp-xrp" + private const val SOLANA_KEY = "mcp-solana" + private const val TYPED_KEY = "mcp-typed" + private const val EVM_KEY = "mcp-evm" + private const val X509_KEY = "mcp-x509" + private const val AP2_KEY = "mcp-ap2" + private const val MC_KEY = "mcp-mc-vi" + private const val ALLOWED_KEY = "mcp-allowed" + private const val DENIED_KEY = "mcp-denied" + + @Container + @JvmField + val dockerEnvironment = KeeperComposeFactory.newEnv(listOf( + "keeper-1", "keeper-2", "keeper-3", "keeper-4", "keeper-8" + )) + } +} diff --git a/integration-tests/performance/build.gradle b/integration-tests/performance/build.gradle index e14ab373..de798483 100644 --- a/integration-tests/performance/build.gradle +++ b/integration-tests/performance/build.gradle @@ -6,7 +6,7 @@ plugins { } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() diff --git a/integration-tests/src/testFixtures/java/org/exploit/test/authority/PaymentMandate.java b/integration-tests/src/testFixtures/java/org/exploit/test/authority/PaymentMandate.java new file mode 100644 index 00000000..ce4de5b6 --- /dev/null +++ b/integration-tests/src/testFixtures/java/org/exploit/test/authority/PaymentMandate.java @@ -0,0 +1,29 @@ +package org.exploit.test.authority; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Objects; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record PaymentMandate( + @JsonProperty("json") String json, + @JsonProperty("checkoutJson") String checkoutJson, + @JsonProperty("disclosureHash") String disclosureHash +) { + public PaymentMandate { + Objects.requireNonNull(json, "json must not be null"); + } + + public static PaymentMandate of(String json) { + return new PaymentMandate(json, null, null); + } + + public static PaymentMandate checkout(String json, String checkoutJson) { + return new PaymentMandate(json, Objects.requireNonNull(checkoutJson, "checkoutJson must not be null"), null); + } + + public static PaymentMandate openCheckout(String json, String disclosureHash) { + return new PaymentMandate(json, null, Objects.requireNonNull(disclosureHash, "disclosureHash must not be null")); + } +} diff --git a/integration-tests/src/testFixtures/kotlin/org/exploit/test/TKeeperComposeEnvironment.kt b/integration-tests/src/testFixtures/kotlin/org/exploit/test/TKeeperComposeEnvironment.kt index b6620f6d..94852cb6 100644 --- a/integration-tests/src/testFixtures/kotlin/org/exploit/test/TKeeperComposeEnvironment.kt +++ b/integration-tests/src/testFixtures/kotlin/org/exploit/test/TKeeperComposeEnvironment.kt @@ -1,6 +1,7 @@ package org.exploit.test import org.exploit.test.failure.FailureInjectionClient +import org.exploit.test.mcp.McpTestClient import org.exploit.tkeeper.sdk.TKeeperClient import org.exploit.tkeeper.sdk.auth.DevTokenAuth import org.exploit.tkeeper.sdk.model.KeeperInitData @@ -44,6 +45,9 @@ abstract class TKeeperComposeEnvironment { TKeeperClient(baseUrl(service), DevTokenAuth(devToken)) } + protected fun mcpClient(service: String = "keeper-1"): McpTestClient = + McpTestClient(baseUrl(service), devToken) + protected fun resetClient(service: String) { clients.remove(service)?.close() failureClients.remove(service) diff --git a/integration-tests/src/testFixtures/kotlin/org/exploit/test/authority/AuthorityFixtures.kt b/integration-tests/src/testFixtures/kotlin/org/exploit/test/authority/AuthorityFixtures.kt index b822f6f1..9674d39c 100644 --- a/integration-tests/src/testFixtures/kotlin/org/exploit/test/authority/AuthorityFixtures.kt +++ b/integration-tests/src/testFixtures/kotlin/org/exploit/test/authority/AuthorityFixtures.kt @@ -55,8 +55,14 @@ object AuthorityFixtures { const val EVM_SEPOLIA_DEMO_TOKEN = "test:evm/sepolia-demo-token" const val BITCOIN_BTC_SMALL_OUTPUT = "test:bitcoin/btc-small-output" + const val TRON_NATIVE_TRANSFER = "test:tron/native-transfer" + const val XRP_NATIVE_PAYMENT = "test:xrp/native-payment" + const val SOLANA_NATIVE_TRANSFER = "test:solana/native-transfer" const val X509_SERVICE_LEAF = "test:x509/service-leaf" + const val AP2_PURCHASES = "test:ap2/purchases" + const val MCINTENT_PURCHASES = "test:mcintent/purchases" + val paymentsSmall = fixture(CUSTOM_PAYMENTS_SMALL, "custom/payments-small.yaml") val paymentsCountry = fixture(CUSTOM_PAYMENTS_COUNTRY, "custom/payments-blocked-country.yaml") val treasuryLarge = fixture(CUSTOM_TREASURY_LARGE, "custom/treasury-large.yaml") @@ -72,6 +78,9 @@ object AuthorityFixtures { val evmSepoliaDemoToken = fixture(EVM_SEPOLIA_DEMO_TOKEN, "evm/sepolia-erc20-demo.yaml") val bitcoinBtcSmallOutput = fixture(BITCOIN_BTC_SMALL_OUTPUT, "bitcoin/btc-small-output.yaml") + val tronNativeTransfer = fixture(TRON_NATIVE_TRANSFER, "tron/native-transfer.yaml") + val xrpNativePayment = fixture(XRP_NATIVE_PAYMENT, "xrp/native-payment.yaml") + val solanaNativeTransfer = fixture(SOLANA_NATIVE_TRANSFER, "solana/native-transfer.yaml") val x509ServiceLeaf = fixture(X509_SERVICE_LEAF, "x509/service-leaf.yaml") val custom = listOf(paymentsSmall, paymentsCountry, treasuryLarge, payrollEur, adminOps) @@ -84,7 +93,12 @@ object AuthorityFixtures { evmMainnetVaultWithdraw, evmSepoliaDemoToken ) - val all = custom + evm + bitcoinBtcSmallOutput + x509ServiceLeaf + + val ap2Purchases = fixture(AP2_PURCHASES, "payments/ap2-purchases.yaml") + val mcPurchases = fixture(MCINTENT_PURCHASES, "payments/mcintent-purchases.yaml") + val agenticPayments = listOf(ap2Purchases, mcPurchases) + + val all = custom + evm + bitcoinBtcSmallOutput + x509ServiceLeaf + agenticPayments fun publish( publisher: OciAuthorityPublisher, diff --git a/integration-tests/src/testFixtures/kotlin/org/exploit/test/authority/DigitalAssetPayloads.kt b/integration-tests/src/testFixtures/kotlin/org/exploit/test/authority/DigitalAssetPayloads.kt new file mode 100644 index 00000000..9ba50e84 --- /dev/null +++ b/integration-tests/src/testFixtures/kotlin/org/exploit/test/authority/DigitalAssetPayloads.kt @@ -0,0 +1,69 @@ +package org.exploit.test.authority + +import org.exploit.ecc.ed25519.key.Ed25519PublicKey +import org.exploit.ecc.secp256k1.key.Secp256k1PublicKey +import org.exploit.ecc.spi.key.ECPublicKey +import org.exploit.signet.solana.codec.SolanaMessageCompiler +import org.exploit.signet.solana.model.transaction.SolanaUnsignedTransaction +import org.exploit.signet.solana.program.SystemProgram +import org.exploit.signet.solana.util.SolanaEncoding +import org.exploit.signet.tron.accounts.address.TronAddresses +import org.exploit.signet.tron.model.protocol.TronContract +import org.exploit.signet.tron.model.protocol.TronParameter +import org.exploit.signet.tron.model.protocol.TronRawData +import org.exploit.signet.tron.model.protocol.TronTransferValue +import org.exploit.signet.tron.model.transaction.TronUnsignedTransaction +import org.exploit.signet.xrp.accounts.address.XrpAddresses +import org.exploit.signet.xrp.model.transaction.XrpPayment +import org.exploit.signet.xrp.model.transaction.XrpUnsignedTransaction +import org.exploit.tkeeper.sdk.model.command.artifact.UnsignedSolanaTransaction +import org.exploit.tkeeper.sdk.model.command.artifact.UnsignedTronTransaction +import org.exploit.tkeeper.sdk.model.command.artifact.UnsignedXrpTransaction +import java.math.BigInteger +import java.util.Base64 + +object DigitalAssetPayloads { + private const val XRP_RECIPIENT = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + private val SOLANA_RECIPIENT = SolanaEncoding.base58(ByteArray(32) { 2 }) + private val SOLANA_BLOCKHASH = SolanaEncoding.base58(ByteArray(32) { 9 }) + private val TRON_RECIPIENT = TronAddresses.fromHex("41" + "00".repeat(20)) + + fun tronTransfer(publicKey64: String, amount: Long): UnsignedTronTransaction { + val key = Secp256k1PublicKey.fromBytes(Base64.getDecoder().decode(publicKey64)) + val owner = TronAddresses.toHex(TronAddresses.fromPublicKey(key)) + val contract = TronContract( + TronParameter.transfer(TronTransferValue(amount, owner, TronAddresses.toHex(TRON_RECIPIENT))), + TronContract.TRANSFER + ) + val raw = TronRawData(listOf(contract), "1234", "1234567890abcdef", 2_000, 30_000_000, 1_000) + val unsigned = TronUnsignedTransaction(false, raw, raw.hex(), raw.txid(), BigInteger.ZERO) + + return UnsignedTronTransaction(unsigned.dump()) + } + + fun xrpPayment(publicKey64: String, amount: Long): UnsignedXrpTransaction { + val encoded = Base64.getDecoder().decode(publicKey64) + val key: ECPublicKey<*> = when (encoded.size) { + 32 -> Ed25519PublicKey.fromEncoded(encoded) + else -> Secp256k1PublicKey.fromBytes(encoded) + } + val payment = XrpPayment( + XrpAddresses.fromPublicKey(key), XRP_RECIPIENT, BigInteger.valueOf(amount), + BigInteger.TEN, 1, 100L, 0L, null, XrpPayment.FULLY_CANONICAL_SIGNATURE, + XrpAddresses.publicKeyHex(key) + ) + + return UnsignedXrpTransaction(XrpUnsignedTransaction(payment).dump()) + } + + fun solanaTransfer(publicKey64: String, amount: Long): UnsignedSolanaTransaction { + val payer = SolanaEncoding.base58(Base64.getDecoder().decode(publicKey64)) + val message = SolanaMessageCompiler.compile( + payer, SOLANA_BLOCKHASH, + listOf(SystemProgram.transfer(payer, SOLANA_RECIPIENT, BigInteger.valueOf(amount))), + false, emptyList(), 0 + ) + + return UnsignedSolanaTransaction(SolanaUnsignedTransaction(message).dump()) + } +} diff --git a/integration-tests/src/testFixtures/kotlin/org/exploit/test/authority/PaymentPayloads.kt b/integration-tests/src/testFixtures/kotlin/org/exploit/test/authority/PaymentPayloads.kt new file mode 100644 index 00000000..58735a49 --- /dev/null +++ b/integration-tests/src/testFixtures/kotlin/org/exploit/test/authority/PaymentPayloads.kt @@ -0,0 +1,109 @@ +package org.exploit.test.authority + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.exploit.test.authority.PaymentMandate +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.interfaces.ECPublicKey +import java.security.Signature +import java.security.spec.ECGenParameterSpec +import java.util.Base64 + +object PaymentPayloads { + private val mapper = jacksonObjectMapper() + private val issuedAt = java.time.Instant.now().epochSecond + private val merchantKey = KeyPairGenerator.getInstance("EC").apply { initialize(ECGenParameterSpec("secp256r1")) }.generateKeyPair() + + data class SigningInput(val value: String, val disclosures: List) + + fun encoded(value: String): String = Base64.getUrlEncoder().withoutPadding().encodeToString(value.toByteArray()) + + fun hash(value: String): String = Base64.getUrlEncoder().withoutPadding() + .encodeToString(MessageDigest.getInstance("SHA-256").digest(value.toByteArray())) + + fun disclosure(json: String, salt: String = "0123456789abcdef"): String = encoded("[\"$salt\",$json]") + + fun signingInput(protocol: String, mandates: List): SigningInput { + val disclosures = mandates.mapIndexed { index, mandate -> + disclosure(mandate.json(), if (mandate.disclosureHash() != null) "0123456789abcdef" else "salt-0123456789abcdef-$index") + } + val hashes = disclosures.map(::hash) + val mc = protocol == "mcintent.mandate" + val open = mandates.any { it.json().contains("mandate.payment.open.1") } + val header = mapOf("alg" to "ES256", "typ" to if (!mc) "dc+sd-jwt" else if (open) "kb-sd-jwt+kb" else "kb-sd-jwt", "kid" to "agent-1") + val payload = linkedMapOf("iat" to issuedAt, "exp" to issuedAt + 900, "_sd_alg" to "sha-256", + "delegate_payload" to hashes.map { mapOf("..." to it) }) + if (mc) { + payload.putAll(mapOf("nonce" to "nonce-0123456789abcdef", "aud" to "https://network.example/authorize", "sd_hash" to hash("parent-presentation~"), "_sd" to hashes)) + } else { + payload.putAll(mapOf("iss" to "https://agent.example", "vct" to "com.example.agent_mandate")) + } + return SigningInput(encoded(mapper.writeValueAsString(header)) + "." + encoded(mapper.writeValueAsString(payload)), disclosures) + } + + private val pairs = mutableMapOf, List>() + + @Synchronized + fun pair(id: String = "order-1", amount: Long = 7500): List = pairs.getOrPut(id to amount) { createPair(id, amount) } + + private fun createPair(id: String, amount: Long): List { + val checkout = """ + {"id":"$id","merchant":{"id":"merchant-1","name":"Office shop","website":"https://office.example"}, + "status":"ready_for_complete","currency":"USD","links":[], + "line_items":[{"id":"line-1","item":{"id":"sku-1","title":"Paper","price":$amount},"quantity":1,"totals":[]}], + "totals":[{"type":"subtotal","amount":$amount},{"type":"total","amount":$amount}]} + """.trimIndent() + + val input = encoded("""{"alg":"ES256"}""") + "." + encoded(checkout) + val signature = Signature.getInstance("SHA256withECDSAinP1363Format").run { + initSign(merchantKey.private) + update(input.toByteArray()) + Base64.getUrlEncoder().withoutPadding().encodeToString(sign()) + } + val jwt = "$input.$signature" + val idHash = hash(jwt) + val mandate = """{"vct":"mandate.checkout.1","checkout_jwt":"$jwt","checkout_hash":"$idHash"}""" + + return listOf(PaymentMandate.checkout(mandate, checkout), payment(idHash, amount)) + } + + fun payment(id: String = "order-1", amount: Long = 7500): PaymentMandate { + val reference = if (id.length == 43) id else hash(id) + return PaymentMandate.of(""" + {"vct":"mandate.payment.1","transaction_id":"$reference", + "payee":{"id":"merchant-1","name":"Office shop","website":"https://office.example"}, + "payment_instrument":{"id":"card-1","type":"card"},"payment_amount":{"currency":"USD","amount":$amount}} + """.trimIndent()) + } + + fun merchantJwk(): Map { + val publicKey = merchantKey.public as ECPublicKey + fun coordinate(value: java.math.BigInteger): String { + val bytes = value.toByteArray().takeLast(32).toByteArray() + return Base64.getUrlEncoder().withoutPadding().encodeToString(ByteArray(32 - bytes.size) + bytes) + } + return mapOf("kty" to "EC", "crv" to "P-256", "kid" to "agent-1", + "x" to coordinate(publicKey.w.affineX), "y" to coordinate(publicKey.w.affineY)) + } + + fun delegation(protocol: String): List { + val jwk = mapper.writeValueAsString(merchantJwk()) + val prefix = if (protocol == "ap2.mandate") "" else "mandate." + val merchant = """{"id":"merchant-1","name":"Office shop","website":"https://office.example"}""" + val checkout = """ + {"vct":"mandate.checkout.open.1","cnf":{"jwk":$jwk},"constraints":[ + {"type":"${prefix}checkout.allowed_merchants","allowed":[$merchant]}, + {"type":"${prefix}checkout.line_items","items":[{"id":"line-1","quantity":1,"acceptable_items":[{"id":"sku-1","title":"Paper"}]}]}]} + """.trimIndent() + val checkoutHash = hash(disclosure(checkout)) + val payment = """ + {"vct":"mandate.payment.open.1","cnf":{"jwk":$jwk}, + "payment_instrument":{"id":"card-1","type":"card"},"constraints":[ + {"type":"${prefix}payment.reference","conditional_transaction_id":"$checkoutHash"}, + {"type":"${prefix}payment.allowed_payees","allowed":[$merchant]}, + {"type":"${prefix}payment.amount_range","currency":"USD","max":10000}]} + """.trimIndent() + + return listOf(PaymentMandate.openCheckout(checkout, checkoutHash), PaymentMandate.of(payment)) + } +} diff --git a/integration-tests/src/testFixtures/kotlin/org/exploit/test/mcp/McpTestClient.kt b/integration-tests/src/testFixtures/kotlin/org/exploit/test/mcp/McpTestClient.kt new file mode 100644 index 00000000..cea40de7 --- /dev/null +++ b/integration-tests/src/testFixtures/kotlin/org/exploit/test/mcp/McpTestClient.kt @@ -0,0 +1,66 @@ +package org.exploit.test.mcp + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class McpTestClient(private val baseUrl: String, private val token: String) { + val json = ObjectMapper() + private val http = HttpClient.newHttpClient() + + fun result(name: String, arguments: Map): JsonNode { + val response = request("tools/call", mapOf("name" to name, "arguments" to arguments)) + assertEquals(200, response.statusCode(), response.body()) + val result = json.readTree(response.body()).path("result") + assertTrue(!result.path("isError").asBoolean(), response.body()) + return result.path("structuredContent") + } + + fun toolError(name: String, arguments: Map): JsonNode { + val response = request("tools/call", mapOf("name" to name, "arguments" to arguments)) + assertEquals(200, response.statusCode(), response.body()) + val result = json.readTree(response.body()).path("result") + assertTrue(result.path("isError").asBoolean(), response.body()) + return result.path("structuredContent") + } + + fun request( + method: String, + params: Map = emptyMap(), + authenticated: Boolean = true, + protocolVersion: String = VERSION, + methodHeader: String = method, + nameHeader: String? = params["name"]?.toString(), + origin: String? = null + ): HttpResponse { + val body = mapOf( + "jsonrpc" to "2.0", + "id" to 1, + "method" to method, + "params" to params + mapOf("_meta" to mapOf( + "io.modelcontextprotocol/protocolVersion" to protocolVersion, + "io.modelcontextprotocol/clientCapabilities" to emptyMap() + )) + ) + val request = HttpRequest.newBuilder(URI.create("$baseUrl/mcp")) + .header("Content-Type", "application/json") + .header("MCP-Protocol-Version", protocolVersion) + .header("Mcp-Method", methodHeader) + .POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body))) + + if (authenticated) request.header("X-DEV-TOKEN", token) + if (nameHeader != null) request.header("Mcp-Name", nameHeader) + if (origin != null) request.header("Origin", origin) + + return http.send(request.build(), HttpResponse.BodyHandlers.ofString()) + } + + private companion object { + const val VERSION = "2026-07-28" + } +} diff --git a/integration-tests/src/testFixtures/resources/authorities/bitcoin/btc-small-output.yaml b/integration-tests/src/testFixtures/resources/authorities/bitcoin/btc-small-output.yaml index 67f06356..a30f0f9c 100644 --- a/integration-tests/src/testFixtures/resources/authorities/bitcoin/btc-small-output.yaml +++ b/integration-tests/src/testFixtures/resources/authorities/bitcoin/btc-small-output.yaml @@ -8,7 +8,7 @@ metadata: suite: integration intent: bitcoin config: - protocol: BTC + network: MAINNET policy: id: test-bitcoin-btc-small-output fallback: DENY diff --git a/integration-tests/src/testFixtures/resources/authorities/custom/payments-small.yaml b/integration-tests/src/testFixtures/resources/authorities/custom/payments-small.yaml index fcc3a1f4..bbb16b76 100644 --- a/integration-tests/src/testFixtures/resources/authorities/custom/payments-small.yaml +++ b/integration-tests/src/testFixtures/resources/authorities/custom/payments-small.yaml @@ -4,6 +4,7 @@ type: custom version: 1.0.0 metadata: title: Small USD payment signing + description: Sign USD payment requests of up to 1000 units. labels: suite: integration intent: custom diff --git a/integration-tests/src/testFixtures/resources/authorities/payments/ap2-purchases.yaml b/integration-tests/src/testFixtures/resources/authorities/payments/ap2-purchases.yaml new file mode 100644 index 00000000..cb7ea68a --- /dev/null +++ b/integration-tests/src/testFixtures/resources/authorities/payments/ap2-purchases.yaml @@ -0,0 +1,34 @@ +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')" diff --git a/integration-tests/src/testFixtures/resources/authorities/payments/mcintent-purchases.yaml b/integration-tests/src/testFixtures/resources/authorities/payments/mcintent-purchases.yaml new file mode 100644 index 00000000..59ddc6be --- /dev/null +++ b/integration-tests/src/testFixtures/resources/authorities/payments/mcintent-purchases.yaml @@ -0,0 +1,34 @@ +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')" diff --git a/integration-tests/src/testFixtures/resources/authorities/solana/native-transfer.yaml b/integration-tests/src/testFixtures/resources/authorities/solana/native-transfer.yaml new file mode 100644 index 00000000..a437f3e4 --- /dev/null +++ b/integration-tests/src/testFixtures/resources/authorities/solana/native-transfer.yaml @@ -0,0 +1,19 @@ +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')" diff --git a/integration-tests/src/testFixtures/resources/authorities/tron/native-transfer.yaml b/integration-tests/src/testFixtures/resources/authorities/tron/native-transfer.yaml new file mode 100644 index 00000000..5d94fc0b --- /dev/null +++ b/integration-tests/src/testFixtures/resources/authorities/tron/native-transfer.yaml @@ -0,0 +1,21 @@ +schemaVersion: verdict.authority/v1 +id: test:tron/native-transfer +type: tron.transaction +version: 1.0.0 +metadata: + description: Sign a TRON native transfer of up to 100 TRX to the configured recipient. +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')" diff --git a/integration-tests/src/testFixtures/resources/authorities/xrp/native-payment.yaml b/integration-tests/src/testFixtures/resources/authorities/xrp/native-payment.yaml new file mode 100644 index 00000000..2263c300 --- /dev/null +++ b/integration-tests/src/testFixtures/resources/authorities/xrp/native-payment.yaml @@ -0,0 +1,21 @@ +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')" diff --git a/openapi.yaml b/openapi.yaml index 10f8636f..81dbd619 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1,10 +1,12 @@ openapi: 3.0.3 info: title: TKeeper API - version: 2.4.1 + version: 2.5.0 description: | TKeeper public HTTP API. JSON request bodies reject unknown properties and are limited to 10 MiB by the HTTP server. + Digital asset, agentic payment, and PKI commands use the shared signing API. + Enabled features add their command types and, where available, composed results. servers: - url: / @@ -14,6 +16,11 @@ tags: - name: Keys - name: DKG - name: Signing + description: Shared signing and verification for AI, digital assets, PKI, and custom authorities. + - name: Composition + description: Signed digital asset transactions and agentic payment credentials; other types return a raw signature. + - name: MCP + description: AI agent tools over the optional MCP feature. - name: TrustedDealer - name: Quorum - name: DryRun @@ -338,6 +345,38 @@ paths: default: $ref: '#/components/responses/ErrorResponse' + /v2/keeper/compose: + post: + tags: [Composition] + operationId: compose + summary: Sign and compose an artifact + description: | + Uses the same request, permission, authority, policy, approval, and signing checks as `/v2/keeper/sign`. + EVM, TRON, XRP, and Solana commands return signed transactions. AP2 and MC Intent + commands return payment credentials. Types without a composer, including Bitcoin and X.509, + return `CalculatedSignature`. Composition does not broadcast or submit the result. + x-permissions: + template: tkeeper.key.{keyId}.sign + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Sign' + responses: + '200': + description: Composed artifact or raw signature fallback + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/SignedTransaction' + - $ref: '#/components/schemas/SignedSolanaTransaction' + - $ref: '#/components/schemas/PaymentCredential' + - $ref: '#/components/schemas/CalculatedSignature' + default: + $ref: '#/components/responses/ErrorResponse' + /v2/keeper/sign/verify: post: tags: [Signing] @@ -361,6 +400,70 @@ paths: default: $ref: '#/components/responses/ErrorResponse' + /mcp: + post: + tags: [MCP] + operationId: mcp + summary: Discover and call agent tools + description: | + Optional `mcp` feature. Supports `server/discover`, `tools/list`, and `tools/call` + over HTTP POST with JSON-RPC 2.0. The host authenticates with the same Keeper token + used by the public API. Tool discovery is permission filtered; calls check permission + again. Requests with an `Origin` header are rejected. + parameters: + - name: MCP-Protocol-Version + in: header + required: true + schema: + type: string + enum: ['2026-07-28'] + - name: Mcp-Method + in: header + required: true + description: Must match the JSON-RPC `method`. + schema: + type: string + enum: [server/discover, tools/list, tools/call] + - name: Mcp-Name + in: header + required: false + description: Required for `tools/call`; must match `params.name`. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/McpRequest' + responses: + '200': + description: JSON-RPC result, including tool errors and some protocol errors + content: + application/json: + schema: + $ref: '#/components/schemas/McpResponse' + '400': + description: Invalid JSON-RPC request, headers, or protocol version + content: + application/json: + schema: + $ref: '#/components/schemas/McpErrorResponse' + '403': + description: Browser origin rejected + content: + application/json: + schema: + $ref: '#/components/schemas/McpErrorResponse' + '404': + description: Unknown MCP method + content: + application/json: + schema: + $ref: '#/components/schemas/McpErrorResponse' + default: + $ref: '#/components/responses/ErrorResponse' + /v1/keeper/emulate: post: tags: [DryRun] @@ -1499,10 +1602,13 @@ components: $ref: '#/components/schemas/Artifact' Artifact: + description: The command type selects the artifact format and its authority checks. oneOf: - $ref: '#/components/schemas/ArbitraryArtifact' - $ref: '#/components/schemas/TypedArtifact' - $ref: '#/components/schemas/EvmTransactionArtifact' + - $ref: '#/components/schemas/TransactionArtifact' + - $ref: '#/components/schemas/PaymentMandatesArtifact' - $ref: '#/components/schemas/BitcoinTransactionArtifact' - $ref: '#/components/schemas/X509TbsCertificateArtifact' @@ -1550,6 +1656,39 @@ components: message64: $ref: '#/components/schemas/NonEmptyBase64' + TransactionArtifact: + type: object + additionalProperties: false + description: | + For `tron.transaction`, `transaction` is unsigned transaction JSON. + For `xrp.transaction`, it is unsigned transaction hex. + For `solana.transaction`, it is an unsigned transaction encoded as Base64. + required: [transaction] + properties: + transaction: + type: string + minLength: 1 + + PaymentMandatesArtifact: + type: object + additionalProperties: false + description: Artifact for `ap2.mandate` and `mcintent.mandate`. + required: [mode, signingInput, disclosures] + properties: + mode: + type: string + enum: [PAIRED, PAYMENTS, CHECKOUTS] + signingInput: + type: string + minLength: 1 + description: Unsigned JWS protected header and payload. + disclosures: + type: array + minItems: 1 + items: + type: string + minLength: 1 + BitcoinTransactionArtifact: type: object additionalProperties: false @@ -1589,6 +1728,7 @@ components: CalculatedSignature: type: object + additionalProperties: false required: [type, generation, imposters] properties: type: @@ -1606,6 +1746,191 @@ components: items: type: string + SignedTransaction: + type: object + additionalProperties: false + description: EVM, TRON, or XRP composed transaction. + required: [type, rawTransaction, transactionHash, generation, imposters] + properties: + type: + type: string + enum: [evm.transaction, tron.transaction, xrp.transaction] + rawTransaction: + type: string + description: Signed hex for EVM and XRP; signed JSON for TRON. + transactionHash: + type: string + generation: + $ref: '#/components/schemas/Generation' + imposters: + type: array + items: + type: string + + SignedSolanaTransaction: + type: object + additionalProperties: false + required: [type, rawTransaction, transactionHash, complete, generation, imposters] + properties: + type: + type: string + enum: [solana.transaction] + rawTransaction: + type: string + description: Signed or partially signed transaction encoded as Base64. + transactionHash: + type: string + nullable: true + description: Available when the transaction is complete. + complete: + type: boolean + generation: + $ref: '#/components/schemas/Generation' + imposters: + type: array + items: + type: string + + PaymentCredential: + type: object + additionalProperties: false + required: [type, credential, generation, imposters] + properties: + type: + type: string + enum: [ap2.mandate, mcintent.mandate] + credential: + type: string + description: Signed JWS with the referenced SD-JWT disclosures. + generation: + $ref: '#/components/schemas/Generation' + imposters: + type: array + items: + type: string + + McpRequest: + type: object + required: [jsonrpc, id, method, params] + properties: + jsonrpc: + type: string + enum: ['2.0'] + id: + oneOf: + - type: string + - type: integer + description: JSON-RPC request id; notifications are not supported. + method: + type: string + enum: [server/discover, tools/list, tools/call] + params: + type: object + required: [_meta] + description: '`tools/call` also requires `name` and an object `arguments`.' + properties: + name: + type: string + arguments: + type: object + _meta: + type: object + required: [io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientCapabilities] + properties: + io.modelcontextprotocol/protocolVersion: + type: string + enum: ['2026-07-28'] + io.modelcontextprotocol/clientCapabilities: + type: object + example: + jsonrpc: '2.0' + id: 1 + method: tools/list + params: + _meta: + io.modelcontextprotocol/protocolVersion: '2026-07-28' + io.modelcontextprotocol/clientCapabilities: {} + + McpResponse: + oneOf: + - $ref: '#/components/schemas/McpSuccessResponse' + - $ref: '#/components/schemas/McpErrorResponse' + + McpSuccessResponse: + type: object + required: [jsonrpc, id, result] + properties: + jsonrpc: + type: string + enum: ['2.0'] + id: + description: Echoes the string or integer request id. + result: + type: object + required: [resultType, _meta] + description: | + Discovery returns `supportedVersions`, `capabilities`, `ttlMs`, and `cacheScope`. + Tool listing returns `tools`, `ttlMs`, and `cacheScope`. Tool calls return `content`, + `structuredContent`, and `isError`; tool failures set `isError` to `true`. + properties: + resultType: + type: string + enum: [complete] + _meta: + type: object + description: Includes `io.modelcontextprotocol/serverInfo`. + supportedVersions: + type: array + items: + type: string + capabilities: + type: object + tools: + type: array + items: + type: object + ttlMs: + type: integer + enum: [0] + cacheScope: + type: string + enum: [private] + content: + type: array + items: + type: object + required: [type, text] + properties: + type: + type: string + enum: [text] + text: + type: string + structuredContent: + description: Tool-specific JSON result or error object. + isError: + type: boolean + + McpErrorResponse: + type: object + required: [jsonrpc, id, error] + properties: + jsonrpc: + type: string + enum: ['2.0'] + id: + description: Request id, or null when the request could not be parsed. + error: + type: object + required: [code, message] + properties: + code: + type: integer + message: + type: string + data: + type: object + VerifyResult: type: object required: [valid] diff --git a/platform-ecc/build.gradle b/platform-ecc/build.gradle index fef723ab..0e4db8b5 100644 --- a/platform-ecc/build.gradle +++ b/platform-ecc/build.gradle @@ -2,11 +2,11 @@ plugins { id 'java-library' id 'org.jetbrains.kotlin.jvm' 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' } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() diff --git a/platform-pqc/build.gradle b/platform-pqc/build.gradle index 20326351..5764206a 100644 --- a/platform-pqc/build.gradle +++ b/platform-pqc/build.gradle @@ -2,11 +2,11 @@ plugins { id 'java-library' id 'org.jetbrains.kotlin.jvm' 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' } group = 'org.exploit' -version = '2.4.1' +version = '2.5.0' repositories { mavenCentral() @@ -26,7 +26,7 @@ dependencies { testImplementation 'com.code-intelligence:jazzer-junit:0.30.0' testImplementation(project(':')) implementation ('org.exploit.anvil:t-mldsa:0.2.2') - implementation 'org.bouncycastle:bcprov-jdk18on:1.84' + implementation 'org.bouncycastle:bcprov-jdk18on:1.85.2' compileOnly(project(':')) ksp "io.micronaut:micronaut-inject-kotlin:4.10.8" diff --git a/sdk/README.md b/sdk/README.md index d644ddad..6d90fc29 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -10,14 +10,14 @@ Maven: org.exploit tkeeper-sdk - 2.4.1 + 2.5.0 ``` Gradle: ```groovy -implementation 'org.exploit:tkeeper-sdk:2.4.1' +implementation 'org.exploit:tkeeper-sdk:2.5.0' ``` ## Client diff --git a/sdk/build.gradle b/sdk/build.gradle index fe20c778..fb37d0e8 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -7,7 +7,7 @@ plugins { } group = 'org.exploit' -version = findProperty('releaseVersion') ?: '2.4.1' +version = findProperty('releaseVersion') ?: '2.5.0' def projectName = "TKeeper SDK" def projectDescription = "API client for TKeeper in Java" @@ -30,7 +30,7 @@ dependencies { api 'org.exploit:jettyx:0.1.7' api 'org.exploit:jettyx-jackson:0.1.7' api 'org.exploit:jettyx-http2:0.1.7' - api 'com.fasterxml.jackson.core:jackson-databind:2.22.0' + api 'com.fasterxml.jackson.core:jackson-databind:2.22.3' } java { diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/PaymentCredential.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/PaymentCredential.java new file mode 100644 index 00000000..22b0f6cf --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/PaymentCredential.java @@ -0,0 +1,13 @@ +package org.exploit.tkeeper.sdk.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record PaymentCredential( + @JsonProperty("type") String type, + @JsonProperty("credential") String credential, + @JsonProperty("generation") int generation, + @JsonProperty("imposters") List imposters +) { +} diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/SignedEvmTransaction.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/SignedEvmTransaction.java new file mode 100644 index 00000000..22021aef --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/SignedEvmTransaction.java @@ -0,0 +1,14 @@ +package org.exploit.tkeeper.sdk.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record SignedEvmTransaction( + @JsonProperty("type") String type, + @JsonProperty("rawTransaction") String rawTransaction, + @JsonProperty("transactionHash") String transactionHash, + @JsonProperty("generation") int generation, + @JsonProperty("imposters") List imposters +) { +} diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/SignedSolanaTransaction.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/SignedSolanaTransaction.java new file mode 100644 index 00000000..bb2381c8 --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/SignedSolanaTransaction.java @@ -0,0 +1,15 @@ +package org.exploit.tkeeper.sdk.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record SignedSolanaTransaction( + @JsonProperty("type") String type, + @JsonProperty("rawTransaction") String rawTransaction, + @JsonProperty("transactionHash") String transactionHash, + @JsonProperty("complete") boolean complete, + @JsonProperty("generation") int generation, + @JsonProperty("imposters") List imposters +) { +} diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/SignedTronTransaction.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/SignedTronTransaction.java new file mode 100644 index 00000000..562fd8a4 --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/SignedTronTransaction.java @@ -0,0 +1,14 @@ +package org.exploit.tkeeper.sdk.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record SignedTronTransaction( + @JsonProperty("type") String type, + @JsonProperty("rawTransaction") String rawTransaction, + @JsonProperty("transactionHash") String transactionHash, + @JsonProperty("generation") int generation, + @JsonProperty("imposters") List imposters +) { +} diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/SignedXrpTransaction.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/SignedXrpTransaction.java new file mode 100644 index 00000000..2b1e4c2d --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/SignedXrpTransaction.java @@ -0,0 +1,14 @@ +package org.exploit.tkeeper.sdk.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record SignedXrpTransaction( + @JsonProperty("type") String type, + @JsonProperty("rawTransaction") String rawTransaction, + @JsonProperty("transactionHash") String transactionHash, + @JsonProperty("generation") int generation, + @JsonProperty("imposters") List imposters +) { +} diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/Ap2Mandates.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/Ap2Mandates.java new file mode 100644 index 00000000..7dbac52f --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/Ap2Mandates.java @@ -0,0 +1,23 @@ +package org.exploit.tkeeper.sdk.model.command.artifact; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Objects; + +public record Ap2Mandates( + @JsonProperty("mode") PaymentRequestMode mode, + @JsonProperty("signingInput") String signingInput, + @JsonProperty("disclosures") List disclosures +) implements Artifact { + public Ap2Mandates { + Objects.requireNonNull(signingInput, "signingInput must not be null"); + Objects.requireNonNull(mode, "mode must not be null"); + disclosures = List.copyOf(disclosures); + } + + @Override + public String type() { + return IntentTypes.AP2_MANDATE; + } +} diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/IntentTypes.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/IntentTypes.java index c73fcc55..b02a34ce 100644 --- a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/IntentTypes.java +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/IntentTypes.java @@ -5,6 +5,12 @@ public final class IntentTypes { public static final String X509_TBS_CERTIFICATE = "x509.tbs-certificate"; public static final String EVM_TRANSACTION = "evm.transaction"; public static final String BITCOIN_TRANSACTION = "bitcoin.transaction"; + public static final String TRON_TRANSACTION = "tron.transaction"; + public static final String XRP_TRANSACTION = "xrp.transaction"; + public static final String SOLANA_TRANSACTION = "solana.transaction"; + + public static final String AP2_MANDATE = "ap2.mandate"; + public static final String MCINTENT_MANDATE = "mcintent.mandate"; private IntentTypes() { } diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/McMandates.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/McMandates.java new file mode 100644 index 00000000..a79839be --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/McMandates.java @@ -0,0 +1,23 @@ +package org.exploit.tkeeper.sdk.model.command.artifact; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Objects; + +public record McMandates( + @JsonProperty("mode") PaymentRequestMode mode, + @JsonProperty("signingInput") String signingInput, + @JsonProperty("disclosures") List disclosures +) implements Artifact { + public McMandates { + Objects.requireNonNull(signingInput, "signingInput must not be null"); + Objects.requireNonNull(mode, "mode must not be null"); + disclosures = List.copyOf(disclosures); + } + + @Override + public String type() { + return IntentTypes.MCINTENT_MANDATE; + } +} diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/PaymentRequestMode.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/PaymentRequestMode.java new file mode 100644 index 00000000..119db60a --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/PaymentRequestMode.java @@ -0,0 +1,7 @@ +package org.exploit.tkeeper.sdk.model.command.artifact; + +public enum PaymentRequestMode { + PAIRED, + PAYMENTS, + CHECKOUTS +} diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/UnsignedSolanaTransaction.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/UnsignedSolanaTransaction.java new file mode 100644 index 00000000..fcb651bc --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/UnsignedSolanaTransaction.java @@ -0,0 +1,10 @@ +package org.exploit.tkeeper.sdk.model.command.artifact; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record UnsignedSolanaTransaction(@JsonProperty("transaction") String transaction) implements Artifact { + @Override + public String type() { + return IntentTypes.SOLANA_TRANSACTION; + } +} diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/UnsignedTronTransaction.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/UnsignedTronTransaction.java new file mode 100644 index 00000000..da56814d --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/UnsignedTronTransaction.java @@ -0,0 +1,10 @@ +package org.exploit.tkeeper.sdk.model.command.artifact; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record UnsignedTronTransaction(@JsonProperty("transaction") String transaction) implements Artifact { + @Override + public String type() { + return IntentTypes.TRON_TRANSACTION; + } +} diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/UnsignedXrpTransaction.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/UnsignedXrpTransaction.java new file mode 100644 index 00000000..14cc1c93 --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/command/artifact/UnsignedXrpTransaction.java @@ -0,0 +1,10 @@ +package org.exploit.tkeeper.sdk.model.command.artifact; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record UnsignedXrpTransaction(@JsonProperty("transaction") String transaction) implements Artifact { + @Override + public String type() { + return IntentTypes.XRP_TRANSACTION; + } +} diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/modules/SignatureModule.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/modules/SignatureModule.java index 3d105c92..38fd3f18 100644 --- a/sdk/src/main/java/org/exploit/tkeeper/sdk/modules/SignatureModule.java +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/modules/SignatureModule.java @@ -1,5 +1,7 @@ package org.exploit.tkeeper.sdk.modules; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.eclipse.jetty.http.HttpMethod; import org.exploit.jettyx.Jettyx; import org.exploit.jettyx.annotation.Body; @@ -16,6 +18,8 @@ import static org.exploit.tkeeper.sdk.util.KeeperUtils.handleError; public final class SignatureModule { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final SignatureApi api; private interface SignatureApi { @@ -24,6 +28,11 @@ private interface SignatureApi { @ContentType("application/json") HttpResponse sign(@Body Sign request); + @Description("Sign an authority command and compose its protocol artifact") + @HttpRequest(method = HttpMethod.POST, path = "/v2/keeper/compose") + @ContentType("application/json") + HttpResponse compose(@Body Sign request); + @Description("Verify threshold signature") @HttpRequest(method = HttpMethod.POST, path = "/v2/keeper/sign/verify") @ContentType("application/json") @@ -45,4 +54,20 @@ public VerifyResult verify(Verify request) { handleError(resp); return resp.body(); } -} \ No newline at end of file + + public JsonNode compose(Sign request) { + var response = api.compose(request); + handleError(response); + if (response.exception() != null) { + throw new IllegalStateException("Failed to deserialize compose response", response.exception()); + } + if (response.body() == null) { + throw new IllegalStateException("Compose response body is missing"); + } + return response.body(); + } + + public T compose(Sign request, Class responseType) { + return MAPPER.convertValue(compose(request), responseType); + } +} diff --git a/settings.gradle b/settings.gradle index b631c6c5..509d6d3b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,8 +10,12 @@ include 'integration-tests:functional' include 'integration-tests:performance' include 'features:seal-aws' include 'features:seal-gcloud' -include 'features:authority-bitcoin' -include 'features:authority-evm' +include 'features:digital-assets' +include 'features:digital-assets:bitcoin' +include 'features:digital-assets:evm' +include 'features:digital-assets:tron' +include 'features:digital-assets:xrp' +include 'features:digital-assets:solana' include 'features:authority-x509' include 'features:ecies' include 'features:ui' @@ -27,3 +31,8 @@ include 'features:recovery:ecc' include 'features:recovery:pqc' include 'features:dry-run' +include 'features:mcp' + +include 'features:agentic-payments' +include 'features:agentic-payments:ap2' +include 'features:agentic-payments:mc-vi' diff --git a/src/main/kotlin/org/exploit/keeper/factory/PolicyEngineFactory.kt b/src/main/kotlin/org/exploit/keeper/factory/PolicyEngineFactory.kt index 915c7a6a..2f5cee6f 100644 --- a/src/main/kotlin/org/exploit/keeper/factory/PolicyEngineFactory.kt +++ b/src/main/kotlin/org/exploit/keeper/factory/PolicyEngineFactory.kt @@ -2,14 +2,19 @@ package org.exploit.keeper.factory import io.micronaut.context.annotation.Factory import jakarta.inject.Singleton +import org.exploit.keeper.service.policy.PolicyEvaluatorCustomizer import org.exploit.verdict.PolicyEvaluator @Factory -class PolicyEngineFactory { +class PolicyEngineFactory(private val customizers: List) { @Singleton fun policyEvaluator(): PolicyEvaluator { - return PolicyEvaluator.builder() - .standardLibraries(true) - .build() + val builder = PolicyEvaluator.builder() + + for (customizer in customizers) { + customizer.customize(builder) + } + + return builder.build() } } \ No newline at end of file diff --git a/src/main/kotlin/org/exploit/keeper/service/command/ArbitraryCommandMaterializer.kt b/src/main/kotlin/org/exploit/keeper/service/command/ArbitraryCommandMaterializer.kt index 003bb9de..38f2d787 100644 --- a/src/main/kotlin/org/exploit/keeper/service/command/ArbitraryCommandMaterializer.kt +++ b/src/main/kotlin/org/exploit/keeper/service/command/ArbitraryCommandMaterializer.kt @@ -16,7 +16,7 @@ class ArbitraryCommandMaterializer: CommandMaterializer { return OtherKeySetAuthorityId.ARBITRARY } - override fun toSigningMaterial(keyId: String, generation: Int, c: Command): SigningMaterial { + override fun toSigningMaterial(keyId: String, generation: Int, c: Command, tweak: String?): SigningMaterial { if (c.artifact !is ArbitraryData) throw TKeeperException(ErrorType.INVALID_AUTHORITY_ARTIFACT, "Not arbitrary data") @@ -25,4 +25,4 @@ class ArbitraryCommandMaterializer: CommandMaterializer { return DefaultSigningMaterial(a.scheme, bytes) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/org/exploit/keeper/service/command/CommandMaterializer.kt b/src/main/kotlin/org/exploit/keeper/service/command/CommandMaterializer.kt index 2b34a690..43f9458e 100644 --- a/src/main/kotlin/org/exploit/keeper/service/command/CommandMaterializer.kt +++ b/src/main/kotlin/org/exploit/keeper/service/command/CommandMaterializer.kt @@ -6,5 +6,5 @@ import org.exploit.keeper.model.material.SigningMaterial interface CommandMaterializer { fun type(): String - fun toSigningMaterial(keyId: String, generation: Int, c: Command): SigningMaterial -} \ No newline at end of file + fun toSigningMaterial(keyId: String, generation: Int, c: Command, tweak: String?): SigningMaterial +} diff --git a/src/main/kotlin/org/exploit/keeper/service/command/CommonCommandMaterializer.kt b/src/main/kotlin/org/exploit/keeper/service/command/CommonCommandMaterializer.kt index c5e742cb..04a5a924 100644 --- a/src/main/kotlin/org/exploit/keeper/service/command/CommonCommandMaterializer.kt +++ b/src/main/kotlin/org/exploit/keeper/service/command/CommonCommandMaterializer.kt @@ -8,7 +8,7 @@ import org.exploit.keeper.model.material.SigningMaterial @Singleton class CommonCommandMaterializer(private val materializers: List) { - fun materialize(keyId: String, generation: Int, command: Command): SigningMaterial { + fun materialize(keyId: String, generation: Int, command: Command, tweak: String?): SigningMaterial { if (command.artifact.type() != command.type) throw TKeeperException( ErrorType.INVALID_AUTHORITY_ARTIFACT, @@ -16,7 +16,7 @@ class CommonCommandMaterializer(private val materializers: List): Map = ArtifactSchemas.objectFields( + mapOf( + "scheme" to mapOf("type" to "string"), + "hash" to mapOf("type" to "string", "enum" to HashMethod.entries.map { it.name }), + "data64" to mapOf("type" to "string", "contentEncoding" to "base64") + ), + listOf("scheme", "data64") + ) + override fun deserialize(node: JsonNode, ctx: DeserializationContext): Artifact { node.requireOnly(ctx, "scheme", "hash", "data64") diff --git a/src/main/kotlin/org/exploit/keeper/service/command/deserializer/ArtifactDeserializer.kt b/src/main/kotlin/org/exploit/keeper/service/command/deserializer/ArtifactDeserializer.kt index 1939a598..963cbc81 100644 --- a/src/main/kotlin/org/exploit/keeper/service/command/deserializer/ArtifactDeserializer.kt +++ b/src/main/kotlin/org/exploit/keeper/service/command/deserializer/ArtifactDeserializer.kt @@ -8,4 +8,6 @@ interface ArtifactDeserializer { fun type(): String fun deserialize(node: JsonNode, ctx: DeserializationContext): Artifact -} \ No newline at end of file + + fun schema(config: Map): Map +} diff --git a/src/main/kotlin/org/exploit/keeper/service/command/deserializer/ArtifactSchemas.kt b/src/main/kotlin/org/exploit/keeper/service/command/deserializer/ArtifactSchemas.kt new file mode 100644 index 00000000..a434dc8f --- /dev/null +++ b/src/main/kotlin/org/exploit/keeper/service/command/deserializer/ArtifactSchemas.kt @@ -0,0 +1,13 @@ +package org.exploit.keeper.service.command.deserializer + +object ArtifactSchemas { + fun stringField(name: String, description: String): Map = + objectFields(mapOf(name to mapOf("type" to "string", "description" to description)), listOf(name)) + + fun objectFields(properties: Map, required: List): Map = mapOf( + "type" to "object", + "properties" to properties, + "required" to required, + "additionalProperties" to false + ) +} diff --git a/src/main/kotlin/org/exploit/keeper/service/command/deserializer/TypedDataDeserializer.kt b/src/main/kotlin/org/exploit/keeper/service/command/deserializer/TypedDataDeserializer.kt index 453bcfd6..ed3e68f2 100644 --- a/src/main/kotlin/org/exploit/keeper/service/command/deserializer/TypedDataDeserializer.kt +++ b/src/main/kotlin/org/exploit/keeper/service/command/deserializer/TypedDataDeserializer.kt @@ -13,6 +13,9 @@ 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.IntentTypes +import org.exploit.verdict.intent.typed.config.TypedIntentConfig +import org.exploit.verdict.intent.typed.constant.TypedIntentFieldType +import org.exploit.verdict.intent.typed.model.TypedIntentField @Singleton class TypedDataDeserializer: ArtifactDeserializer { @@ -20,6 +23,37 @@ class TypedDataDeserializer: ArtifactDeserializer { return IntentTypes.CUSTOM } + override fun schema(config: Map): Map = ArtifactSchemas.objectFields( + mapOf( + "scheme" to mapOf("type" to "string"), + "hash" to mapOf("type" to "string", "enum" to HashMethod.entries.map { it.name }), + "typed" to fieldsSchema(TypedIntentConfig.from(config).fields()) + ), + listOf("scheme", "typed") + ) + + private fun fieldsSchema(fields: Map): Map = + ArtifactSchemas.objectFields( + fields.mapValues { (_, field) -> fieldSchema(field) }, + fields.filterValues { it.required() && it.defaultValue().isEmpty }.keys.toList() + ) + + private fun fieldSchema(field: TypedIntentField): Map { + val schema = when (field.type()) { + TypedIntentFieldType.STRING -> mapOf("type" to "string") + TypedIntentFieldType.BOOL -> mapOf("type" to "boolean") + TypedIntentFieldType.INT -> mapOf("type" to "integer", "minimum" to Int.MIN_VALUE, "maximum" to Int.MAX_VALUE) + TypedIntentFieldType.BIGINT -> mapOf("type" to "integer") + TypedIntentFieldType.DECIMAL -> mapOf("type" to "number") + TypedIntentFieldType.TIME -> mapOf("type" to "string", "format" to "date-time") + TypedIntentFieldType.BYTES -> mapOf("type" to "string", "contentEncoding" to "base64") + TypedIntentFieldType.OBJECT -> fieldsSchema(field.fields()) + TypedIntentFieldType.LIST -> mapOf("type" to "array", "items" to fieldSchema(field.items().orElseThrow())) + } + + return if (field.nullable()) schema + ("type" to listOf(schema["type"], "null")) else schema + } + override fun deserialize(node: JsonNode, ctx: DeserializationContext): Artifact { node.requireOnly(ctx, "scheme", "hash", "typed") diff --git a/src/main/kotlin/org/exploit/keeper/service/command/intent/TypedIntentBuilder.kt b/src/main/kotlin/org/exploit/keeper/service/command/intent/TypedIntentBuilder.kt index da38e619..61334fde 100644 --- a/src/main/kotlin/org/exploit/keeper/service/command/intent/TypedIntentBuilder.kt +++ b/src/main/kotlin/org/exploit/keeper/service/command/intent/TypedIntentBuilder.kt @@ -16,9 +16,9 @@ import org.exploit.verdict.authority.model.CompiledAuthority import org.exploit.verdict.intent.Intent import org.exploit.verdict.intent.IntentTypes import org.exploit.verdict.intent.typed.TypedIntent -import org.exploit.verdict.intent.typed.TypedIntentConfig -import org.exploit.verdict.intent.typed.TypedIntentField -import org.exploit.verdict.intent.typed.TypedIntentFieldType +import org.exploit.verdict.intent.typed.config.TypedIntentConfig +import org.exploit.verdict.intent.typed.constant.TypedIntentFieldType +import org.exploit.verdict.intent.typed.model.TypedIntentField import java.time.Instant import java.time.format.DateTimeParseException import java.util.* diff --git a/src/main/kotlin/org/exploit/keeper/service/compose/KeeperComposer.kt b/src/main/kotlin/org/exploit/keeper/service/compose/KeeperComposer.kt index 55b102fc..c8e6ee57 100644 --- a/src/main/kotlin/org/exploit/keeper/service/compose/KeeperComposer.kt +++ b/src/main/kotlin/org/exploit/keeper/service/compose/KeeperComposer.kt @@ -12,12 +12,10 @@ class KeeperComposer(private val manager: SigningSessionManager, private val com fun compose(body: Sign): Outcome { val signature = manager.coordinate(body) - val command = body.command - val composer = composers.find { it.type.equalsIgnoreCase(body.command.type) } ?: return signature - return composer.compose(command, signature) + return composer.compose(body, signature) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/org/exploit/keeper/service/compose/spi/Composer.kt b/src/main/kotlin/org/exploit/keeper/service/compose/spi/Composer.kt index 85ab7927..638a9aac 100644 --- a/src/main/kotlin/org/exploit/keeper/service/compose/spi/Composer.kt +++ b/src/main/kotlin/org/exploit/keeper/service/compose/spi/Composer.kt @@ -1,11 +1,11 @@ package org.exploit.keeper.service.compose.spi import org.exploit.keeper.model.api.outgoing.CalculatedSignature -import org.exploit.keeper.model.command.Command +import org.exploit.keeper.model.api.incoming.Sign import org.exploit.keeper.model.outcome.Outcome interface Composer { val type: String - fun compose(command: Command, signature: CalculatedSignature): Outcome -} \ No newline at end of file + fun compose(request: Sign, signature: CalculatedSignature): Outcome +} diff --git a/src/main/kotlin/org/exploit/keeper/service/policy/AddStandardLibraries.kt b/src/main/kotlin/org/exploit/keeper/service/policy/AddStandardLibraries.kt new file mode 100644 index 00000000..b3610260 --- /dev/null +++ b/src/main/kotlin/org/exploit/keeper/service/policy/AddStandardLibraries.kt @@ -0,0 +1,11 @@ +package org.exploit.keeper.service.policy + +import jakarta.inject.Singleton +import org.exploit.verdict.PolicyEvaluatorBuilder + +@Singleton +class AddStandardLibraries: PolicyEvaluatorCustomizer { + override fun customize(builder: PolicyEvaluatorBuilder) { + builder.standardLibraries(true) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/exploit/keeper/service/policy/PolicyEvaluatorCustomizer.kt b/src/main/kotlin/org/exploit/keeper/service/policy/PolicyEvaluatorCustomizer.kt new file mode 100644 index 00000000..24beef43 --- /dev/null +++ b/src/main/kotlin/org/exploit/keeper/service/policy/PolicyEvaluatorCustomizer.kt @@ -0,0 +1,7 @@ +package org.exploit.keeper.service.policy + +import org.exploit.verdict.PolicyEvaluatorBuilder + +interface PolicyEvaluatorCustomizer { + fun customize(builder: PolicyEvaluatorBuilder) +} \ No newline at end of file diff --git a/src/main/kotlin/org/exploit/keeper/service/signature/registry/SigningSessionManager.kt b/src/main/kotlin/org/exploit/keeper/service/signature/registry/SigningSessionManager.kt index b3fb6c31..09ddb81a 100644 --- a/src/main/kotlin/org/exploit/keeper/service/signature/registry/SigningSessionManager.kt +++ b/src/main/kotlin/org/exploit/keeper/service/signature/registry/SigningSessionManager.kt @@ -31,7 +31,7 @@ class SigningSessionManager( if (generation < 1) throw TKeeperException(ErrorType.KEY_NOT_FOUND) - val resolved = prepareSession(initData.quorumMode(), request.keyId, generation, request.command) + val resolved = prepareSession(initData.quorumMode(), request.keyId, generation, request.command, request.tweak) return resolved.manager.coordinate(request, resolved.generation,resolved.materialized) } @@ -39,7 +39,7 @@ class SigningSessionManager( if (initData.quorumMode() != QuorumMode.THRESHOLD) throw TKeeperException(ErrorType.NOT_THRESHOLD) - val resolved = prepareSession(QuorumMode.THRESHOLD, init.keyId, init.generation, init.command) + val resolved = prepareSession(QuorumMode.THRESHOLD, init.keyId, init.generation, init.command, init.tweak) val manager = resolved.manager as ThresholdSessionManager manager.participate(init, resolved.materialized) @@ -55,12 +55,12 @@ class SigningSessionManager( return instances.find { it.quorum() == mode && it.method().equalsIgnoreCase(method) && it.algorithm().equalsIgnoreCase(algorithm) } } - private fun prepareSession(mode: QuorumMode, keyId: String, generation: Int, command: AuthorityCommand): ResolvedSession { + private fun prepareSession(mode: QuorumMode, keyId: String, generation: Int, command: AuthorityCommand, tweak: String?): ResolvedSession { val algId = db.vault.extractAlgorithm(keyId, generation) ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, keyId) val algorithm = algorithms.findOrThrow(algId) - val materialized = materializer.materialize(keyId, generation, command) + val materialized = materializer.materialize(keyId, generation, command, tweak) val manager = findManager(mode, materialized.scheme, algorithm.name()) ?: throw TKeeperException(ErrorType.SESSION_MANAGER_NOT_FOUND, "No manager for ${materialized.scheme}-${algorithm.name()}") diff --git a/src/main/kotlin/org/exploit/keeper/service/signature/verify/SignatureVerifyManager.kt b/src/main/kotlin/org/exploit/keeper/service/signature/verify/SignatureVerifyManager.kt index fcf32f43..c6eddae6 100644 --- a/src/main/kotlin/org/exploit/keeper/service/signature/verify/SignatureVerifyManager.kt +++ b/src/main/kotlin/org/exploit/keeper/service/signature/verify/SignatureVerifyManager.kt @@ -35,7 +35,7 @@ class SignatureVerifyManager( val algorithm = algorithms.findOrThrow(algId) - val materialized = materializer.materialize(verify.keyId, generation, verify.command) + val materialized = materializer.materialize(verify.keyId, generation, verify.command, verify.tweak) val method = materialized.scheme AuditScope.currentOrNull()?.update { diff --git a/src/test/kotlin/org/exploit/keeper/tests/authority/TypedDataDeserializerSchemaTest.kt b/src/test/kotlin/org/exploit/keeper/tests/authority/TypedDataDeserializerSchemaTest.kt new file mode 100644 index 00000000..79a07ea3 --- /dev/null +++ b/src/test/kotlin/org/exploit/keeper/tests/authority/TypedDataDeserializerSchemaTest.kt @@ -0,0 +1,36 @@ +package org.exploit.keeper.tests.authority + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import org.exploit.keeper.service.command.deserializer.TypedDataDeserializer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class TypedDataDeserializerSchemaTest { + @Test + fun `MCP schema reflects nested required default and nullable fields`() { + val config = mapOf("fields" to mapOf( + "amount" to mapOf("type" to "bigint"), + "metadata" to mapOf("type" to "object", "fields" to mapOf( + "labels" to mapOf("type" to "list", "items" to mapOf("type" to "string")), + "note" to mapOf("type" to "string", "required" to false, "nullable" to true) + )), + "status" to mapOf("type" to "string", "default" to "READY") + )) + + val artifact = ObjectMapper().valueToTree(TypedDataDeserializer().schema(config)) + val typed = artifact.path("properties").path("typed") + val fields = typed.path("properties") + val metadata = fields.path("metadata") + + assertEquals(setOf("scheme", "typed"), artifact.path("required").map { it.asText() }.toSet()) + assertEquals(setOf("amount", "metadata"), typed.path("required").map { it.asText() }.toSet()) + assertFalse(typed.path("additionalProperties").asBoolean()) + assertEquals("integer", fields.path("amount").path("type").asText()) + assertEquals(setOf("labels"), metadata.path("required").map { it.asText() }.toSet()) + assertEquals("string", metadata.path("properties").path("labels").path("items").path("type").asText()) + assertEquals(listOf("string", "null"), metadata.path("properties").path("note") + .path("type").map { it.asText() }) + } +} diff --git a/src/test/kotlin/org/exploit/keeper/tests/authority/TypedIntentPolicySchemaTest.kt b/src/test/kotlin/org/exploit/keeper/tests/authority/TypedIntentPolicySchemaTest.kt index 58788da5..0f61e402 100644 --- a/src/test/kotlin/org/exploit/keeper/tests/authority/TypedIntentPolicySchemaTest.kt +++ b/src/test/kotlin/org/exploit/keeper/tests/authority/TypedIntentPolicySchemaTest.kt @@ -5,7 +5,7 @@ import dev.cel.common.types.SimpleType import org.exploit.keeper.service.command.intent.ArbitraryDataIntentBuilder import org.exploit.keeper.service.command.intent.TypedIntentBuilder import org.exploit.verdict.intent.typed.TypedIntent -import org.exploit.verdict.intent.typed.TypedIntentConfig +import org.exploit.verdict.intent.typed.config.TypedIntentConfig import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue diff --git a/tools/NativeJarSmoke.java b/tools/NativeJarSmoke.java new file mode 100644 index 00000000..4c6857aa --- /dev/null +++ b/tools/NativeJarSmoke.java @@ -0,0 +1,38 @@ +import java.lang.reflect.Method; + +class NativeJarSmoke { + public static void main(String[] args) throws Exception { + String actual = platform(); + if (!args[0].equals("all") && !args[0].equals(actual)) { + throw new IllegalStateException("Jar target is " + args[0] + ", but this JVM runs on " + actual); + } + + load("org.rocksdb.RocksDB", "loadLibrary"); + load("org.exploit.bigint.LibGMP", "load"); + load("org.exploit.sodium.LibSodium", "load"); + if (Boolean.parseBoolean(args[1])) { + load("org.exploit.ecc.secp256k1.LibSecp256k1", "load"); + } + Class.forName("io.netty.internal.tcnative.SSL", false, NativeJarSmoke.class.getClassLoader()) + .getMethod("getGroupName", long.class); + System.out.println("Verified Netty TLS native ABI"); + System.out.println("Native libraries loaded on " + actual); + } + + private static void load(String className, String methodName) throws Exception { + Method method = Class.forName(className).getMethod(methodName); + method.invoke(null); + System.out.println("Loaded " + className); + } + + private static String platform() { + String os = System.getProperty("os.name").toLowerCase(java.util.Locale.ROOT); + String arch = System.getProperty("os.arch").toLowerCase(java.util.Locale.ROOT); + String family = os.contains("linux") ? "linux" : + os.contains("mac") ? "macos" : + os.contains("win") ? "windows" : os; + String cpu = arch.equals("x86_64") || arch.equals("amd64") ? "amd64" : + arch.equals("aarch64") || arch.equals("arm64") ? "arm64" : arch; + return family + "-" + cpu; + } +} diff --git a/vendor/gmp/README.md b/vendor/gmp/README.md new file mode 100644 index 00000000..4b387ae5 --- /dev/null +++ b/vendor/gmp/README.md @@ -0,0 +1,20 @@ +# GMP 6.3.0 + +`gmp-6.3.0.tar.xz` is the source archive supplied for this project. Both production +and integration Docker builds copy it from this directory and run +`sha256sum --check --strict SHA256SUMS` before extracting or executing its contents. +No GMP download is performed during the build. + +SHA-256: `a3c2b80201b89e68616f4ad30bc66aee4927c3ce50e33929ca819d5c43538898` + +To verify locally on macOS: + +```sh +cd vendor/gmp +shasum -a 256 --check SHA256SUMS +``` + +When upgrading GMP, review the replacement archive, update its checksum and the +version paths in both Dockerfiles in the same change. The checksum pins the supplied +bytes; it is not a publisher signature. Licensing files are included in the archive +(`COPYING`, `COPYING.LESSERv3`, `COPYINGv2`). diff --git a/vendor/gmp/SHA256SUMS b/vendor/gmp/SHA256SUMS new file mode 100644 index 00000000..b01f955b --- /dev/null +++ b/vendor/gmp/SHA256SUMS @@ -0,0 +1 @@ +a3c2b80201b89e68616f4ad30bc66aee4927c3ce50e33929ca819d5c43538898 gmp-6.3.0.tar.xz diff --git a/vendor/gmp/gmp-6.3.0.tar.xz b/vendor/gmp/gmp-6.3.0.tar.xz new file mode 100644 index 00000000..eed1334c Binary files /dev/null and b/vendor/gmp/gmp-6.3.0.tar.xz differ