diff --git a/README.md b/README.md index e8e0aff3..a032aae3 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ See [Build and Features](docs/deployment/build-and-features.md). ## Security references -**Security assurance:** 356 automated functional scenarios, including 90 +**Security assurance:** 364 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 @@ -205,7 +205,7 @@ vectors and executable evidence. The HTTP contract is described by [openapi.yaml](openapi.yaml). -Java integrations can use [`org.exploit:tkeeper-sdk:2.4.0`](sdk/README.md). +Java integrations can use [`org.exploit:tkeeper-sdk:2.4.1`](sdk/README.md). ## Verification diff --git a/build.gradle b/build.gradle index cc474e40..4cf542e0 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { def mainClassName = 'org.exploit.keeper.TKeeperKt' group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() @@ -39,6 +39,7 @@ def featureModules = [ def explicitFeatureModules = [ 'features:auth-dev' : 'keeper.feature.auth.dev', + 'features:dry-run' : 'keeper.feature.dry-run', 'features:recovery' : 'keeper.feature.recovery', ] @@ -375,6 +376,7 @@ tasks.register('shadowJarProductionTest', com.github.jengelman.gradle.plugins.sh doLast { def forbidden = [ 'auth-dev' : 'org/exploit/keeper/auth/dev/provider/DevAuthProvider.class', + 'dry-run' : 'org/exploit/keeper/emulator/controller/DryRunController.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', @@ -403,6 +405,7 @@ tasks.register('artifactIsolationTest') { doLast { def markers = [ 'auth-dev' : 'org/exploit/keeper/auth/dev/provider/DevAuthProvider.class', + 'dry-run' : 'org/exploit/keeper/emulator/controller/DryRunController.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', diff --git a/docs/README.md b/docs/README.md index f08f7154..e9051641 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,6 +27,7 @@ TKeeper gives machines, agents, services, and workflows a cryptographic identity | 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) | diff --git a/docs/api-reference/README.md b/docs/api-reference/README.md index 4a9bd79d..c1bbd75a 100644 --- a/docs/api-reference/README.md +++ b/docs/api-reference/README.md @@ -4,6 +4,7 @@ Read: - [OpenAPI](openapi.md) - [Java SDK](sdk.md) +- [Dry Run Policy Evaluation](../signing-and-authorities/dry-run.md) - [Errors](errors.md) - [Permissions](permissions.md) diff --git a/docs/api-reference/permissions.md b/docs/api-reference/permissions.md index a701362b..e5768827 100644 --- a/docs/api-reference/permissions.md +++ b/docs/api-reference/permissions.md @@ -45,6 +45,7 @@ tkeeper.key.*.sign | `tkeeper.compliance.inventory` | read asset inventory | | `tkeeper.integrity.rotate` | rotate audit integrity key | | `tkeeper.audit.log.verify` | verify signed audit log lines | +| `tkeeper.emulate` | evaluate an authority policy without executing its command | | `tkeeper.control.system` | read control-plane system state | | `tkeeper.control.sinks` | read control-plane audit sink state | @@ -55,6 +56,7 @@ tkeeper.key.*.sign - Keep import permissions highly restricted. - Restrict consistency repair, share recovery, and quorum promotion to recovery operators. - Treat control-plane read permissions as sensitive operational metadata. +- Treat dry-run access as sensitive policy metadata; `tkeeper.emulate` is not scoped to a key id. - Keep destroy permissions separate from rotate and refresh. - Use negative permissions to carve high-risk identities out of broad grants. - Review wildcard grants before production. diff --git a/docs/api-reference/sdk.md b/docs/api-reference/sdk.md index 1000c6e6..9094612d 100644 --- a/docs/api-reference/sdk.md +++ b/docs/api-reference/sdk.md @@ -3,10 +3,10 @@ The Java SDK provides typed modules for the public TKeeper API: - [SDK README](../../sdk/README.md) -- Maven coordinate: `org.exploit:tkeeper-sdk:2.4.0` +- Maven coordinate: `org.exploit:tkeeper-sdk:2.4.1` - Java toolchain: 17 or newer -The SDK follows the same module boundaries as the API: system, DKG, signing, storage/import, quorum promotion, ECIES, compliance, expiration, audit, integrity, consistency, destroy, and control-plane reads. +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. The wire contract remains [`../../openapi.yaml`](../../openapi.yaml). Treat generated or handwritten SDK helpers as convenience code, not a competing source of truth. @@ -16,5 +16,6 @@ The wire contract remains [`../../openapi.yaml`](../../openapi.yaml). Treat gene - Close `TKeeperClient` to release its HTTP resources. - Pass `KeySetAuthorities` explicitly when creating governed identities; convenience constructors without authorities select `arbitrary` raw signing. - Build signing requests with `AuthorityCommand`. Verify converts it to a `VerificationCommand`, which retains only the material `type` and `artifact`. Verification may use any supported material type, including typed or arbitrary material, without requiring that type or payload to belong to the key's current authority manifest. +- 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. diff --git a/docs/deployment/backup-and-recovery.md b/docs/deployment/backup-and-recovery.md index 9a64ebd0..7e6c9cda 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.0.jar + java -Dkeeper.recovery=true -jar build/libs/tkeeper-2.4.1.jar ``` 4. Unseal the keepers if required and confirm health and status. diff --git a/docs/deployment/build-and-features.md b/docs/deployment/build-and-features.md index 5a169677..3fe239c7 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.0.jar +build/libs/tkeeper-2.4.1.jar ``` TKeeper requires Java 25. @@ -60,6 +60,7 @@ Feature names match child project names. The module `:features:authority-evm` is | AWS KMS seal provider | `seal-aws` | any required crypto platform | | 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 | | ML-DSA identities | none | `pqc` | | Default production set | `all` | `all` | @@ -76,7 +77,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 and `auth-dev` are excluded from `keeper.features=all` and +are not selected separately. Recovery, `auth-dev`, and `dry-run` 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 @@ -89,6 +90,12 @@ code and routes in the artifact. ./gradlew :build -Pkeeper.features=auth-dev -Pkeeper.platforms=ecc ``` +Build the dry-run endpoint explicitly in the same way: + +```bash +./gradlew :build -Pkeeper.features=dry-run -Pkeeper.platforms=ecc +``` + ## Selection properties | Scope | Features | Platforms | @@ -98,7 +105,7 @@ code and routes in the artifact. | 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` and `auth-dev` are +default production module in that category. Explicit features such as `recovery`, `auth-dev`, and `dry-run` are not included. ## Docker @@ -120,7 +127,7 @@ Build a recovery image with both platform implementations: Production image tags: ```text -exploit/tkeeper:2.4.0 +exploit/tkeeper:2.4.1 exploit/tkeeper:latest ``` @@ -139,7 +146,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.0 + exploit/tkeeper:2.4.1 ``` ## Integration image @@ -161,8 +168,8 @@ Build both images used by functional integration tests with: The test task reuses these images. Re-run the build after changing application code, dependencies, or Dockerfiles. Do not pass `keeper.features` or `keeper.platforms` to this task. The development integration image -uses a dedicated classpath containing every default production feature, the explicit `auth-dev` and -`recovery` features, both recovery platform modules, every platform, and the test-only +uses a dedicated classpath containing every default production feature, the explicit `auth-dev`, +`dry-run`, and `recovery` features, both recovery platform modules, every platform, and the test-only failure-injection module. Never deploy either test image as production runtime. diff --git a/docs/deployment/configuration.md b/docs/deployment/configuration.md index 88e2daa6..da9574e6 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.0.jar + -jar build/libs/tkeeper-2.4.1.jar ``` Minimal node config: diff --git a/docs/deployment/control-plane-ui.md b/docs/deployment/control-plane-ui.md index 5482215a..7e39e850 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.0.jar + -jar build/libs/tkeeper-2.4.1.jar ``` ## Authentication diff --git a/docs/getting-started/local-single-node.md b/docs/getting-started/local-single-node.md index a1c226f2..5da84091 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.0.jar +build/libs/tkeeper-2.4.1.jar ``` ## Create local config @@ -102,7 +102,7 @@ java \ -Dkeeper.dev.enabled=true \ -Dkeeper.dev.config.location=/tmp/tkeeper \ -Dkeeper.coordinator.enabled=true \ - -jar build/libs/tkeeper-2.4.0.jar + -jar build/libs/tkeeper-2.4.1.jar ``` ## Initialize the node diff --git a/docs/operations/error-tracking.md b/docs/operations/error-tracking.md index fc737108..0a4459e5 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.0" + release = "2.4.1" } ``` diff --git a/docs/operations/integration-tests.md b/docs/operations/integration-tests.md index 65967fe3..fd956e14 100644 --- a/docs/operations/integration-tests.md +++ b/docs/operations/integration-tests.md @@ -136,12 +136,13 @@ Do not pass `keeper.features` or `keeper.platforms` to `buildTestContainers`. Th - every default production feature - the explicit recovery feature and both recovery platform modules - development authentication +- dry-run policy evaluation - every platform - the test-only failure-injection module The production transport test image uses the production UBI Dockerfile and excludes development -authentication, recovery, and failure injection. Regular `shadowJar` and `dockerBuild` exclude -failure injection; recovery is included only when selected. +authentication, dry run, recovery, and failure injection. Regular `shadowJar` and `dockerBuild` +exclude failure injection; dry run and recovery are included only when selected. See [`../../integration-tests/README.md`](../../integration-tests/README.md) for local requirements and Testcontainers setup. diff --git a/docs/security-model/security-assurance.md b/docs/security-model/security-assurance.md index 91ec1dfb..98567a3c 100644 --- a/docs/security-model/security-assurance.md +++ b/docs/security-model/security-assurance.md @@ -1,6 +1,6 @@ # Security Assurance -TKeeper security assurance currently comprises **356 automated functional scenarios across 17 test +TKeeper security assurance currently comprises **364 automated functional scenarios across 18 test classes**, including **90 protocol and corruption failure-injection scenarios** and one 3-of-5 share-recovery scenario, executed against multi-node Keeper deployments. @@ -10,7 +10,7 @@ transport cluster and a 3-of-5 recovery cluster with per-run PKI, TLS, mTLS, pee and SPKI pinning. The transport cluster also exercises JWT and JWKS behavior. Every pull request targeting `main` and every commit pushed to `main` runs the Release Gate. A -passing revision completes the module test tasks, all 356 functional scenarios, artifact isolation, +passing revision completes the module test tasks, all 364 functional scenarios, artifact isolation, and both container builds. > **In short:** TKeeper tests cover production identity and transport, @@ -233,6 +233,7 @@ paths are covered in [SignatureTests]. | --- | --- | --- | | Authority downgrade or type confusion (T-3, partial T-4) | Raw `arbitrary` authority cannot be mixed with OCI authorities. Empty, duplicate, malformed, missing-reference, reference/id-mismatched, unsupported, command-unattached, and artifact-type-mismatched authorities fail closed. | [AuthorityPolicyTests]: `rejectsAuthorityThatIsNotAllowedForKey`, `rejectsArtifactTypeThatDoesNotMatchAuthorityType`, `rejectsGenerateWithArbitraryMixedWithOciAuthority`, `rejectsDuplicateAuthorityIdsOnGenerate`, `rejectsGenerateWithEmptyAuthorities`, `rejectsGenerateWithInvalidAuthorityId`, `rejectsOciAuthorityWithoutReference`, `rejectsOciReferenceWhoseAuthorityIdDoesNotMatch`, `rejectsUnsupportedAuthorityTypeBeforeCreatingKey`, `verifyRejectsMalformedEvmMaterialWithoutResolvingAuthority` | | Typed intent or effect-policy bypass (T-5) | Allowed commands sign while policy violations are denied for custom payments, EVM native/ERC-20 transfer, approval, `transferFrom`, vault operations, Bitcoin spend, and X.509 issuance. Unknown fields, wrong types, wrong principals, and over-limit amounts are exercised. | [AuthorityPolicyTests]: `deniesTypedCommandRejectedByAuthorityPolicy`, `rejectsTypedCommandWithUnknownField`, `rejectsTypedCommandWithWrongFieldType`, `deniesEvmErc20ApprovalWhenAmountExceedsPolicy`, `deniesEvmErc20TransferFromWhenOwnerDoesNotMatchPolicy`, `deniesEvmVaultWithdrawWhenRecipientDoesNotMatchPolicy`, `deniesBitcoinSpendRejectedByAuthorityPolicy`, `deniesX509TbsCertificateRejectedByAuthorityPolicy` | +| Dry-run policy bypass (T-1, T-3, T-5) | The optional endpoint reports allow, deny, and four-eye-control decisions with approval requirements; it requires authentication, validates key identifiers and existence, and rejects authorities not assigned to the key. | [DryRunTests]: `returnsAllowForAcceptedCommand`, `returnsDenyAsEvaluationResult`, `returnsAllowWithRequirementsForFourEyeControl`, `requiresAuthentication`, `rejectsInvalidKeyId`, `rejectsMissingKey`, `rejectsAuthorityNotAssignedToKey` | | Four-eye bypass or incomplete approval group (T-6) | Sign, decrypt, refresh, rotate, and destroy fail without required proofs. Replaced approver sets invalidate old proofs; every matching authority group is required; key and authority requirements are cumulative. | [FourEyeControlTests]: `ensureDecryptRequiresProofs`, `ensureSignRequiresProofs`, `ensureRefreshRequiresProofs`, `ensureOldKeySetNoLongerWorks`, `ensureRotateRequiresProofs`, `authorityPolicyRequiresEveryMatchingApprovalGroup`, `keyAndAuthorityPoliciesShareOneApprovalNonce`, `lenientFourEyeControlAppliesToDestroy` | | Approval substitution, replay, or race (T-6) | Changing an approved command invalidates its proof. A consumed approval cannot replay; concurrent submissions yield only one success; consumption survives coordinator restart. | [FourEyeControlTests]: `authorityPolicyBindsProofsAndRejectsReplay`, `approvalNonceIsConsumedAtomically`, `approvalNonceRemainsConsumedAfterCoordinatorRestart` | | Single-peer policy tampering (T-3, T-5, T-6) | Mutating the coordinator's authority or deleting its four-eye or expiry policy does not reach threshold. A healthy quorum still accepts a valid command when one non-coordinator peer has missing or swapped authority metadata. | [FailureInjectionTests]: `arbitraryAuthorityInjectedOnOneCoordinatorPeerDoesNotReachThreshold`, `authoritySwappedOnCoordinatorCannotAuthorizeDifferentCommand`, `fourEyePolicyDeletedOnCoordinatorStillRequiresPeerApprovals`, `expiredApplyPolicyClearedOnCoordinatorStillBlocksAtPeers`, `validCommandStillSignsWhenOnePeerRemovedAuthorities`, `validCommandStillSignsWhenOnePeerSwappedAuthorities` | @@ -264,7 +265,7 @@ paths are covered in [SignatureTests]. | --- | --- | --- | | Invalid TLS identity rotation | A mismatched PEM key/certificate update retains the last known good identity. | [ProductionTransportSecurityTests]: `publicPemRotationRetainsLastKnownGoodIdentityWhileFilesMismatch` | | JWKS rotation or refresh failure | Rotation accepts the new key and removes the retired key; failed refresh retains the last known good set. | [ProductionTransportSecurityTests]: `jwksRotationAcceptsNewSigningKeyWithoutRestart`, `failedJwksRefreshKeepsLastKnownGoodKeys` | -| Optional-module isolation | Development authentication, failure injection, and recovery modules are present in the integration artifact and absent from the production test artifact. | `./gradlew artifactIsolationTest` | +| Optional-module isolation | Development authentication, dry run, failure injection, and recovery modules are present in the integration artifact and absent from the production test artifact. | `./gradlew artifactIsolationTest` | | Release evidence | Module tests, artifact isolation, container builds, functional suites, and production transport tests execute as one release gate. | `./gradlew releaseGate` | ### Exact protocol vector catalog @@ -332,6 +333,7 @@ produces and verifies a normal distributed signature with the same stored key. [NIST ACVP ML-DSA specification]: https://pages.nist.gov/ACVP/draft-celi-acvp-ml-dsa.html [AuthorityPolicyTests]: https://github.com/tkeeper-org/tkeeper/blob/main/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/AuthorityPolicyTests.kt [CoordinatorDisabledTest]: https://github.com/tkeeper-org/tkeeper/blob/main/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/CoordinatorDisabledTest.kt +[DryRunTests]: https://github.com/tkeeper-org/tkeeper/blob/main/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/DryRunTests.kt [ECIESTests]: https://github.com/tkeeper-org/tkeeper/blob/main/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/ECIESTests.kt [FailureInjectionTests]: https://github.com/tkeeper-org/tkeeper/blob/main/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/FailureInjectionTests.kt [RecoveryFailureInjectionTests]: https://github.com/tkeeper-org/tkeeper/blob/main/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/RecoveryFailureInjectionTests.kt diff --git a/docs/signing-and-authorities/README.md b/docs/signing-and-authorities/README.md index 7864be99..128d8736 100644 --- a/docs/signing-and-authorities/README.md +++ b/docs/signing-and-authorities/README.md @@ -6,6 +6,7 @@ Read: - [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) diff --git a/docs/signing-and-authorities/authorities.md b/docs/signing-and-authorities/authorities.md index 8f223c15..3f60a2d9 100644 --- a/docs/signing-and-authorities/authorities.md +++ b/docs/signing-and-authorities/authorities.md @@ -113,7 +113,7 @@ policy: allowedEnvironments: [production] requiredRoles: [release-manager, production] allowedCidrs: ["10.20.0.0/16", "fd00:20::/48"] - minimumReleaseVersion: "2.4.0" + minimumReleaseVersion: "2.4.1" maximumRiskScore: "0.25" minimumSequence: "10000000000000000000" maximumWindowSeconds: 300 @@ -181,7 +181,7 @@ For a concrete authority, the sign command must reference an authority attached "action": "deploy", "service": "billing-api", "environment": "production", - "releaseVersion": "2.4.0", + "releaseVersion": "2.4.1", "sequence": 10000000000000000001, "riskScore": 0.20, "roles": ["release-manager", "production"], @@ -239,7 +239,7 @@ Example effect: "action": "deploy", "service": "billing-api", "environment": "production", - "version": "2.4.0", + "version": "2.4.1", "sequence": 10000000000000000001 } ``` diff --git a/docs/signing-and-authorities/dry-run.md b/docs/signing-and-authorities/dry-run.md new file mode 100644 index 00000000..6f1141d2 --- /dev/null +++ b/docs/signing-and-authorities/dry-run.md @@ -0,0 +1,144 @@ +# Dry Run Policy Evaluation + +The optional `dry-run` module evaluates an `AuthorityCommand` against the policy attached to a key's current authority without executing the command. + +Use it to preview whether the same command would be allowed, denied, or require authority-policy approvals before submitting it for signing. Dry run does not create a signature, verify or consume approvals, or mutate key state. + +## Enable the module + +Dry run is an explicit build feature. It is excluded from `keeper.features=all` and must be selected by name: + +```bash +./gradlew :build \ + -Pkeeper.features=dry-run \ + -Pkeeper.platforms=ecc +``` + +For a Docker image: + +```bash +./gradlew dockerBuild \ + -Pkeeper.docker.features=dry-run \ + -Pkeeper.docker.platforms=ecc +``` + +See [Build and Features](../deployment/build-and-features.md) for feature and platform selection. + +## Permission + +The endpoint requires an authenticated caller with: + +```text +tkeeper.emulate +``` + +This permission is not scoped to a key id. A successful response can expose policy matches, approver public keys, and approver metadata for any requested key known to the caller. Grant it only to services that are allowed to inspect authority-policy decisions. + +## Java SDK + +The Java SDK exposes dry-run evaluation through `TKeeperClient.dryRun()`. + +```java +import org.exploit.tkeeper.sdk.model.Emulate; +import org.exploit.tkeeper.sdk.model.HashMethod; +import org.exploit.tkeeper.sdk.model.PolicyVerdict; +import org.exploit.tkeeper.sdk.model.SignatureSchemes; +import org.exploit.tkeeper.sdk.model.command.AuthorityCommand; +import org.exploit.tkeeper.sdk.model.command.artifact.TypedData; +import org.exploit.tkeeper.sdk.util.TKeeperJackson; + +var payload = TKeeperJackson.signingNode() + .put("purpose", "payments") + .put("amount", 500) + .put("currency", "USD"); + +var command = AuthorityCommand.of( + "payments-small", + new TypedData(SignatureSchemes.ECDSA, HashMethod.SHA256, payload) +); + +var evaluation = keeper.dryRun().emulate( + Emulate.of("payments-key", command) +); + +if (evaluation.decision() == PolicyVerdict.DENY) { + throw new IllegalStateException("authority policy denied the command"); +} +``` + +The request uses the same `AuthorityCommand` shape as signing. The command's `authorityId` must be assigned to the key, and its artifact type must match that authority. + +## Decisions + +Dry run returns HTTP `200` for every completed policy evaluation, including denials. + +| `decision` | Meaning | +| --- | --- | +| `ALLOW` | The authority policy currently allows the command without policy-driven approvals. | +| `ALLOW_WITH_REQUIREMENTS` | The authority policy allows the command after every returned approval group is satisfied. | +| `DENY` | The authority policy rejects the command. A real signing request would fail with `POLICY_VIOLATION`. | + +`matches` contains the rules that contributed to the decision. `approvalRequirements` contains every authority-policy approval group required by the decision. + +Example response requiring four-eye approval: + +```json +{ + "decision": "ALLOW_WITH_REQUIREMENTS", + "matches": [ + { + "id": "review-large-payment", + "effect": "ALLOW_WITH_REQUIREMENTS" + } + ], + "approvalRequirements": [ + { + "policyId": "payments-policy", + "source": "review-large-payment", + "threshold": 1, + "approvers": { + "operator": { + "algorithm": "ED25519", + "publicKey64": "...", + "metadata": { + "team": "payments" + } + } + } + } + ] +} +``` + +Each approval requirement identifies the policy and matching rule or fallback in `source`, the required threshold, and the eligible approvers. See [Four-Eye Control](../security-model/four-eye-control.md) for constructing approvals for the later signing request. + +## HTTP API + +The SDK calls: + +```text +POST /v1/keeper/emulate +``` + +Request body: + +```json +{ + "keyId": "payments-key", + "command": { + "type": "custom", + "authorityId": "payments-small", + "artifact": { + "scheme": "ECDSA", + "hash": "SHA256", + "typed": { + "purpose": "payments", + "amount": 500, + "currency": "USD" + } + } + } +} +``` + +The complete wire contract is in [`../../openapi.yaml`](../../openapi.yaml). diff --git a/docs/signing-and-authorities/signing.md b/docs/signing-and-authorities/signing.md index e5c42743..0a771226 100644 --- a/docs/signing-and-authorities/signing.md +++ b/docs/signing-and-authorities/signing.md @@ -84,7 +84,7 @@ Typed command: "action": "deploy", "service": "billing-api", "environment": "production", - "releaseVersion": "2.4.0", + "releaseVersion": "2.4.1", "sequence": 10000000000000000001, "riskScore": 0.20, "roles": ["release-manager", "production"], diff --git a/features/auth-dev/build.gradle b/features/auth-dev/build.gradle index 84519e64..0c779ca3 100644 --- a/features/auth-dev/build.gradle +++ b/features/auth-dev/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/features/authority-bitcoin/build.gradle b/features/authority-bitcoin/build.gradle index 6ab15e50..90b72376 100644 --- a/features/authority-bitcoin/build.gradle +++ b/features/authority-bitcoin/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/features/authority-evm/build.gradle b/features/authority-evm/build.gradle index d2a18e46..cc98ec8a 100644 --- a/features/authority-evm/build.gradle +++ b/features/authority-evm/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/features/authority-x509/build.gradle b/features/authority-x509/build.gradle index f80df73a..a6a3d714 100644 --- a/features/authority-x509/build.gradle +++ b/features/authority-x509/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/features/dry-run/build.gradle b/features/dry-run/build.gradle new file mode 100644 index 00000000..3b31cfee --- /dev/null +++ b/features/dry-run/build.gradle @@ -0,0 +1,42 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' version '2.3.20' + id 'com.gradleup.shadow' version '9.4.1' + id 'com.google.devtools.ksp' version '2.3.6' +} + +group = 'org.exploit' +version = '2.4.1' + +repositories { + mavenCentral() +} + +dependencies { + testImplementation 'org.jetbrains.kotlin:kotlin-test' + compileOnly(project(':')) + testImplementation(project(':')) + + 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/dry-run/src/main/kotlin/org/exploit/keeper/emulator/constant/DryRunPermission.kt b/features/dry-run/src/main/kotlin/org/exploit/keeper/emulator/constant/DryRunPermission.kt new file mode 100644 index 00000000..b948cb85 --- /dev/null +++ b/features/dry-run/src/main/kotlin/org/exploit/keeper/emulator/constant/DryRunPermission.kt @@ -0,0 +1,5 @@ +package org.exploit.keeper.emulator.constant + +object DryRunPermission { + const val EMULATE = "tkeeper.emulate" +} \ No newline at end of file diff --git a/features/dry-run/src/main/kotlin/org/exploit/keeper/emulator/controller/DryRunController.kt b/features/dry-run/src/main/kotlin/org/exploit/keeper/emulator/controller/DryRunController.kt new file mode 100644 index 00000000..ba9f2fea --- /dev/null +++ b/features/dry-run/src/main/kotlin/org/exploit/keeper/emulator/controller/DryRunController.kt @@ -0,0 +1,31 @@ +package org.exploit.keeper.emulator.controller + +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.emulator.constant.DryRunPermission +import org.exploit.keeper.emulator.model.Emulate +import org.exploit.keeper.emulator.service.PolicyEmulatorService +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.util.extension.http.hasPermission +import org.exploit.keeper.util.validation.InputValidator + +@Singleton +class DryRunController( + private val support: ControllerSupport, + private val emulator: PolicyEmulatorService +): ExternalController { + override fun register(sb: ServerBuilder) = with(support) { + sb.publicPost("/v1/keeper/emulate", Emulate::class.java) { ctx, emulate -> + if (!ctx.hasPermission(DryRunPermission.EMULATE)) + throw TKeeperException(ErrorType.ACCESS_DENIED) + + if (!InputValidator.isValidKeyId(emulate.keyId)) + throw TKeeperException(ErrorType.INVALID_KEY_ID, emulate.keyId) + + emulator.emulate(emulate.keyId, emulate.command) + } + } +} \ No newline at end of file diff --git a/features/dry-run/src/main/kotlin/org/exploit/keeper/emulator/model/Emulate.kt b/features/dry-run/src/main/kotlin/org/exploit/keeper/emulator/model/Emulate.kt new file mode 100644 index 00000000..1ea70a86 --- /dev/null +++ b/features/dry-run/src/main/kotlin/org/exploit/keeper/emulator/model/Emulate.kt @@ -0,0 +1,5 @@ +package org.exploit.keeper.emulator.model + +import org.exploit.keeper.model.command.AuthorityCommand + +data class Emulate(val keyId: String, val command: AuthorityCommand) \ No newline at end of file diff --git a/features/dry-run/src/main/kotlin/org/exploit/keeper/emulator/service/PolicyEmulatorService.kt b/features/dry-run/src/main/kotlin/org/exploit/keeper/emulator/service/PolicyEmulatorService.kt new file mode 100644 index 00000000..9ab34a31 --- /dev/null +++ b/features/dry-run/src/main/kotlin/org/exploit/keeper/emulator/service/PolicyEmulatorService.kt @@ -0,0 +1,21 @@ +package org.exploit.keeper.emulator.service + +import jakarta.inject.Singleton +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.authority.KeyAuthorityEvaluator +import org.exploit.keeper.service.db.RocksKeyDB +import org.exploit.verdict.model.evaluation.PolicyEvaluation + +@Singleton +class PolicyEmulatorService(private val db: RocksKeyDB, private val evaluator: KeyAuthorityEvaluator) { + fun emulate(keyId: String, command: AuthorityCommand): PolicyEvaluation { + val latestGen = db.vault.getCurrentGeneration(keyId) + + if (latestGen < 1) + throw TKeeperException(ErrorType.KEY_NOT_FOUND) + + return evaluator.evaluate(keyId, latestGen, command) + } +} \ No newline at end of file diff --git a/features/ecies/build.gradle b/features/ecies/build.gradle index 7e1b62b7..946d2343 100644 --- a/features/ecies/build.gradle +++ b/features/ecies/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/features/recovery/build.gradle b/features/recovery/build.gradle index f99574f0..3b31cfee 100644 --- a/features/recovery/build.gradle +++ b/features/recovery/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/features/recovery/ecc/build.gradle b/features/recovery/ecc/build.gradle index 93c81942..cf67a98a 100644 --- a/features/recovery/ecc/build.gradle +++ b/features/recovery/ecc/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/features/recovery/pqc/build.gradle b/features/recovery/pqc/build.gradle index 491fe92a..fe35930b 100644 --- a/features/recovery/pqc/build.gradle +++ b/features/recovery/pqc/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/features/seal-aws/build.gradle b/features/seal-aws/build.gradle index c87c2c88..c1515fe2 100644 --- a/features/seal-aws/build.gradle +++ b/features/seal-aws/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/features/seal-gcloud/build.gradle b/features/seal-gcloud/build.gradle index 9bfcff02..67cfbdd1 100644 --- a/features/seal-gcloud/build.gradle +++ b/features/seal-gcloud/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/features/ui/build.gradle b/features/ui/build.gradle index e6634297..48047ada 100644 --- a/features/ui/build.gradle +++ b/features/ui/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/integration-tests/README.md b/integration-tests/README.md index 9ba6c69c..7e24091c 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -55,16 +55,16 @@ 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` and `:features:recovery` modules +- the explicit opt-in `:features:auth-dev`, `:features:dry-run`, and `:features:recovery` modules - both recovery platform modules - `platform-ecc` and `platform-pqc` - the test-only `:integration-tests:failure-injection` module -Regular `shadowJar` and `dockerBuild` artifacts do not include failure injection. Recovery is included -only when explicitly selected. Never deploy the integration image as a production runtime. +Regular `shadowJar` and `dockerBuild` artifacts do not include failure injection. Dry run and recovery +are included only when explicitly selected. Never deploy the integration image as a production runtime. -Verify that the production transport test artifact excludes `auth-dev`, recovery, and failure -injection while the integration artifact contains all three: +Verify that the production transport test artifact excludes `auth-dev`, `dry-run`, recovery, and +failure injection while the integration artifact contains all four: ```bash ./gradlew artifactIsolationTest @@ -77,6 +77,7 @@ The functional suite is split by boundary: | `SignatureTests` | ECC and ML-DSA signing and verification | | `KeyLifecycleTests` | create, refresh, rotate, and PQC-specific lifecycle behavior | | `AuthorityPolicyTests` | typed authority materialization and policy decisions | +| `DryRunTests` | opt-in policy evaluation, authentication, and fail-closed request validation | | `FailureInjectionTests` | corruption, malicious signing packages, transcript mutations, ECC/PQC DKG and FROST/GG20/ML-DSA protocol-order violations, sequential replay, eight-way transition races, in-flight protocol restarts, demotion, and consistency repair | | `RecoveryFailureInjectionTests` | production-TLS 3-of-5 ECC/PQC recovery, two differently damaged peers, explicit healthy helpers, mode restarts, full historical state rebuild, destroyed generations, transaction rollback, prefix isolation, and post-recovery signing | | `ProductionTransportSecurityTests` | generated PKI, public HTTPS/JWT, HTTPS JWKS rotation, internal mTLS/SPKI, and fail-closed startup variants | diff --git a/integration-tests/build.gradle b/integration-tests/build.gradle index 022f85a9..51ebcc87 100644 --- a/integration-tests/build.gradle +++ b/integration-tests/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' ext { projectName = "Integration Tests" diff --git a/integration-tests/docker/conf/keeper-1/dev.conf b/integration-tests/docker/conf/keeper-1/dev.conf index e20c5bbb..3096a637 100644 --- a/integration-tests/docker/conf/keeper-1/dev.conf +++ b/integration-tests/docker/conf/keeper-1/dev.conf @@ -22,6 +22,7 @@ keeper { "tkeeper.control.sinks", "tkeeper.storage.write", "tkeeper.expired.view", + "tkeeper.emulate", "tkeeper.failure.inject", "tkeeper.quorum.promote" ] diff --git a/integration-tests/docker/conf/keeper-2/dev.conf b/integration-tests/docker/conf/keeper-2/dev.conf index e20c5bbb..3096a637 100644 --- a/integration-tests/docker/conf/keeper-2/dev.conf +++ b/integration-tests/docker/conf/keeper-2/dev.conf @@ -22,6 +22,7 @@ keeper { "tkeeper.control.sinks", "tkeeper.storage.write", "tkeeper.expired.view", + "tkeeper.emulate", "tkeeper.failure.inject", "tkeeper.quorum.promote" ] diff --git a/integration-tests/docker/conf/keeper-3/dev.conf b/integration-tests/docker/conf/keeper-3/dev.conf index e20c5bbb..3096a637 100644 --- a/integration-tests/docker/conf/keeper-3/dev.conf +++ b/integration-tests/docker/conf/keeper-3/dev.conf @@ -22,6 +22,7 @@ keeper { "tkeeper.control.sinks", "tkeeper.storage.write", "tkeeper.expired.view", + "tkeeper.emulate", "tkeeper.failure.inject", "tkeeper.quorum.promote" ] diff --git a/integration-tests/docker/conf/keeper-4/dev.conf b/integration-tests/docker/conf/keeper-4/dev.conf index b16587ef..97d2885c 100644 --- a/integration-tests/docker/conf/keeper-4/dev.conf +++ b/integration-tests/docker/conf/keeper-4/dev.conf @@ -22,6 +22,7 @@ keeper { "tkeeper.control.sinks", "tkeeper.storage.write", "tkeeper.expired.view", + "tkeeper.emulate", "tkeeper.failure.inject", "tkeeper.quorum.promote" ] diff --git a/integration-tests/docker/conf/keeper-5/dev.conf b/integration-tests/docker/conf/keeper-5/dev.conf index b16587ef..97d2885c 100644 --- a/integration-tests/docker/conf/keeper-5/dev.conf +++ b/integration-tests/docker/conf/keeper-5/dev.conf @@ -22,6 +22,7 @@ keeper { "tkeeper.control.sinks", "tkeeper.storage.write", "tkeeper.expired.view", + "tkeeper.emulate", "tkeeper.failure.inject", "tkeeper.quorum.promote" ] diff --git a/integration-tests/docker/conf/keeper-6/dev.conf b/integration-tests/docker/conf/keeper-6/dev.conf index b16587ef..97d2885c 100644 --- a/integration-tests/docker/conf/keeper-6/dev.conf +++ b/integration-tests/docker/conf/keeper-6/dev.conf @@ -22,6 +22,7 @@ keeper { "tkeeper.control.sinks", "tkeeper.storage.write", "tkeeper.expired.view", + "tkeeper.emulate", "tkeeper.failure.inject", "tkeeper.quorum.promote" ] diff --git a/integration-tests/failure-injection/build.gradle b/integration-tests/failure-injection/build.gradle index bb4ef58f..86bb73c9 100644 --- a/integration-tests/failure-injection/build.gradle +++ b/integration-tests/failure-injection/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/integration-tests/functional/build.gradle b/integration-tests/functional/build.gradle index 5cfa985a..5d9979af 100644 --- a/integration-tests/functional/build.gradle +++ b/integration-tests/functional/build.gradle @@ -4,7 +4,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/AuthorityPolicyTests.kt b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/AuthorityPolicyTests.kt index 24bb4a0d..0038ba78 100644 --- a/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/AuthorityPolicyTests.kt +++ b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/AuthorityPolicyTests.kt @@ -1402,6 +1402,27 @@ class AuthorityPolicyTests : TKeeperComposeEnvironment() { assertContains(details, "policy '$policyId', DENY rule '$ruleId', unless[1]") } + @Test + @Order(69) + fun rejectsTypedCommandWhenKeyListsArbitraryAuthority() { + val ex = assertThrows { + client(1).signature().sign( + Sign.of( + ARBITRARY_KEY, + Command.of( + KeySetAuthority.ARBITRARY, + TypedData( + SignatureSchemes.ECDSA, + TKeeperJackson.signingNode().put("payload", "typed-as-arbitrary") + ) + ) + ) + ) + } + + assertEquals(ErrorType.INVALID_AUTHORITY_ARTIFACT, ex.errorType) + } + private fun signCommand(keyId: String, command: Command): String = client(1).signature().sign(Sign.of(keyId, command)).signature64() diff --git a/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/DryRunTests.kt b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/DryRunTests.kt new file mode 100644 index 00000000..0ee3762b --- /dev/null +++ b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/DryRunTests.kt @@ -0,0 +1,214 @@ +package org.exploit.test.functional + +import org.exploit.ed25519.Ed25519 +import org.exploit.jettyx.auth.NoAuth +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.OciAuthorityPublisher +import org.exploit.tkeeper.sdk.TKeeperClient +import org.exploit.tkeeper.sdk.exception.TKeeperException +import org.exploit.tkeeper.sdk.model.Emulate +import org.exploit.tkeeper.sdk.model.ErrorType +import org.exploit.tkeeper.sdk.model.Generate +import org.exploit.tkeeper.sdk.model.HashMethod +import org.exploit.tkeeper.sdk.model.KeyAlgorithms +import org.exploit.tkeeper.sdk.model.KeyGenMode +import org.exploit.tkeeper.sdk.model.KeySetAuthorities +import org.exploit.tkeeper.sdk.model.KeySetAuthority +import org.exploit.tkeeper.sdk.model.PolicyEvaluation +import org.exploit.tkeeper.sdk.model.PolicyVerdict +import org.exploit.tkeeper.sdk.model.SignatureSchemes +import org.exploit.tkeeper.sdk.model.command.AuthorityCommand +import org.exploit.tkeeper.sdk.model.command.Command +import org.exploit.tkeeper.sdk.model.command.artifact.TypedData +import org.exploit.tkeeper.sdk.util.TKeeperJackson +import org.exploit.tss.TSS +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 java.net.URI +import java.util.Base64 +import kotlin.test.assertEquals + +@Testcontainers +class DryRunTests : TKeeperComposeEnvironment() { + override val env: ComposeEnvironment = dockerEnvironment + + @BeforeAll + fun createKey() { + TSS.loadLibraries() + + val publisher = OciAuthorityPublisher( + registryHost = env.getServiceHost(REGISTRY_SERVICE, REGISTRY_PORT), + registryPort = env.getServicePort(REGISTRY_SERVICE, REGISTRY_PORT) + ) + val authorities = AuthorityFixtures.publish(publisher, listOf(AuthorityFixtures.paymentsSmall)) + val approvalAuthority = KeySetAuthority.oci( + APPROVAL_AUTHORITY_ID, + publisher.publish(APPROVAL_AUTHORITY_REPOSITORY, approvalAuthorityYaml()) + ) + + client(1).dkg().generate( + Generate( + KEY_ID, + KeyAlgorithms.SECP256K1, + KeySetAuthorities.of( + authorities.authority(AuthorityFixtures.CUSTOM_PAYMENTS_SMALL), + approvalAuthority + ), + KeyGenMode.CREATE + ) + ) + } + + @Test + fun returnsAllowForAcceptedCommand() { + val evaluation = emulate(KEY_ID, paymentCommand(500)) + + assertEquals(PolicyVerdict.ALLOW, evaluation.decision) + assertEquals(listOf("allow-small-usd-payment"), evaluation.matches.map { it.id }) + } + + @Test + fun returnsDenyAsEvaluationResult() { + assertEquals( + PolicyVerdict.DENY, + emulate(KEY_ID, paymentCommand(5_000)).decision + ) + } + + @Test + fun returnsAllowWithRequirementsForFourEyeControl() { + val evaluation = emulate(KEY_ID, approvalCommand()) + + assertEquals(PolicyVerdict.ALLOW_WITH_REQUIREMENTS, evaluation.decision) + assertEquals(1, evaluation.approvalRequirements.size) + assertEquals(APPROVAL_POLICY_ID, evaluation.approvalRequirements.single().policyId) + assertEquals(1, evaluation.approvalRequirements.single().threshold) + assertEquals(setOf(APPROVER_ID), evaluation.approvalRequirements.single().approvers.keys) + } + + @Test + fun rejectsAuthorityNotAssignedToKey() { + val error = assertThrows { + emulate(KEY_ID, paymentCommand(500, "test:custom/not-assigned")) + } + + assertEquals(ErrorType.AUTHORITY_VIOLATION, error.errorType) + } + + @Test + fun rejectsInvalidKeyId() { + val error = assertThrows { + emulate("invalid key id", paymentCommand(500)) + } + + assertEquals(ErrorType.INVALID_KEY_ID, error.errorType) + } + + @Test + fun rejectsMissingKey() { + val error = assertThrows { + emulate("dry-run-missing", paymentCommand(500)) + } + + assertEquals(ErrorType.KEY_NOT_FOUND, error.errorType) + } + + @Test + fun requiresAuthentication() { + val error = TKeeperClient(baseUrl(), NoAuth()).use { unauthenticated -> + assertThrows { + unauthenticated.dryRun().emulate(Emulate.of(KEY_ID, paymentCommand(500))) + } + } + + assertEquals(ErrorType.UNAUTHENTICATED, error.errorType) + } + + private fun emulate( + keyId: String, + command: AuthorityCommand + ): PolicyEvaluation = client(1).dryRun().emulate(Emulate.of(keyId, command)) + + private fun baseUrl(): String { + val host = env.getServiceHost(KEEPER_SERVICE, KEEPER_PORT) + val port = env.getServicePort(KEEPER_SERVICE, KEEPER_PORT) + return URI("http", null, host, port, null, null, null).toString() + } + + private fun paymentCommand( + amount: Long, + authorityId: String = AuthorityFixtures.CUSTOM_PAYMENTS_SMALL + ): AuthorityCommand { + val typed = TKeeperJackson.signingNode() + .put("purpose", "payments") + .put("amount", amount) + .put("currency", "USD") + .put("payload", "dry-run-it") + + return Command.of( + authorityId, + TypedData(SignatureSchemes.ECDSA, HashMethod.SHA256, typed) + ) + } + + private fun approvalCommand(): AuthorityCommand = Command.of( + APPROVAL_AUTHORITY_ID, + TypedData( + SignatureSchemes.ECDSA, + HashMethod.SHA256, + TKeeperJackson.signingNode().put("purpose", "approval") + ) + ) + + private fun approvalAuthorityYaml(): String { + val pair = Ed25519.generateKeyPair() + val publicKey64 = try { + Base64.getEncoder().encodeToString(pair.publicKey) + } finally { + pair.secretKey.fill(0) + } + + return $$""" + schemaVersion: verdict.authority/v1 + id: $$APPROVAL_AUTHORITY_ID + type: custom + version: 1.0.0 + config: + fields: + purpose: + type: string + policy: + id: $$APPROVAL_POLICY_ID + fallback: ALLOW_WITH_REQUIREMENTS + approvers: + $$APPROVER_ID: + algorithm: ED25519 + publicKey64: $$publicKey64 + fallbackApprovals: + threshold: 1 + approvers: [$$APPROVER_ID] + """.trimIndent() + } + + companion object { + private const val REGISTRY_SERVICE = "registry" + private const val REGISTRY_PORT = 5000 + private const val KEEPER_SERVICE = "keeper-1" + private const val KEEPER_PORT = 8080 + private const val KEY_ID = "dry-run-policy" + private const val APPROVAL_AUTHORITY_ID = "test:custom/dry-run-approval" + private const val APPROVAL_AUTHORITY_REPOSITORY = "verdict/authorities/dry-run-approval" + private const val APPROVAL_POLICY_ID = "dry-run-approval-policy" + private const val APPROVER_ID = "operator" + + @Container + @JvmField + val dockerEnvironment = KeeperComposeFactory.newEnv() + } +} diff --git a/integration-tests/performance/build.gradle b/integration-tests/performance/build.gradle index 8b477161..e14ab373 100644 --- a/integration-tests/performance/build.gradle +++ b/integration-tests/performance/build.gradle @@ -6,7 +6,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/openapi.yaml b/openapi.yaml index d6843ecb..10f8636f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: TKeeper API - version: 2.4.0 + version: 2.4.1 description: | TKeeper public HTTP API. JSON request bodies reject unknown properties and are limited to 10 MiB by the HTTP server. @@ -16,6 +16,7 @@ tags: - name: Signing - name: TrustedDealer - name: Quorum + - name: DryRun - name: ECIES - name: Audit - name: Inventory @@ -360,6 +361,33 @@ paths: default: $ref: '#/components/responses/ErrorResponse' + /v1/keeper/emulate: + post: + tags: [DryRun] + operationId: emulateAuthorityCommand + summary: Evaluate an authority command without executing it + description: | + Requires the optional `dry-run` feature. Resolves the key authority and evaluates its policy + without signing, consuming approvals, or mutating key state. `ALLOW_WITH_REQUIREMENTS` + includes every four-eye approval requirement needed by the authority policy. + x-permissions: + required: [tkeeper.emulate] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Emulate' + responses: + '200': + description: Authority policy evaluation + content: + application/json: + schema: + $ref: '#/components/schemas/PolicyEvaluation' + default: + $ref: '#/components/responses/ErrorResponse' + /v1/keeper/ecies/encrypt: post: tags: [ECIES] @@ -819,6 +847,12 @@ components: responses: ErrorResponse: description: Error response + headers: + WWW-Authenticate: + description: Authentication challenge included on `401 Unauthorized` responses. + schema: + type: string + example: TKeeper realm="tkeeper" content: application/json: schema: @@ -1396,6 +1430,17 @@ components: approvals: $ref: '#/components/schemas/Approvals' + Emulate: + type: object + additionalProperties: false + description: Authority-policy evaluation request for the optional dry-run feature. + required: [keyId, command] + properties: + keyId: + $ref: '#/components/schemas/KeyId' + command: + $ref: '#/components/schemas/AuthorityCommand' + Verify: type: object additionalProperties: false @@ -1966,7 +2011,21 @@ components: approvers: type: object additionalProperties: - $ref: '#/components/schemas/ApproverPublicKey' + $ref: '#/components/schemas/PolicyApprover' + + PolicyApprover: + type: object + additionalProperties: false + required: [algorithm, publicKey64, metadata] + properties: + algorithm: + $ref: '#/components/schemas/KeyAlgorithm' + publicKey64: + $ref: '#/components/schemas/NonEmptyBase64' + metadata: + type: object + additionalProperties: + type: string RuleMatch: type: object diff --git a/platform-ecc/build.gradle b/platform-ecc/build.gradle index 5e6a011f..fef723ab 100644 --- a/platform-ecc/build.gradle +++ b/platform-ecc/build.gradle @@ -6,7 +6,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/platform-pqc/build.gradle b/platform-pqc/build.gradle index 04096a6b..20326351 100644 --- a/platform-pqc/build.gradle +++ b/platform-pqc/build.gradle @@ -6,7 +6,7 @@ plugins { } group = 'org.exploit' -version = '2.4.0' +version = '2.4.1' repositories { mavenCentral() diff --git a/sdk/README.md b/sdk/README.md index 1dd0e21e..d644ddad 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -10,14 +10,14 @@ Maven: org.exploit tkeeper-sdk - 2.4.0 + 2.4.1 ``` Gradle: ```groovy -implementation 'org.exploit:tkeeper-sdk:2.4.0' +implementation 'org.exploit:tkeeper-sdk:2.4.1' ``` ## Client @@ -104,6 +104,7 @@ The short `Generate` constructors that omit `KeySetAuthorities` default to `arbi | `destroy()` | generation destruction | | `consistency()` | threshold consistency repair | | `ecies()` | optional encrypt/decrypt feature | +| [`dryRun()`](../docs/signing-and-authorities/dry-run.md) | optional authority-policy evaluation without execution | | `compliance()` | asset inventory | | `expire()` | expiration indexes | | `audit()` and `integrity()` | audit verification and integrity-key rotation | diff --git a/sdk/build.gradle b/sdk/build.gradle index 4199b571..fe20c778 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -7,7 +7,7 @@ plugins { } group = 'org.exploit' -version = findProperty('releaseVersion') ?: '2.4.0' +version = findProperty('releaseVersion') ?: '2.4.1' def projectName = "TKeeper SDK" def projectDescription = "API client for TKeeper in Java" diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/TKeeperClient.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/TKeeperClient.java index 791940c7..a1e7bd71 100644 --- a/sdk/src/main/java/org/exploit/tkeeper/sdk/TKeeperClient.java +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/TKeeperClient.java @@ -28,6 +28,7 @@ public final class TKeeperClient implements Closeable { private final ExpireModule expire; private final QuorumModule quorum; private final RecoveryModule recovery; + private final DryRunModule dryRun; public TKeeperClient(Jettyx jettyx, String baseUrl, Authorization authorization) { this.jettyx = jettyx; @@ -48,6 +49,7 @@ public TKeeperClient(Jettyx jettyx, String baseUrl, Authorization authorization) this.expire = new ExpireModule(jettyx, baseUrl, auth); this.quorum = new QuorumModule(jettyx, baseUrl, auth); this.recovery = new RecoveryModule(jettyx, baseUrl, auth); + this.dryRun = new DryRunModule(jettyx, baseUrl, auth); } public TKeeperClient(String baseUrl, Authorization authorization) { @@ -119,6 +121,10 @@ public RecoveryModule recovery() { return recovery; } + public DryRunModule dryRun() { + return dryRun; + } + public Jettyx getJettyx() { return jettyx; } diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/Approver.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/Approver.java index fcc90603..47c7998e 100644 --- a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/Approver.java +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/Approver.java @@ -2,7 +2,18 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; + public record Approver( @JsonProperty("algorithm") String algorithm, - @JsonProperty("publicKey64") String publicKey64 -) {} + @JsonProperty("publicKey64") String publicKey64, + @JsonProperty("metadata") Map metadata +) { + public Approver { + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } + + public Approver(String algorithm, String publicKey64) { + this(algorithm, publicKey64, Map.of()); + } +} diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/Emulate.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/Emulate.java new file mode 100644 index 00000000..5b0a8029 --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/Emulate.java @@ -0,0 +1,32 @@ +package org.exploit.tkeeper.sdk.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.exploit.tkeeper.sdk.model.command.AuthorityCommand; +import org.exploit.tkeeper.sdk.model.command.Command; + +import java.util.Objects; + +public record Emulate( + @JsonProperty("keyId") String keyId, + @JsonProperty("command") AuthorityCommand command +) { + public Emulate { + Objects.requireNonNull(keyId, "keyId must not be null"); + Objects.requireNonNull(command, "command must not be null"); + } + + public Emulate(String keyId, Command command) { + this(keyId, requireAuthority(command)); + } + + public static Emulate of(String keyId, Command command) { + return new Emulate(keyId, command); + } + + private static AuthorityCommand requireAuthority(Command command) { + if (command instanceof AuthorityCommand authority) { + return authority; + } + throw new IllegalArgumentException("Emulate requires AuthorityCommand"); + } +} diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/modules/DryRunModule.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/modules/DryRunModule.java new file mode 100644 index 00000000..bf63c53d --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/modules/DryRunModule.java @@ -0,0 +1,41 @@ +package org.exploit.tkeeper.sdk.modules; + +import org.eclipse.jetty.http.HttpMethod; +import org.exploit.jettyx.Jettyx; +import org.exploit.jettyx.annotation.Body; +import org.exploit.jettyx.annotation.ContentType; +import org.exploit.jettyx.annotation.HttpRequest; +import org.exploit.jettyx.auth.Authorization; +import org.exploit.jettyx.model.HttpResponse; +import org.exploit.tkeeper.sdk.annotation.Description; +import org.exploit.tkeeper.sdk.model.Emulate; +import org.exploit.tkeeper.sdk.model.PolicyEvaluation; + +import static org.exploit.tkeeper.sdk.util.KeeperUtils.handleError; + +public final class DryRunModule { + private final DryRunApi api; + + private interface DryRunApi { + @Description("Evaluate an authority command without executing it") + @HttpRequest(method = HttpMethod.POST, path = "/v1/keeper/emulate") + @ContentType("application/json") + HttpResponse emulate(@Body Emulate request); + } + + public DryRunModule(Jettyx jettyx, String baseUrl, Authorization auth) { + this.api = jettyx.newApiClient(baseUrl, auth).create(DryRunApi.class); + } + + public PolicyEvaluation emulate(Emulate request) { + var response = api.emulate(request); + handleError(response); + if (response.exception() != null) { + throw new IllegalStateException("Failed to deserialize dry-run response", response.exception()); + } + if (response.body() == null) { + throw new IllegalStateException("Dry-run response body is missing"); + } + return response.body(); + } +} diff --git a/sdk/src/test/java/org/exploit/tkeeper/sdk/model/ModelWireTest.java b/sdk/src/test/java/org/exploit/tkeeper/sdk/model/ModelWireTest.java index 2e482dd0..8d3925fb 100644 --- a/sdk/src/test/java/org/exploit/tkeeper/sdk/model/ModelWireTest.java +++ b/sdk/src/test/java/org/exploit/tkeeper/sdk/model/ModelWireTest.java @@ -58,7 +58,11 @@ void readsApprovalPolicyAndOutcomeDetails() throws Exception { "source": "authority:test:payments", "threshold": 1, "approvers": { - "alice": {"algorithm": "ED25519", "publicKey64": "cHVi"} + "alice": { + "algorithm": "ED25519", + "publicKey64": "cHVi", + "metadata": {"team": "payments"} + } } }] }, @@ -76,6 +80,10 @@ void readsApprovalPolicyAndOutcomeDetails() throws Exception { assertEquals(PolicyVerdict.ALLOW_WITH_REQUIREMENTS, event.policy().decision()); assertEquals("alice", event.policy().approvalRequirements().get(0).approvers().keySet().iterator().next()); + assertEquals( + "payments", + event.policy().approvalRequirements().get(0).approvers().get("alice").metadata().get("team") + ); assertEquals("payments", event.outcome().approvals().get(0).policyId()); } @@ -102,6 +110,22 @@ void signRejectsAuthorityFreeCommand() { assertThrows(IllegalArgumentException.class, () -> Sign.of("key", verification)); } + @Test + void emulateKeepsAuthorityCommandAndRejectsAuthorityFreeCommand() throws Exception { + var command = Command.of( + "test:payments", + new ArbitraryData("ECDSA", HashMethod.SHA256, "cGF5bG9hZA==") + ); + var json = mapper.readTree(mapper.writeValueAsBytes(Emulate.of("key", command))); + + assertEquals("key", json.path("keyId").textValue()); + assertEquals("test:payments", json.path("command").path("authorityId").textValue()); + assertThrows( + IllegalArgumentException.class, + () -> Emulate.of("key", VerificationCommand.from(command)) + ); + } + @Test void destroyCarriesApprovalsAndBuildsCanonicalHash() throws Exception { var approvals = Approvals.template(1, "destroy-nonce", 1760000000000L); diff --git a/settings.gradle b/settings.gradle index 355768c5..b631c6c5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -25,3 +25,5 @@ include 'features:auth-dev' include 'features:recovery' include 'features:recovery:ecc' include 'features:recovery:pqc' + +include 'features:dry-run' diff --git a/src/main/kotlin/org/exploit/keeper/controller/support/ControllerSupport.kt b/src/main/kotlin/org/exploit/keeper/controller/support/ControllerSupport.kt index a0b32555..b30c1b94 100644 --- a/src/main/kotlin/org/exploit/keeper/controller/support/ControllerSupport.kt +++ b/src/main/kotlin/org/exploit/keeper/controller/support/ControllerSupport.kt @@ -435,11 +435,15 @@ class ControllerSupport( val bytes = codec.mapper.writeValueAsBytes(body) + val headers = ResponseHeaders.builder(HttpStatus.valueOf(error.statusCode)) + .contentType(codec.mediaType) + .set(HttpHeaderNames.CACHE_CONTROL, "no-store") + + if (error.statusCode == HttpStatus.UNAUTHORIZED.code()) + headers.set(HttpHeaderNames.WWW_AUTHENTICATE, "TKeeper realm=\"tkeeper\"") + return HttpResponse.of( - ResponseHeaders.builder(HttpStatus.valueOf(error.statusCode)) - .contentType(codec.mediaType) - .set(HttpHeaderNames.CACHE_CONTROL, "no-store") - .build(), + headers.build(), HttpData.copyOf(bytes) ) } diff --git a/src/main/kotlin/org/exploit/keeper/model/keyset/KeySetMeta.kt b/src/main/kotlin/org/exploit/keeper/model/keyset/KeySetMeta.kt index a2c79164..fd7428db 100644 --- a/src/main/kotlin/org/exploit/keeper/model/keyset/KeySetMeta.kt +++ b/src/main/kotlin/org/exploit/keeper/model/keyset/KeySetMeta.kt @@ -10,4 +10,6 @@ data class KeySetMeta( val policy: KeySetPolicy? = null, val destroyed: Boolean = false, val assetOwner: String? = null -) \ No newline at end of file +) { + fun findAuthority(id: String) = authorities.find { it.id == id } +} \ No newline at end of file diff --git a/src/main/kotlin/org/exploit/keeper/service/authority/KeyAuthorityCompiler.kt b/src/main/kotlin/org/exploit/keeper/service/authority/KeyAuthorityCompiler.kt new file mode 100644 index 00000000..54ba4b72 --- /dev/null +++ b/src/main/kotlin/org/exploit/keeper/service/authority/KeyAuthorityCompiler.kt @@ -0,0 +1,113 @@ +package org.exploit.keeper.service.authority + +import jakarta.inject.Singleton +import org.exploit.keeper.config.oras.OrasConfig +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.model.keyset.KeySetAuthority +import org.exploit.keeper.model.keyset.control.ExternalPublicKey +import org.exploit.keeper.service.command.intent.CommonIntentBuilder +import org.exploit.keeper.service.control.FourEyeControl.Companion.MAX_APPROVERS +import org.exploit.keeper.service.control.KeyAuthorityControl.KeyId +import org.exploit.keeper.service.control.validator.PublicKeyMaterialValidator +import org.exploit.verdict.PolicyEvaluator +import org.exploit.verdict.authority.loader.AuthorityLoader +import org.exploit.verdict.authority.model.CompiledAuthority +import org.exploit.verdict.authority.oci.OciAuthorityReference +import org.exploit.verdict.authority.oci.OrasOciArtifactClient +import org.exploit.verdict.exception.IntentValidationException +import org.exploit.verdict.exception.PolicyCompilationException +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +@Singleton +class KeyAuthorityCompiler( + private val evaluator: PolicyEvaluator, + private val oras: OrasConfig, + private val client: OrasOciArtifactClient, + private val loader: AuthorityLoader, + private val pubKeyValidator: PublicKeyMaterialValidator, + private val intentBuilder: CommonIntentBuilder +) { + private val loadedAuthorities: ConcurrentHashMap> = ConcurrentHashMap() + + fun invalidateOldGenerations(keyId: String, currentGeneration: Int) { + loadedAuthorities.keys.removeIf { + it.keyId == keyId && it.generation < currentGeneration + } + } + + fun compile(keyId: String, generation: Int, reference: KeySetAuthority): CompiledAuthority = + loadedAuthorities.computeIfAbsent(KeyId(keyId, generation)) { ConcurrentHashMap() } + .computeIfAbsent(reference.id) { + compile(reference.id, requireNotNull(reference.oci) { + "${reference.id} requires OCI reference" + }) + } + + fun compile(authorityId: String, oci: String): CompiledAuthority { + try { + val reference = OciAuthorityReference.parse(oci) + val registry = reference.containerRef() + .substringBefore('/') + .lowercase(Locale.ROOT) + + if (registry !in oras.allowedRegistries) + throw TKeeperException( + ErrorType.INVALID_AUTHORITY, + "OCI registry is not allowed: $registry" + ) + + val artifact = client.pull(reference) + val loaded = loader.load(artifact) + + if (loaded.authority.id != authorityId) + throw TKeeperException( + ErrorType.INVALID_AUTHORITY, + "Expected $authorityId but got ${loaded.authority.id}" + ) + + val authority = loaded.authority + val builder = intentBuilder.findBuilder(authority.type) + builder.validateConfig(authority.config) + + val policy = evaluator.compileStrict( + authority.policy, + builder.policySchema(authority.config) + ) + + return CompiledAuthority(authority, policy, loaded.artifact) + .also(::validateApprovalRequirements) + } catch (e: TKeeperException) { + throw e + } catch (e: IntentValidationException) { + throw TKeeperException(ErrorType.INVALID_AUTHORITY, e.reason(), e) + } catch (e: PolicyCompilationException) { + throw TKeeperException(ErrorType.INVALID_AUTHORITY, e.message ?: authorityId, e) + } catch (_: Exception) { + throw TKeeperException(ErrorType.INVALID_AUTHORITY, authorityId) + } + } + + private fun validateApprovalRequirements(compiled: CompiledAuthority) { + val policy = compiled.policy + val approvalPolicies = buildList { + policy.fallbackApprovals?.let(::add) + policy.allow.mapNotNullTo(this) { it.approvals } + } + + approvalPolicies.forEach { approval -> + if (approval.threshold > MAX_APPROVERS || approval.approvers.size > MAX_APPROVERS) + throw TKeeperException( + ErrorType.INVALID_AUTHORITY, + "Approval requirement exceeds max $MAX_APPROVERS approvers" + ) + } + + val keys = policy.approvers.values.map { + ExternalPublicKey(it.algorithm, it.publicKey64) + } + + pubKeyValidator.validateDistinct(keys, ErrorType.INVALID_AUTHORITY) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/exploit/keeper/service/authority/KeyAuthorityEvaluator.kt b/src/main/kotlin/org/exploit/keeper/service/authority/KeyAuthorityEvaluator.kt new file mode 100644 index 00000000..265015cb --- /dev/null +++ b/src/main/kotlin/org/exploit/keeper/service/authority/KeyAuthorityEvaluator.kt @@ -0,0 +1,67 @@ +package org.exploit.keeper.service.authority + +import jakarta.inject.Singleton +import org.exploit.keeper.constant.authority.OtherKeySetAuthorityId +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.keyset.KeySetAuthority +import org.exploit.keeper.service.command.intent.CommonIntentBuilder +import org.exploit.keeper.service.db.RocksKeyDB +import org.exploit.verdict.PolicyEvaluator +import org.exploit.verdict.model.evaluation.PolicyEvaluation + +@Singleton +class KeyAuthorityEvaluator( + private val db: RocksKeyDB, + private val compiler: KeyAuthorityCompiler, + private val intentBuilder: CommonIntentBuilder, + private val evaluator: PolicyEvaluator +) { + fun evaluate(keyId: String, gen: Int, command: AuthorityCommand): PolicyEvaluation { + val reference = validate(keyId, gen, command) + return evaluate(keyId, gen, command, reference) + } + + fun evaluate(keyId: String, gen: Int, command: AuthorityCommand, reference: KeySetAuthority): PolicyEvaluation { + if (reference.id == OtherKeySetAuthorityId.ARBITRARY) + throw TKeeperException(ErrorType.INVALID_AUTHORITY_TYPE, "Arbitrary authority can't be evaluated") + + val compiled = compiler.compile(keyId, gen, reference) + val intent = intentBuilder.build(command, compiled) + + return evaluator.evaluate(compiled.policy, intent) + } + + fun validate(keyId: String, generation: Int, command: AuthorityCommand): KeySetAuthority { + val reference = resolveReference(keyId, generation, command) + + val expectedType = + if (reference.id == OtherKeySetAuthorityId.ARBITRARY) + OtherKeySetAuthorityId.ARBITRARY + else + compiler.compile(keyId, generation, reference).authority.type + + if (command.artifact.type() != expectedType) + throw TKeeperException( + ErrorType.INVALID_AUTHORITY_ARTIFACT, + "Expected $expectedType but got ${command.artifact.type()}" + ) + + return reference + } + + private fun resolveReference(keyId: String, generation: Int, command: AuthorityCommand): KeySetAuthority { + val meta = db.meta.loadVersionedMeta(keyId, generation) + ?: throw TKeeperException( + ErrorType.AUTHORITY_VIOLATION, + "Refresh with allowed authority list" + ) + + return meta.findAuthority(command.authorityId) + ?: throw TKeeperException( + ErrorType.AUTHORITY_VIOLATION, + "${command.authorityId} is not allowed for $keyId" + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/exploit/keeper/service/command/intent/CommonIntentBuilder.kt b/src/main/kotlin/org/exploit/keeper/service/command/intent/CommonIntentBuilder.kt new file mode 100644 index 00000000..86be603e --- /dev/null +++ b/src/main/kotlin/org/exploit/keeper/service/command/intent/CommonIntentBuilder.kt @@ -0,0 +1,34 @@ +package org.exploit.keeper.service.command.intent + +import jakarta.inject.Singleton +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.model.command.AuthorityCommand +import org.exploit.verdict.authority.model.CompiledAuthority +import org.exploit.verdict.exception.IntentValidationException +import org.exploit.verdict.intent.Intent + +@Singleton +class CommonIntentBuilder(private val builders: List) { + fun findBuilder(type: String): IntentBuilder { + val matches= builders.filter { it.type() == type } + + if (matches.size != 1) + throw TKeeperException( + ErrorType.INVALID_AUTHORITY_ARTIFACT, + "Expected exactly one intent builder for '$type', found ${matches.size}" + ) + + return matches.single() + } + + fun build(command: AuthorityCommand, compiled: CompiledAuthority): Intent { + val builder = findBuilder(compiled.authority.type) + + return try { + builder.buildIntent(command, compiled) + } catch (e: IntentValidationException) { + throw TKeeperException(ErrorType.INVALID_INTENT, e.reason()) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/exploit/keeper/service/control/KeyAuthorityControl.kt b/src/main/kotlin/org/exploit/keeper/service/control/KeyAuthorityControl.kt index bcacb798..ee83b771 100644 --- a/src/main/kotlin/org/exploit/keeper/service/control/KeyAuthorityControl.kt +++ b/src/main/kotlin/org/exploit/keeper/service/control/KeyAuthorityControl.kt @@ -2,66 +2,38 @@ package org.exploit.keeper.service.control import com.linecorp.armeria.server.ServiceRequestContext import jakarta.inject.Singleton -import org.exploit.keeper.config.oras.OrasConfig import org.exploit.keeper.constant.authority.OtherKeySetAuthorityId 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.error.ErrorCode import org.exploit.keeper.model.keyset.KeySetAuthority import org.exploit.keeper.model.keyset.KeySetPolicy -import org.exploit.keeper.model.keyset.control.ExternalPublicKey -import org.exploit.keeper.service.command.intent.IntentBuilder -import org.exploit.keeper.service.control.FourEyeControl.Companion.MAX_APPROVERS -import org.exploit.keeper.service.control.validator.PublicKeyMaterialValidator +import org.exploit.keeper.service.authority.KeyAuthorityCompiler +import org.exploit.keeper.service.authority.KeyAuthorityEvaluator import org.exploit.keeper.service.db.RocksKeyDB import org.exploit.keeper.service.scope.AuditScope import org.exploit.keeper.util.validation.InputValidator -import org.exploit.verdict.PolicyEvaluator -import org.exploit.verdict.authority.loader.AuthorityLoader -import org.exploit.verdict.authority.model.CompiledAuthority -import org.exploit.verdict.authority.oci.OciAuthorityReference -import org.exploit.verdict.authority.oci.OrasOciArtifactClient import org.exploit.verdict.constant.Verdict -import org.exploit.verdict.exception.IntentValidationException -import org.exploit.verdict.exception.PolicyCompilationException -import org.exploit.verdict.intent.Intent -import java.util.* -import java.util.concurrent.ConcurrentHashMap @Singleton class KeyAuthorityControl( private val db: RocksKeyDB, - private val oras: OrasConfig, - private val client: OrasOciArtifactClient, - private val loader: AuthorityLoader, - private val evaluator: PolicyEvaluator, - private val intentBuilders: List, + private val evaluator: KeyAuthorityEvaluator, + private val compiler: KeyAuthorityCompiler, private val fourEye: FourEyeControl, - private val publicKeyMaterial: PublicKeyMaterialValidator ) { - private val loadedAuthorities: ConcurrentHashMap> = ConcurrentHashMap() - - fun invalidateOldGenerations(keyId: String, currentGeneration: Int) { - loadedAuthorities.keys.removeIf { - it.keyId == keyId && it.generation < currentGeneration - } - } - fun ensureAllowed(ctx: ServiceRequestContext, keyId: String, command: AuthorityCommand) { val latestGen = db.vault.getCurrentGeneration(keyId) if (latestGen < 1) throw TKeeperException(ErrorType.KEY_NOT_FOUND) - val reference = validate(keyId, latestGen, command) + val authority = evaluator.validate(keyId, latestGen, command) - if (reference.id == OtherKeySetAuthorityId.ARBITRARY) + if (authority.id == OtherKeySetAuthorityId.ARBITRARY) return - val compiled = compileCached(keyId, latestGen, reference) - val intent = buildIntent(command, compiled) - val evaluation = evaluator.evaluate(compiled.policy, intent) + val evaluation = evaluator.evaluate(keyId, latestGen, command, authority) AuditScope.currentOrNull()?.update { it.policy = evaluation @@ -75,24 +47,6 @@ class KeyAuthorityControl( } } - fun validate(keyId: String, generation: Int, command: AuthorityCommand): KeySetAuthority { - val reference = resolveReference(keyId, generation, command) - - val expectedType = - if (reference.id == OtherKeySetAuthorityId.ARBITRARY) - OtherKeySetAuthorityId.ARBITRARY - else - compileCached(keyId, generation, reference).authority.type - - if (command.artifact.type() != expectedType) - throw TKeeperException( - ErrorType.INVALID_AUTHORITY_ARTIFACT, - "Expected $expectedType but got ${command.artifact.type()}" - ) - - return reference - } - fun validateKeySetAuthorities(authorities: List) { if (authorities.isEmpty()) throw TKeeperException(ErrorType.INVALID_AUTHORITY, "Authorities can't be empty") @@ -138,7 +92,7 @@ class KeyAuthorityControl( } try { - compile(reference.id, oci) + compiler.compile(reference.id, oci) } catch (e: Exception) { val message = (e as? TKeeperException)?.details throw TKeeperException( @@ -155,114 +109,5 @@ class KeyAuthorityControl( validateKeySetAuthorities(authorities) } - private fun buildIntent(command: AuthorityCommand, compiled: CompiledAuthority): Intent { - val builder = intentBuilder(compiled.authority.type, ErrorType.INVALID_AUTHORITY_ARTIFACT) - - return try { - builder.buildIntent(command, compiled) - } catch (e: IntentValidationException) { - throw TKeeperException(ErrorType.INVALID_INTENT, e.reason()) - } - } - - private fun resolveReference(keyId: String, generation: Int, command: AuthorityCommand): KeySetAuthority { - val meta = db.meta.loadVersionedMeta(keyId, generation) - ?: throw TKeeperException( - ErrorType.AUTHORITY_VIOLATION, - "Refresh with allowed authority list" - ) - - return meta.authorities.find { it.id == command.authorityId } - ?: throw TKeeperException( - ErrorType.AUTHORITY_VIOLATION, - "${command.authorityId} is not allowed for $keyId" - ) - } - - private fun compileCached(keyId: String, generation: Int, reference: KeySetAuthority): CompiledAuthority = - loadedAuthorities - .computeIfAbsent(KeyId(keyId, generation)) { ConcurrentHashMap() } - .computeIfAbsent(reference.id) { - compile(reference.id, requireNotNull(reference.oci) { - "${reference.id} requires OCI reference" - }) - } - - private fun compile(authorityId: String, oci: String): CompiledAuthority { - try { - val reference = OciAuthorityReference.parse(oci) - val registry = reference.containerRef() - .substringBefore('/') - .lowercase(Locale.ROOT) - - if (registry !in oras.allowedRegistries) - throw TKeeperException( - ErrorType.INVALID_AUTHORITY, - "OCI registry is not allowed: $registry" - ) - - val artifact = client.pull(reference) - val loaded = loader.load(artifact) - - if (loaded.authority.id != authorityId) - throw TKeeperException( - ErrorType.INVALID_AUTHORITY, - "Expected $authorityId but got ${loaded.authority.id}" - ) - - val authority = loaded.authority - val builder = intentBuilder(authority.type, ErrorType.INVALID_AUTHORITY) - builder.validateConfig(authority.config) - - val policy = evaluator.compileStrict( - authority.policy, - builder.policySchema(authority.config) - ) - - return CompiledAuthority(authority, policy, loaded.artifact) - .also(::validateApprovalRequirements) - } catch (e: TKeeperException) { - throw e - } catch (e: IntentValidationException) { - throw TKeeperException(ErrorType.INVALID_AUTHORITY, e.reason(), e) - } catch (e: PolicyCompilationException) { - throw TKeeperException(ErrorType.INVALID_AUTHORITY, e.message ?: authorityId, e) - } catch (_: Exception) { - throw TKeeperException(ErrorType.INVALID_AUTHORITY, authorityId) - } - } - - private fun validateApprovalRequirements(compiled: CompiledAuthority) { - val policy = compiled.policy - val approvalPolicies = buildList { - policy.fallbackApprovals?.let(::add) - policy.allow.mapNotNullTo(this) { it.approvals } - } - - approvalPolicies.forEach { approval -> - if (approval.threshold > MAX_APPROVERS || approval.approvers.size > MAX_APPROVERS) - throw TKeeperException( - ErrorType.INVALID_AUTHORITY, - "Approval requirement exceeds max $MAX_APPROVERS approvers" - ) - } - - val keys = policy.approvers.values.map { - ExternalPublicKey(it.algorithm, it.publicKey64) - } - publicKeyMaterial.validateDistinct(keys, ErrorType.INVALID_AUTHORITY) - } - - private fun intentBuilder(type: String, errorType: ErrorCode): IntentBuilder { - val matches = intentBuilders.filter { it.type() == type } - if (matches.size != 1) - throw TKeeperException( - errorType, - "Expected exactly one intent builder for '$type', found ${matches.size}" - ) - - return matches.single() - } - data class KeyId(val keyId: String, val generation: Int) } diff --git a/src/main/kotlin/org/exploit/keeper/service/event/DKGEventListener.kt b/src/main/kotlin/org/exploit/keeper/service/event/DKGEventListener.kt index 47e34adb..5dc96870 100644 --- a/src/main/kotlin/org/exploit/keeper/service/event/DKGEventListener.kt +++ b/src/main/kotlin/org/exploit/keeper/service/event/DKGEventListener.kt @@ -3,16 +3,16 @@ package org.exploit.keeper.service.event import jakarta.annotation.PostConstruct import jakarta.inject.Singleton import org.exploit.keeper.model.event.DKGPromotedEvent -import org.exploit.keeper.service.control.KeyAuthorityControl +import org.exploit.keeper.service.authority.KeyAuthorityCompiler import org.exploit.signalix.annotations.EventHandler import org.exploit.signalix.manager.EventScope import org.exploit.signalix.marker.Listener @Singleton -class DKGEventListener(private val eventScope: EventScope, private val control: KeyAuthorityControl): Listener { +class DKGEventListener(private val eventScope: EventScope, private val compiler: KeyAuthorityCompiler): Listener { @EventHandler fun onKeyPromotedEvent(e: DKGPromotedEvent) { - control.invalidateOldGenerations(e.keyId, e.targetGeneration) + compiler.invalidateOldGenerations(e.keyId, e.targetGeneration) } @PostConstruct