diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86cb6425..2fdc957e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,14 +12,16 @@ Thanks for contributing to TKeeper. It's a security-critical, distributed system ## Reporting a bug (Issues) Before opening an issue: + - Search existing issues/discussions. - Test on the latest release (or `main` if you can). Include: + - Expected vs actual behavior - TKeeper version / commit SHA -- Minimal reproduction steps (copy-pasteable) -- Logs/stack traces (don't forget to remove secrets if present) +- Minimal reproduction steps +- Logs/stack traces - Environment (OS, JDK) If you can provide a minimal failing test, even better. @@ -27,17 +29,18 @@ If you can provide a minimal failing test, even better. ## Feature requests (Discussions → Issues) Start in Discussions with: + - The real problem you're solving - Proposed API/behavior - Compatibility notes (breaking vs non-breaking) - Any relevant standards/papers/RFCs -If the direction is agreed, convert to an issue. +If the direction is agreed, convert it to an issue. ## Testing expectations -- If your change affects behavior, **add or update an integration test**. See [integration tests](integration-tests) -- If you're not sure whether something is “behavioral”, assume it is and add the test. +- If your change affects behavior, **add or update an integration test**. See [integration tests](integration-tests). +- If you're not sure whether something is behavioral, assume it is and add the test. ## Code style @@ -48,18 +51,21 @@ If the direction is agreed, convert to an issue. ## Commit messages -Use clear messages. Conventional Commits are recommended: -- `fix: ...` -- `feat: ...` -- `chore(deps): ...` -- `test: ...` +Use short, clear commit messages that describe the change. Prefixes such as `feat:`, `fix:`, or `chore:` are not required. -If changes relate to Java SDK: -- `fix(sdk): ...` +Good examples: + +- `Fix Java SDK signature validation` +- `Add integration test for expired keys` +- `Update dependency versions` +- `Document key rotation behavior` + +Keep each commit focused on one logical change. ## Pull requests A PR should include: + - What changed (short summary) - Why it changed (rationale) - Tests added/updated (or why not) diff --git a/README.md b/README.md index a66be246..ae8c89bb 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Cryptographic implementations are selected at build time. A deployable artifact must include at least one platform. -Build all production features and platforms: +Build all default production features and platforms: ```bash ./gradlew :build -Pkeeper.features=all -Pkeeper.platforms=all @@ -120,8 +120,9 @@ See [Build and Features](docs/deployment/build-and-features.md). ## Security references -**Security assurance:** 346 automated scenarios, including 81 failure-injection -scenarios, run for every pull request and commit to `main`. See +**Security assurance:** 356 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. diff --git a/build.gradle b/build.gradle index 4cb60ab1..2116f241 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { def mainClassName = 'org.exploit.keeper.TKeeperKt' group = 'org.exploit' -version = '2.3.2' +version = '2.4.0' repositories { mavenCentral() @@ -38,7 +38,8 @@ def featureModules = [ ] def explicitFeatureModules = [ - 'features:auth-dev': 'keeper.feature.auth.dev' + 'features:auth-dev' : 'keeper.feature.auth.dev', + 'features:recovery' : 'keeper.feature.recovery', ] def testOnlyFeatureModules = ['integration-tests:failure-injection'] @@ -48,9 +49,15 @@ def platformModules = [ 'platform-pqc': 'keeper.platform.pqc' ] +def recoveryPlatformModules = [ + 'platform-ecc': 'features:recovery:ecc', + 'platform-pqc': 'features:recovery:pqc' +] + def shadowedRuntimeModules = featureModules.keySet() + explicitFeatureModules.keySet() + + recoveryPlatformModules.values() + testOnlyFeatureModules shadowedRuntimeModules.each { moduleName -> @@ -132,7 +139,13 @@ def enabledPlatformModules = platformModules.findAll { moduleName, propertyName truthy(findProperty(propertyName)) } -enabledFeatureModules.keySet().each { moduleName -> +def enabledRecoveryPlatformModules = enabledExplicitFeatureModules.containsKey('features:recovery') + ? recoveryPlatformModules.findAll { platformName, ignored -> + enabledPlatformModules.containsKey(platformName) + }.values() + : [] + +(enabledFeatureModules.keySet() + enabledExplicitFeatureModules.keySet()).each { moduleName -> featurePlatformRequirements.get(moduleName, []).each { requiredPlatform -> if (!enabledPlatformModules.containsKey(requiredPlatform)) { def featureName = moduleName.tokenize(':').last() @@ -218,6 +231,10 @@ dependencies { productionFeatureRuntime project(path: ":$moduleName", configuration: 'shadowRuntimeElements') } + enabledRecoveryPlatformModules.each { moduleName -> + productionFeatureRuntime project(path: ":$moduleName", configuration: 'shadowRuntimeElements') + } + enabledPlatformModules.keySet().each { moduleName -> productionFeatureRuntime project(path: ":$moduleName", configuration: 'shadowRuntimeElements') } @@ -230,6 +247,10 @@ dependencies { integrationFeatureRuntime project(path: ":$moduleName", configuration: 'shadowRuntimeElements') } + recoveryPlatformModules.values().each { moduleName -> + integrationFeatureRuntime project(path: ":$moduleName", configuration: 'shadowRuntimeElements') + } + testOnlyFeatureModules.each { moduleName -> integrationFeatureRuntime project(path: ":$moduleName", configuration: 'shadowRuntimeElements') } @@ -286,6 +307,7 @@ tasks.named('build') { dependsOn ':sdk:test' dependsOn enabledFeatureModules.keySet().collect { ":$it:test" } dependsOn enabledExplicitFeatureModules.keySet().collect { ":$it:test" } + dependsOn enabledRecoveryPlatformModules.collect { ":$it:test" } dependsOn enabledPlatformModules.keySet().collect { ":$it:test" } } @@ -353,7 +375,10 @@ tasks.register('shadowJarProductionTest', com.github.jengelman.gradle.plugins.sh doLast { def forbidden = [ 'auth-dev' : 'org/exploit/keeper/auth/dev/provider/DevAuthProvider.class', - 'failure-injection': 'org/exploit/keeper/module/failure/FailureInjectionController.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', + 'recovery-pqc' : 'org/exploit/keeper/module/recovery/pqc/service/PQCRecoveryService.class', ] def archive = archiveFile.get().asFile def zip = new java.util.zip.ZipFile(archive) @@ -378,7 +403,10 @@ tasks.register('artifactIsolationTest') { doLast { def markers = [ 'auth-dev' : 'org/exploit/keeper/auth/dev/provider/DevAuthProvider.class', - 'failure-injection': 'org/exploit/keeper/module/failure/FailureInjectionController.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', + 'recovery-pqc' : 'org/exploit/keeper/module/recovery/pqc/service/PQCRecoveryService.class', ] def productionJar = tasks.named('shadowJarProductionTest').get().archiveFile.get().asFile @@ -472,6 +500,7 @@ def releaseChecksBeforeContainers = [ ':integration-tests:test', featureModules.keySet().collect { ":$it:test" }, explicitFeatureModules.keySet().collect { ":$it:test" }, + recoveryPlatformModules.values().collect { ":$it:test" }, testOnlyFeatureModules.collect { ":$it:test" }, platformModules.keySet().collect { ":$it:test" }, tasks.named('artifactIsolationTest') @@ -500,7 +529,11 @@ tasks.register('securityFuzz') { dependsOn ':platform-ecc:fuzzSecurityParsers' dependsOn ':platform-ecc:fuzzProtocolStateMachines' + dependsOn ':platform-ecc:fuzzKeeperProtocolStateMachines' dependsOn ':platform-pqc:fuzzMLDSAStateMachine' + dependsOn ':platform-pqc:fuzzKeeperMLDSAProtocols' + dependsOn ':features:recovery:ecc:fuzzRecoveryProtocol' + dependsOn ':features:recovery:pqc:fuzzRecoveryPayloads' } test { diff --git a/docs/api-reference/permissions.md b/docs/api-reference/permissions.md index 26fa5971..a701362b 100644 --- a/docs/api-reference/permissions.md +++ b/docs/api-reference/permissions.md @@ -40,6 +40,7 @@ tkeeper.key.*.sign | `tkeeper.storage.write` | trusted-dealer import | | `tkeeper.quorum.promote` | promote mono to threshold | | `tkeeper.consistency.fix` | repair threshold state | +| `tkeeper.recover` | run share-recovery protocols | | `tkeeper.expired.view` | read key-expiration indexes | | `tkeeper.compliance.inventory` | read asset inventory | | `tkeeper.integrity.rotate` | rotate audit integrity key | @@ -52,7 +53,7 @@ tkeeper.key.*.sign - Give signing services only the key identities they need. - Keep lifecycle permissions out of normal signing clients. - Keep import permissions highly restricted. -- Restrict consistency repair and quorum promotion to recovery operators. +- Restrict consistency repair, share recovery, and quorum promotion to recovery operators. - Treat control-plane read permissions as sensitive operational metadata. - Keep destroy permissions separate from rotate and refresh. - Use negative permissions to carve high-risk identities out of broad grants. diff --git a/docs/deployment/README.md b/docs/deployment/README.md index 5e0d7d63..b7cd11c4 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -32,6 +32,7 @@ Mono and threshold use the same public API model. The difference is where key au For production: - build only required features +- include recovery only in temporary maintenance artifacts and replace them after repair - include every required platform explicitly - use a production authenticator - keep the internal API private to the cluster diff --git a/docs/deployment/backup-and-recovery.md b/docs/deployment/backup-and-recovery.md index 84f445bd..9a64ebd0 100644 --- a/docs/deployment/backup-and-recovery.md +++ b/docs/deployment/backup-and-recovery.md @@ -38,6 +38,153 @@ Restore a peer in an isolated environment before reconnecting it to the cluster. An older backup may represent a generation that the cluster has already rotated, refreshed, destroyed, or repaired. Do not run consistency fix as an automatic restore step; use it only when the operator can establish which quorum state is safe. +## Share recovery mode + +Share recovery is an explicit build capability. Select `recovery` and the platforms used by the +key histories that need repair. Gradle adds the corresponding platform recovery modules. + +ECC recovery artifact: + +```bash +./gradlew :build -Pkeeper.features=recovery -Pkeeper.platforms=ecc +``` + +ML-DSA recovery artifact: + +```bash +./gradlew :build -Pkeeper.features=recovery -Pkeeper.platforms=pqc +``` + +Artifact for both platforms: + +```bash +./gradlew :build -Pkeeper.features=recovery -Pkeeper.platforms=ecc,pqc +``` + +The `all` feature selector does not include recovery. Build recovery as a separate maintenance +artifact and run that artifact on every helper and target only for the repair window. + +### Preconditions + +- The configured quorum satisfies `1 < threshold < total`. +- The operator has selected a sorted list of exactly `threshold` healthy helper peer ids. +- The target peer id is not in the helper list. +- The helpers agree on the complete key history and have no pending state for the key. +- Public TLS, internal mTLS with client authentication, outbound client mTLS, and a distinct + `tls-spki-sha256` pin for every peer are configured. +- Initialization, database identity, integrity, TLS, and seal state are intact on the target. + +Legacy unversioned generation zero is outside the recovery boundary. + +### Run recovery + +1. Stop normal traffic to the participating keepers. +2. Deploy the recovery-capable artifact to the target and selected helpers. +3. Restart each participant with recovery mode enabled: + + ```bash + java -Dkeeper.recovery=true -jar build/libs/tkeeper-2.4.0.jar + ``` + +4. Unseal the keepers if required and confirm health and status. +5. Call the recovery endpoint on the damaged keeper with a principal that has + `tkeeper.recover`: + + ```bash + curl --fail-with-body \ + --cacert public-ca.crt \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"keyId":"signing-key","helperIds":[1,2,3]}' \ + https://keeper-4:8080/v1/keeper/recovery/recover + ``` + +6. Compare the returned algorithm, current generation, recovered generations, and helpers with + the expected state. Read every generation's public key while recovery mode is still enabled. +7. Repeat for each damaged logical key. +8. Rebuild the normal production artifact with the deployment's regular feature and platform + selectors, without `recovery`: + + ```bash + ./gradlew :build -Pkeeper.features= -Pkeeper.platforms= + ``` + +9. Replace the maintenance artifact on every participant and restart with + `keeper.recovery=false`. Do not return the recovery-capable artifact to normal service. +10. Verify historical inventory, current generation, public keys, owner and expiration indexes, + destroyed generations, and a normal signing operation. + +The endpoint requires the operator-provided helper list. It does not discover or replace helpers. +If a selected helper is missing or has conflicting key state, the request fails before target writes. + +### Rebuilt state + +The target first obtains an identical manifest from every selected helper. Only after validation +does it replace all local state for that logical key in one database transaction. The rebuild covers: + +- active key material for every non-destroyed generation; +- signed versioned metadata, policies, authorities, owner, and expiration data; +- the current-generation pointer and historical inventory; +- destroy markers; +- ECC commitments or ML-DSA public keys and recovery commitments; and +- derived owner and expiration indexes. + +Target-local pending, missing, stale, or conflicting records for the key are discarded. A failure +inside the replacement transaction rolls back the reset and install together. Prefix-adjacent key +ids are not part of the reset. + +If the helpers mark a generation destroyed, recovery restores its metadata and destroy marker but +does not install secret material. Recovery can rebuild a peer with no local records for the logical +key, provided the target database identity and other node-scoped state still exist. + +### Platform behavior + +ECC recovery uses exactly `threshold` remote helpers. Helpers exchange fresh pairwise masks and +return masked Lagrange contributions. The target validates each reconstructed share against the +generation's polynomial commitments. It works for any configured quorum with `1 < threshold < total`; +`threshold = total` cannot recover a missing share because only `total - 1` shares survive. + +ML-DSA helpers return only replicated RSS components that belong to the target. The target validates +each component against the signed per-subset commitments stored during DKG or dealer split. Legacy +ML-DSA generations without these commitments remain usable but return `RECOVERY_NOT_POSSIBLE`. +ML-DSA parameter changes between generations are supported. A history that crosses between ECC and +PQC returns `RECOVERY_NOT_POSSIBLE` without installing partial state. + +Recovery is limited to 64 historical generations per logical key. + +### Recovery mode boundary + +Recovery mode blocks normal key operations. The public API allows health, status, seal, unseal, +public-key reads, and recovery routes. The internal API allows health, integrity-public-key discovery, +and the selected platform's recovery routes. Helpers reject recovery calls while running in normal +mode. Startup fails if the artifact lacks the recovery feature or the required TLS and peer-pin +configuration. + +Recovery repairs keeper state. It does not determine whether the lost or modified share was exposed. +Rotate or reshare after recovery when required by the incident response decision. + +Disabling the runtime flag is not the final deployment state. The normal artifact must exclude the +recovery base module and both platform recovery implementations. + +### ECC mask boundary + +The mask construction hides an honest helper contribution from the target colluding with up to +`t - 2` helpers. A malicious helper can cause the final commitment check to fail; this version does +not include a zero-knowledge proof that attributes that denial of service. Secret scalar arithmetic +uses random quotient blinding, pair masks, and one secure modular reduction, but the bigint backend +is not a formally verified fixed-width constant-time field implementation. Co-resident timing and +microarchitectural attackers remain outside the protocol's threat model. + +Within one recovery session each helper caches its contribution, so retries return the same value +and do not create a second equation. Every new session uses independent CSPRNG pair masks. Reusing a +pair mask in a session with a different helper set (and therefore a different Lagrange coefficient) +would reveal that helper's share; the signed transcript binds the session id, target, canonical +helper set, algorithm, generation, commitments, metadata, and expiry, and rejects such reuse. + +The complete graph contains `t(t - 1) / 2` mask deliveries. The implementation bounds setup to 64 +concurrent requests; validate recovery latency at the deployment's largest supported `t` before +relying on the default session lifetime. + ## Recovery objectives Define and test separately: diff --git a/docs/deployment/build-and-features.md b/docs/deployment/build-and-features.md index 761c4236..5a169677 100644 --- a/docs/deployment/build-and-features.md +++ b/docs/deployment/build-and-features.md @@ -7,7 +7,7 @@ TKeeper has two build-time selectors: A usable production artifact must include at least one platform. -## Build all production modules +## Build default production modules ```bash ./gradlew build -Pkeeper.features=all -Pkeeper.platforms=all @@ -26,7 +26,7 @@ Equivalent: The jar lands under: ```text -build/libs/tkeeper-2.3.1.jar +build/libs/tkeeper-2.4.0.jar ``` TKeeper requires Java 25. @@ -55,6 +55,7 @@ Feature names match child project names. The module `:features:authority-evm` is | Bitcoin transaction authority | `authority-bitcoin` | `ecc` | | X.509 certificate authority | `authority-x509` | `ecc` | | ECIES | `ecies` | `ecc` | +| Peer share recovery | `recovery` (explicit opt-in) | `ecc`, `pqc`, or both | | Control-plane UI | `ui` | any required crypto platform | | AWS KMS seal provider | `seal-aws` | any required crypto platform | | Google Cloud KMS seal provider | `seal-gcloud` | any required crypto platform | @@ -64,6 +65,24 @@ Feature names match child project names. The module `:features:authority-evm` is Features with platform dependencies require the matching platform. The build should fail early instead of producing an artifact with a missing runtime provider. +Recovery is an explicit artifact capability. Selecting it adds the base recovery API and the +recovery module for each selected platform: + +```bash +./gradlew :build -Pkeeper.features=recovery -Pkeeper.platforms=ecc +./gradlew :build -Pkeeper.features=recovery -Pkeeper.platforms=pqc +./gradlew :build -Pkeeper.features=recovery -Pkeeper.platforms=ecc,pqc +``` + +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 +must be requested explicitly. + +Treat this as a maintenance artifact. After recovery, rebuild and redeploy the normal production +artifact without the `recovery` selector; setting `keeper.recovery=false` alone leaves the recovery +code and routes in the artifact. + `auth-dev` is deliberately excluded from `all`, but it may be included in any deployable artifact by requesting it explicitly: ```bash @@ -78,7 +97,9 @@ Features with platform dependencies require the matching platform. The build sho | Docker build | `keeper.docker.features` | `keeper.docker.platforms` | | 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 production module in that category. +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 +not included. ## Docker @@ -88,10 +109,18 @@ Build the production Docker image: ./gradlew dockerBuild -Pkeeper.features=all -Pkeeper.platforms=all ``` +Build a recovery image with both platform implementations: + +```bash +./gradlew dockerBuild \ + -Pkeeper.docker.features=recovery \ + -Pkeeper.docker.platforms=ecc,pqc +``` + Production image tags: ```text -exploit/tkeeper:2.3.1 +exploit/tkeeper:2.4.0 exploit/tkeeper:latest ``` @@ -110,7 +139,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.3.1 + exploit/tkeeper:2.4.0 ``` ## Integration image @@ -131,7 +160,10 @@ 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 production feature, `auth-dev`, every platform, and the test-only failure-injection module. +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 +failure-injection module. Never deploy either test image as production runtime. @@ -143,6 +175,12 @@ The feature was not included in the artifact. Rebuild with the required feature and platform. +For recovery, select the base feature and the required platforms: + +```bash +./gradlew :build -Pkeeper.features=recovery -Pkeeper.platforms=ecc,pqc +``` + ### No provider for algorithm The platform was not included in the artifact. diff --git a/docs/deployment/configuration.md b/docs/deployment/configuration.md index 6a84c730..cc7a7a93 100644 --- a/docs/deployment/configuration.md +++ b/docs/deployment/configuration.md @@ -93,6 +93,7 @@ Common fields: | `keeper.peers` | Other peers in the cluster; self is omitted | | `keeper.providers.selected` | Seal provider id | | `keeper.client.tls` | TLS for peer clients | +| `keeper.recovery` | Recovery-only runtime mode; defaults to `false` | | `keeper.approval.ttl` | Four-eye approval lifetime and persistent nonce replay-retention window | | `keeper.session.*` | DKG, FROST, GG20, ML-DSA, ECIES, destroy session limits | @@ -110,6 +111,16 @@ KEEPER_COORDINATOR_ENABLED=false Use that for peers that only participate in threshold protocols. +`keeper.recovery=true` is fail-closed maintenance mode. Build the artifact with +`-Pkeeper.features=recovery` and the required platform selectors. The mode requires the recovery +feature in the artifact, public TLS, internal mTLS with client authentication, outbound client mTLS, +and a distinct `tls-spki-sha256` binding for every peer. Normal key operations and readiness are +blocked until the keeper is restarted with recovery disabled. See +[Backup and Recovery](backup-and-recovery.md). + +After repair, replace the maintenance artifact with a production build that does not include the +`recovery` feature. Do not use the runtime flag as the only recovery deactivation step. + ## Server TLS TLS can use a keystore: diff --git a/docs/operations/integration-tests.md b/docs/operations/integration-tests.md index e896bc59..65967fe3 100644 --- a/docs/operations/integration-tests.md +++ b/docs/operations/integration-tests.md @@ -57,6 +57,28 @@ The development topology uses compose volumes for keeper-1 through keeper-3 so RocksDB and the keeper-2 SoftHSM token survive a container restart. Signing sessions remain process-local and must not survive it. +Run the keeper protocol-order, replay, and concurrent-transition cases with: + +```bash +./gradlew :integration-tests:functional:test \ + --tests 'org.exploit.test.functional.FailureInjectionTests.keeperProtocolStateRejectsReorderedReplayAndConcurrentTransitions*' +``` + +This sends invalid transitions through the internal peer transport for ECC DKG, PQC DKG, FROST, +GG20, and threshold ML-DSA signing. Replay and eight-way race cases require exactly one accepted +transition. A normal distributed signature follows every case. + +Run the 3-of-5 share-recovery scenario with: + +```bash +./gradlew :integration-tests:functional:test \ + --tests 'org.exploit.test.functional.RecoveryFailureInjectionTests' +``` + +The scenario starts a production-TLS cluster, creates ECC and ML-DSA histories, damages two peers in +different ways, restarts all five keepers in recovery mode, repairs both peers from three explicitly +selected healthy helpers, restarts in normal mode, and verifies state and signing. + Run the deterministic property and fuzz-seed regressions for security-sensitive binary formats with: @@ -77,15 +99,17 @@ Run the deterministic protocol-state properties and fuzz seeds with: ```bash ./gradlew :platform-ecc:test \ --tests 'org.exploit.keeper.platform.ecc.property.ProtocolStateMachineProperties' \ - --tests 'org.exploit.keeper.platform.ecc.fuzz.SecurityProtocolStateFuzzTest' + --tests 'org.exploit.keeper.platform.ecc.fuzz.SecurityProtocolStateFuzzTest' \ + --tests 'org.exploit.keeper.platform.ecc.fuzz.KeeperProtocolStateFuzzTest' ./gradlew :platform-pqc:test \ --tests 'org.exploit.keeper.platform.pqc.property.MLDSAStateMachineProperties' \ - --tests 'org.exploit.keeper.platform.pqc.fuzz.MLDSAStateMachineFuzzTest' + --tests 'org.exploit.keeper.platform.pqc.fuzz.MLDSAStateMachineFuzzTest' \ + --tests 'org.exploit.keeper.platform.pqc.fuzz.KeeperMLDSAProtocolStateFuzzTest' ./gradlew :test \ --tests 'org.exploit.keeper.tests.temporary.InMemoryTemporaryMapConcurrencyTest' ``` -Run the coverage-guided FROST/GG20 and ML-DSA state-machine campaigns with: +Run all coverage-guided security campaigns with: ```bash ./gradlew securityFuzz @@ -96,7 +120,11 @@ Override the time budget when running a longer local or scheduled campaign: ```bash ./gradlew :platform-ecc:fuzzSecurityParsers -Pkeeper.fuzz.duration=5m ./gradlew :platform-ecc:fuzzProtocolStateMachines -Pkeeper.fuzz.duration=5m +./gradlew :platform-ecc:fuzzKeeperProtocolStateMachines -Pkeeper.fuzz.duration=5m ./gradlew :platform-pqc:fuzzMLDSAStateMachine -Pkeeper.fuzz.duration=5m +./gradlew :platform-pqc:fuzzKeeperMLDSAProtocols -Pkeeper.fuzz.duration=5m +./gradlew :features:recovery:ecc:fuzzRecoveryProtocol -Pkeeper.fuzz.duration=5m +./gradlew :features:recovery:pqc:fuzzRecoveryPayloads -Pkeeper.fuzz.duration=5m ``` The generated `.cifuzz-corpus/` is local build state and is ignored. Minimize @@ -105,12 +133,15 @@ merging the fix. Do not pass `keeper.features` or `keeper.platforms` to `buildTestContainers`. The development integration artifact uses its own classpath and includes: -- every production feature +- every default production feature +- the explicit recovery feature and both recovery platform modules - development authentication - every platform - the test-only failure-injection module -The production transport test image uses the production UBI Dockerfile and excludes development authentication and failure injection. Regular `shadowJar` and `dockerBuild` also exclude failure injection. +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. See [`../../integration-tests/README.md`](../../integration-tests/README.md) for local requirements and Testcontainers setup. diff --git a/docs/overview/status-and-limitations.md b/docs/overview/status-and-limitations.md index 709b1bea..93e3160c 100644 --- a/docs/overview/status-and-limitations.md +++ b/docs/overview/status-and-limitations.md @@ -16,6 +16,7 @@ TKeeper currently provides: - build-time cryptographic platform selection - ECC and ML-DSA platform support - optional ECIES support when the ECIES feature is built +- optional ECC and ML-DSA peer-share recovery when the recovery feature is built ## Build-time module limits @@ -47,7 +48,10 @@ ML-DSA refresh advances the generation while carrying each peer's existing share Failure injection is only for integration tests. -The integration image includes every production feature, every platform, and the test-only failure-injection module. Regular production builds do not include failure injection. +The integration image includes every default production feature, the explicit recovery and +development-authentication features, every platform, both recovery platform modules, and the +test-only failure-injection module. Regular production builds do not include failure injection; +recovery is included only when selected. Do not deploy the integration image as a production runtime. diff --git a/docs/security-model/security-assurance.md b/docs/security-model/security-assurance.md index fc74827e..91ec1dfb 100644 --- a/docs/security-model/security-assurance.md +++ b/docs/security-model/security-assurance.md @@ -1,25 +1,25 @@ # Security Assurance -TKeeper security assurance currently comprises **346 automated scenarios**, -including **81 failure-injection scenarios**, executed against multi-node -Keeper deployments. +TKeeper security assurance currently comprises **356 automated functional scenarios across 17 test +classes**, including **90 protocol and corruption failure-injection scenarios** and one 3-of-5 +share-recovery scenario, executed against multi-node Keeper deployments. -The Testcontainers topology runs a 2-of-3 quorum with peer -communication, storage, SoftHSM, restarts, and malicious protocol injection. A -three-node production topology exercises TLS, mTLS, JWT/JWKS, peer -authentication, and SPKI pinning with per-run PKI. +The standard Testcontainers topology runs a 2-of-3 quorum with peer communication, storage, +SoftHSM, restarts, and malicious protocol injection. Production integration uses a three-node +transport cluster and a 3-of-5 recovery cluster with per-run PKI, TLS, mTLS, peer authentication, +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 all 346 scenarios, artifact -isolation, and both container builds. +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, +and both container builds. > **In short:** TKeeper tests cover production identity and transport, > authorization and four-eye policy, malicious coordinators and Byzantine > peers, FROST/GG20/ML-DSA transcript attacks, ECIES contribution integrity, > tamper-evident key state and lifecycle, generative and coverage-guided binary -> parser and protocol-state testing, concurrent duplicate delivery, crash-safe -> session cleanup, audit persistence, recovery after rejected contributions, -> and production artifact isolation. +> parser and protocol-state testing, invalid transition order, concurrent +> duplicate delivery, crash-safe session cleanup, audit persistence, ECC and +> ML-DSA share recovery, and production artifact isolation. Every claim below maps to an executable scenario, generated property, fuzz target, or release check. @@ -42,8 +42,8 @@ The tests demonstrate these properties: - **Tamper-evident state.** Signed metadata, location-bound key records, generation pointers, migrations, and audit records fail closed under the tested storage mutations. -- **Production build separation.** Development authentication and - failure-injection capabilities are verified absent from production artifacts. +- **Production build separation.** Development authentication, failure injection, and recovery are + verified absent from the default production test artifact. Recovery appears only when selected. ## Evidence quality @@ -51,20 +51,20 @@ The tests demonstrate these properties: | --- | --- | --- | | Distributed execution | Functional tests run multi-node Keeper clusters with key generation, shares, network calls, storage, and cryptographic implementations. | Exercises system boundaries and protocol composition across deployed components. | | Production transport topology | The production image runs with generated PKI, TLS, mTLS, certificate pins, JWT, JWKS rotation, and connection-rejection cases. | Exercises deployed authentication and transport failure modes. | -| Protocol failure injection | A test-only module introduces one security-relevant mutation at a time into FROST, GG20, ML-DSA, and ECIES flows. | Demonstrates rejection at the peer that consumes untrusted protocol data. | +| Protocol failure injection | A test-only module introduces one security-relevant mutation at a time into FROST, GG20, ML-DSA, ECIES, and keeper protocol transitions. | Demonstrates rejection at the peer that consumes untrusted protocol data. | | Failure contracts | Negative cases assert rejection reason and, where the protocol supports it, attribution of the malicious peer. | Detects regressions that crash or reject for the wrong reason. | -| Recovery checks | Every FROST and ML-DSA transcript-mutation case is followed by a distributed signature and verification. | Confirms that the stored key and cluster remain usable after rejection. | +| Recovery checks | Every FROST, ML-DSA, and keeper-transition mutation is followed by a distributed signature. A separate 3-of-5 topology rebuilds ECC and ML-DSA histories on two damaged peers. | Confirms continued key use after rejected protocol input and checks full key-scoped state reconstruction. | | Generative parser testing | Five serialization properties generate 2,500 cases per run with shrinking; a seeded Jazzer target coverage-guides malformed inputs through five security-sensitive binary decoders. | Checks round-trip, canonical encoding, record binding, key-kind preservation, bounded parsing, and controlled rejection beyond hand-written examples. | -| Stateful protocol modeling | Fifteen lifecycle and protocol-state properties exercise 7,350 generated participant topologies, action sequences, and concurrent schedules per run. Jazzer targets coverage-guide participant validation, FROST share, GG20 MtA, and ML-DSA round-store transitions. | Compares protocol state containers against explicit legal-transition, uniqueness, operation-isolation, order-independence, single-winner consumption, and destroy-state models. | -| Concurrency and crash recovery | Eight simultaneous signing-package deliveries race for one session id on each threshold protocol. Container-restart cases stop a keeper after FROST nonce generation, GG20 ephemeral initialization, or ML-DSA round 1. | Proves exactly one session creator wins, stale round state cannot resume after restart, durable key state survives, the same session id can be safely recreated, and a signature still verifies. | -| Release isolation | The release gate checks module tests, functional behavior, container builds, and separation of integration-only code from production artifacts. | Prevents the security test harness from becoming a production attack surface. | +| Stateful protocol modeling | Fifteen lifecycle and protocol-state properties exercise 7,350 generated participant topologies, action sequences, and concurrent schedules per run. Jazzer targets cover the protocol state containers used by ECC DKG, PQC DKG, FROST, GG20, threshold ML-DSA signing, and ECC/PQC recovery payload handling. | Compares state containers against legal-transition, uniqueness, operation-isolation, order-independence, single-winner consumption, destroy-state, transcript-binding, and recovery-payload models. | +| Concurrency and crash recovery | Eight simultaneous deliveries race for one signing session or one keeper protocol transition. Container-restart cases stop a keeper after FROST nonce generation, GG20 ephemeral initialization, or ML-DSA round 1. | Checks one-winner transitions, terminal session state, durable key state, safe session recreation, and post-failure signing. | +| Release isolation | The release gate checks module tests, functional behavior, container builds, and separation of integration-only and explicit recovery code from the default production artifact. | Prevents the security harness from entering production and prevents recovery endpoints from appearing unless selected. | ## Assurance by domain | Security domain | Demonstrated assurance | Detailed evidence | | --- | --- | --- | -| Threshold protocol integrity | Malformed signer sets, sequential or concurrent session replay, invalid proofs, transcript mutations, bad partial contributions, and consumed-state reuse fail closed across FROST, GG20, and threshold ML-DSA. Generated and concurrent action schedules additionally check the protocol state containers against explicit transition models. | [Threshold protocols](#threshold-protocols) | -| Byzantine tolerance and recovery | Invalid attributable FROST, GG20, and ECIES contributions identify the responsible peer; an honest quorum continues where the protocol permits retry. Mismatched ML-DSA quorum shares cannot produce a signature. | [Byzantine peers](#byzantine-peers-and-recovery) | +| Threshold protocol integrity | Malformed signer sets, invalid transition order, sequential or concurrent replay, invalid proofs, transcript mutations, bad partial contributions, and consumed-state reuse fail closed across ECC/PQC DKG, FROST, GG20, and threshold ML-DSA. Generated and concurrent action schedules additionally check the protocol state containers against explicit transition models. | [Threshold protocols](#threshold-protocols) | +| Byzantine tolerance and recovery | Invalid attributable FROST, GG20, and ECIES contributions identify the responsible peer; an honest quorum continues where the protocol permits retry. Mismatched ML-DSA shares cannot produce a signature. Operator-selected helper consensus reconstructs ECC and ML-DSA histories on damaged peers without restoring destroyed secret material. | [Byzantine peers](#byzantine-peers-and-recovery) | | Authentication and transport | Forged or malformed JWTs, missing permissions, TLS downgrade, unknown CAs, hostname mismatch, invalid client certificates, peer impersonation, and unsafe production configuration are rejected. | [Identity and transport](#identity-and-transport) | | Intent, policy, and approvals | Authority confusion, typed-intent mutation, policy deletion, incomplete approvals, approval substitution, replay, and nonce races cannot authorize a tested protected operation. | [Authorization and policy](#authorization-and-policy) | | Key state and lifecycle | Signed-record tampering, relocation, pointer rollback, unsafe migration, conflicting lifecycle mutations, and invalid promotion state fail closed or preserve the documented identity invariant. Security-sensitive binary records additionally have generative canonicality, binding, and malformed-input coverage. | [State and lifecycle](#state-lifecycle-and-audit) | @@ -93,6 +93,20 @@ failure contracts are listed in the [exact protocol vector catalog](#exact-proto ECIES adds participant-set validation, DLEQ verification of partial decryptions, and negative ciphertext and tweak coverage outside the 56 signing cases above. +Keeper protocol-order coverage adds seven failure-injection invocations: + +| State machine | Reordered transition | Replay | Eight-way race | Reported cases | +| --- | ---: | ---: | ---: | ---: | +| ECC DKG | 1 | 0 | 0 | 1 | +| PQC DKG | 0 | 1 | 1 | 2 | +| FROST signing | 1 | 0 | 0 | 1 | +| GG20 signing | 1 | 0 | 0 | 1 | +| Threshold ML-DSA signing | 0 | 1 | 1 | 2 | +| **Total** | **3** | **2** | **2** | **7** | + +These cases cross the internal peer transport. The race cases require one accepted transition and +seven rejected duplicates. Each case ends with a normal distributed signature. + ## Research lineage and interpretation | Source | Failure cases converted into executable regressions | @@ -131,15 +145,21 @@ Run the generated and coverage-guided protocol-state layer with: ```bash ./gradlew :platform-ecc:test \ --tests 'org.exploit.keeper.platform.ecc.property.ProtocolStateMachineProperties' \ - --tests 'org.exploit.keeper.platform.ecc.fuzz.SecurityProtocolStateFuzzTest' + --tests 'org.exploit.keeper.platform.ecc.fuzz.SecurityProtocolStateFuzzTest' \ + --tests 'org.exploit.keeper.platform.ecc.fuzz.KeeperProtocolStateFuzzTest' ./gradlew :platform-pqc:test \ --tests 'org.exploit.keeper.platform.pqc.property.MLDSAStateMachineProperties' \ - --tests 'org.exploit.keeper.platform.pqc.fuzz.MLDSAStateMachineFuzzTest' + --tests 'org.exploit.keeper.platform.pqc.fuzz.MLDSAStateMachineFuzzTest' \ + --tests 'org.exploit.keeper.platform.pqc.fuzz.KeeperMLDSAProtocolStateFuzzTest' ./gradlew :test \ --tests 'org.exploit.keeper.tests.temporary.InMemoryTemporaryMapConcurrencyTest' ./gradlew securityFuzz ``` +`securityFuzz` runs seven coverage-guided campaigns: binary decoding, the existing ECC and ML-DSA +state containers, keeper-level ECC and ML-DSA protocol transitions, ECC recovery sessions, and PQC +recovery payloads. + ## Deployment and operational boundaries The following conditions require deployment or operational controls outside @@ -175,6 +195,7 @@ whether that contract passes for a given artifact. | GG20 MtA and Paillier input (T-7) | Valid transcripts pass on both supported GG20 curves. Small, even, or oversized moduli, invalid generator, zero, non-coprime, or out-of-range ciphertexts, non-coprime biprime witnesses, and mutated or truncated range, biprime, and no-small-factor proof material produce an identifiable abort attributed to the initiator. | [FailureInjectionTests]: `validGg20MtATranscriptsPassOnSupportedCurves`, `gg20MtARejectsMaliciousInput` | | Threshold ML-DSA commit/reveal transcript (T-7) | A valid stored-key transcript passes. Changed or truncated commitments and reveals, duplicate, missing, or out-of-range round senders, commitment-opening mismatch, and reuse of consumed round state fail closed as independently reported cases. | [FailureInjectionTests]: `validMLDSASigningTranscriptPasses`, `mldsaSigningTranscriptRejectsMaliciousInput` | | Generated protocol-state transitions (T-7) | Valid threshold participant sets are order-independent; omission, duplication, wrong size, and out-of-range mutation fail with the expected contract. FROST nonce pairs and shares remain operation-scoped and one-shot, GG20 MtA values remain per-peer unique and order-independent, and ML-DSA batches and round stores match explicit transition and destruction models across generated action sequences. Concurrent nonce/share/MtA/round-state races have exactly one winner. Session-map close is terminal and revokes each remaining value once; the first concurrency run exposed and fixed post-close state resurrection. | [ProtocolStateMachineProperties]; [SecurityProtocolStateFuzzTest]; [MLDSAStateMachineProperties]; [MLDSAStateMachineFuzzTest]; [InMemoryTemporaryMapConcurrencyTest] | +| Keeper protocol transition order (T-7) | ECC DKG completion before computation, FROST signing before commitment collection, and GG20 signing before setup are rejected. PQC DKG and ML-DSA signing reject sequential round-1 replay; eight simultaneous round-1 deliveries produce one winner. Generated actions cover these production transition guards and failure rollback. | [FailureInjectionTests]: `keeperProtocolStateRejectsReorderedReplayAndConcurrentTransitions`; [KeeperProtocolStateFuzzTest]; [KeeperMLDSAProtocolStateFuzzTest] | | In-flight keeper crash (T-7) | A keeper is restarted after FROST nonce generation, GG20 ephemeral initialization, or ML-DSA round 1. Persistent RocksDB and HSM key state survive, but the process-local session cannot resume and returns `SESSION_NOT_FOUND`. The same session id can then be created and cleared safely, followed by a valid distributed signature. | [FailureInjectionTests]: `inFlightProtocolStateDoesNotSurviveKeeperRestart` | | Supported protocol paths | Threshold and mono signing and verification execute across GG20 ECDSA, FROST Schnorr/BIP-340/Taproot, threshold Ed25519, and ML-DSA-44/65/87, including supported tweak paths. ECIES executes with AES-GCM and ChaCha20-Poly1305 on secp256k1 and P-256. | [SignatureTests]: scheme-specific sign/verify tests; [ECIESTests]: `encryptDecryptSuccessful`, `encryptDecryptSuccessfulWithTweak`, `ensureDleqProofPassesAfterRefresh` | @@ -193,6 +214,7 @@ paths are covered in [SignatureTests]. | ML-DSA quorum share mismatch (T-7) | A quorum assembled from shares belonging to different keys cannot produce a signature. | [FailureInjectionTests]: `mismatchedMLDSAPartySharesCannotProduceSignature` | | Corrupted ECIES partial decrypt (T-8) | The DLEQ proof rejects the contribution, attributes the peer, and an honest threshold still decrypts. | [FailureInjectionTests]: `corruptedEciesKeyMaterialOnOnePeerIsRejectedByDleqProof` | | Malicious ECIES participant set (T-8) | Omitted local peer, duplicate identifier, undersized set, and configured-range violation are rejected before threshold decryption. | [FailureInjectionTests]: `maliciousCoordinatorCannotInjectInvalidEciesParticipantSet` | +| Damaged peer reconstruction (T-9, T-13) | In a production-TLS 3-of-5 cluster, two peers with complementary missing, rolled-back, and corrupted ECC/ML-DSA state are rebuilt from three explicit healthy helpers. Helper disagreement fails before writes. Recovery restores metadata, policy, authorities, public side-state, history, owner indexes, and active signing while destroyed generations remain without secret material. A forced mid-transaction failure rolls back, and a prefix-adjacent key remains unchanged. | [RecoveryFailureInjectionTests]: `recoversComplementaryDamageAndHistoryAcrossTwoPeers` | ### Identity and transport @@ -242,7 +264,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` | -| Failure-injection isolation | Development authentication and the failure-injection module are present in the integration artifact and absent from the production test artifact. | `./gradlew artifactIsolationTest` | +| 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` | | 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 @@ -312,6 +334,7 @@ produces and verifies a normal distributed signature with the same stored key. [CoordinatorDisabledTest]: https://github.com/tkeeper-org/tkeeper/blob/main/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/CoordinatorDisabledTest.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 [FourEyeControlTests]: https://github.com/tkeeper-org/tkeeper/blob/main/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/FourEyeControlTests.kt [InventoryIndexTest]: https://github.com/tkeeper-org/tkeeper/blob/main/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/InventoryIndexTest.kt [InMemoryTemporaryMapConcurrencyTest]: https://github.com/tkeeper-org/tkeeper/blob/main/src/test/kotlin/org/exploit/keeper/tests/temporary/InMemoryTemporaryMapConcurrencyTest.kt @@ -322,9 +345,11 @@ produces and verifies a normal distributed signature with the same stored key. [LegacyStorageUntrustedRootTests]: https://github.com/tkeeper-org/tkeeper/blob/main/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/LegacyStorageUntrustedRootTests.kt [MLDSAStateMachineFuzzTest]: https://github.com/tkeeper-org/tkeeper/blob/main/platform-pqc/src/test/kotlin/org/exploit/keeper/platform/pqc/fuzz/MLDSAStateMachineFuzzTest.kt [MLDSAStateMachineProperties]: https://github.com/tkeeper-org/tkeeper/blob/main/platform-pqc/src/test/kotlin/org/exploit/keeper/platform/pqc/property/MLDSAStateMachineProperties.kt +[KeeperMLDSAProtocolStateFuzzTest]: https://github.com/tkeeper-org/tkeeper/blob/main/platform-pqc/src/test/kotlin/org/exploit/keeper/platform/pqc/fuzz/KeeperMLDSAProtocolStateFuzzTest.kt [ProductionTransportSecurityTests]: https://github.com/tkeeper-org/tkeeper/blob/main/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/ProductionTransportSecurityTests.kt [QuorumPromotionTests]: https://github.com/tkeeper-org/tkeeper/blob/main/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/QuorumPromotionTests.kt [ProtocolStateMachineProperties]: https://github.com/tkeeper-org/tkeeper/blob/main/platform-ecc/src/test/kotlin/org/exploit/keeper/platform/ecc/property/ProtocolStateMachineProperties.kt +[KeeperProtocolStateFuzzTest]: https://github.com/tkeeper-org/tkeeper/blob/main/platform-ecc/src/test/kotlin/org/exploit/keeper/platform/ecc/fuzz/KeeperProtocolStateFuzzTest.kt [SecurityBinaryParserFuzzTest]: https://github.com/tkeeper-org/tkeeper/blob/main/platform-ecc/src/test/kotlin/org/exploit/keeper/platform/ecc/fuzz/SecurityBinaryParserFuzzTest.kt [SecurityProtocolStateFuzzTest]: https://github.com/tkeeper-org/tkeeper/blob/main/platform-ecc/src/test/kotlin/org/exploit/keeper/platform/ecc/fuzz/SecurityProtocolStateFuzzTest.kt [SecuritySerializationProperties]: https://github.com/tkeeper-org/tkeeper/blob/main/platform-ecc/src/test/kotlin/org/exploit/keeper/platform/ecc/property/SecuritySerializationProperties.kt diff --git a/features/auth-dev/build.gradle b/features/auth-dev/build.gradle index f46f076b..84519e64 100644 --- a/features/auth-dev/build.gradle +++ b/features/auth-dev/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.3.2' +version = '2.4.0' repositories { mavenCentral() diff --git a/features/auth-dev/src/main/kotlin/org/exploit/keeper/auth/dev/DevMachineAuthenticator.kt b/features/auth-dev/src/main/kotlin/org/exploit/keeper/auth/dev/DevMachineAuthenticator.kt index 4121e6d3..97b518e1 100644 --- a/features/auth-dev/src/main/kotlin/org/exploit/keeper/auth/dev/DevMachineAuthenticator.kt +++ b/features/auth-dev/src/main/kotlin/org/exploit/keeper/auth/dev/DevMachineAuthenticator.kt @@ -1,12 +1,13 @@ package org.exploit.keeper.auth.dev -import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.auth.dev.constant.DevModeErrorType import org.exploit.keeper.exception.TKeeperException import org.exploit.keeper.model.auth.AuthData import org.exploit.keeper.model.auth.SimpleAuthData import org.exploit.keeper.model.auth.plane.ControlPlaneAuthConfig import org.exploit.keeper.model.auth.plane.HeaderTokenAuthConfig import org.exploit.keeper.service.auth.external.TokenRequestAuthenticator +import java.security.MessageDigest internal class DevMachineAuthenticator( private val devToken: String, @@ -19,8 +20,11 @@ internal class DevMachineAuthenticator( override val requestHeader: String = DEV_TOKEN_HEADER override fun authenticate(token: String): AuthData { - if (devToken.isBlank() || token != devToken) - throw TKeeperException(ErrorType.DEV_MODE_VIOLATION, "Invalid dev mode token") + val tokenBytes = token.toByteArray() + val devTokenBytes = devToken.toByteArray() + + if (devTokenBytes.isEmpty() || !MessageDigest.isEqual(devTokenBytes, tokenBytes)) + throw TKeeperException(DevModeErrorType.DEV_MODE_VIOLATION, "Invalid dev mode token") return SimpleAuthData(SUBJECT, SUBJECT, permissions) } diff --git a/features/auth-dev/src/main/kotlin/org/exploit/keeper/auth/dev/constant/DevModeErrorType.kt b/features/auth-dev/src/main/kotlin/org/exploit/keeper/auth/dev/constant/DevModeErrorType.kt new file mode 100644 index 00000000..8a0e4285 --- /dev/null +++ b/features/auth-dev/src/main/kotlin/org/exploit/keeper/auth/dev/constant/DevModeErrorType.kt @@ -0,0 +1,7 @@ +package org.exploit.keeper.auth.dev.constant + +import org.exploit.keeper.model.error.ErrorCode + +object DevModeErrorType { + val DEV_MODE_VIOLATION = ErrorCode("DEV_MODE_VIOLATION", 403) +} \ No newline at end of file diff --git a/features/authority-bitcoin/build.gradle b/features/authority-bitcoin/build.gradle index ebff82c2..6ab15e50 100644 --- a/features/authority-bitcoin/build.gradle +++ b/features/authority-bitcoin/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.3.2' +version = '2.4.0' repositories { mavenCentral() diff --git a/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoUtils.kt b/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoUtils.kt index 6a8f28f6..5278b4fe 100644 --- a/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoUtils.kt +++ b/features/authority-bitcoin/src/main/kotlin/org/exploit/keeper/authority/bitcoin/util/BitcoinUtxoUtils.kt @@ -10,11 +10,12 @@ 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 java.util.* object BitcoinUtxoUtils { - fun parseTx(reason: ErrorType, tx64: String): Transaction { + fun parseTx(reason: ErrorCode, tx64: String): Transaction { val bytes = decodeBase64(tx64) val buffer = ByteBuffer.wrap(bytes) diff --git a/features/authority-evm/build.gradle b/features/authority-evm/build.gradle index 3539e62e..d2a18e46 100644 --- a/features/authority-evm/build.gradle +++ b/features/authority-evm/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.3.2' +version = '2.4.0' repositories { mavenCentral() diff --git a/features/authority-x509/build.gradle b/features/authority-x509/build.gradle index 0d1c28d0..f80df73a 100644 --- a/features/authority-x509/build.gradle +++ b/features/authority-x509/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.3.2' +version = '2.4.0' repositories { mavenCentral() diff --git a/features/ecies/build.gradle b/features/ecies/build.gradle index f304e3f8..6cab8345 100644 --- a/features/ecies/build.gradle +++ b/features/ecies/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.3.2' +version = '2.4.0' repositories { mavenCentral() diff --git a/features/ecies/src/main/kotlin/org/exploit/keeper/module/ecies/constant/ECIESPermission.kt b/features/ecies/src/main/kotlin/org/exploit/keeper/module/ecies/constant/ECIESPermission.kt new file mode 100644 index 00000000..63222d57 --- /dev/null +++ b/features/ecies/src/main/kotlin/org/exploit/keeper/module/ecies/constant/ECIESPermission.kt @@ -0,0 +1,10 @@ +package org.exploit.keeper.module.ecies.constant + +object ECIESPermission { + private const val KEY_ENCRYPT = "tkeeper.key.%s.encrypt" + private const val KEY_DECRYPT = "tkeeper.key.%s.decrypt" + + fun keyEncrypt(key: String): String = KEY_ENCRYPT.format(key) + + fun keyDecrypt(key: String): String = KEY_DECRYPT.format(key) +} \ No newline at end of file diff --git a/features/ecies/src/main/kotlin/org/exploit/keeper/module/ecies/controller/ECIESController.kt b/features/ecies/src/main/kotlin/org/exploit/keeper/module/ecies/controller/ECIESController.kt index 175e4970..892f0cba 100644 --- a/features/ecies/src/main/kotlin/org/exploit/keeper/module/ecies/controller/ECIESController.kt +++ b/features/ecies/src/main/kotlin/org/exploit/keeper/module/ecies/controller/ECIESController.kt @@ -3,16 +3,16 @@ package org.exploit.keeper.module.ecies.controller import com.linecorp.armeria.server.ServerBuilder import jakarta.inject.Singleton import org.exploit.keeper.constant.common.ErrorType -import org.exploit.keeper.constant.permission.Permission import org.exploit.keeper.controller.external.ExternalController import org.exploit.keeper.controller.support.ControllerSupport import org.exploit.keeper.exception.TKeeperException -import org.exploit.keeper.util.extension.crypto.decodeBase64 -import org.exploit.keeper.util.extension.http.hasPermission import org.exploit.keeper.model.approval.coordinator.FourEyeAttestation import org.exploit.keeper.module.ecies.ECIESManager +import org.exploit.keeper.module.ecies.constant.ECIESPermission import org.exploit.keeper.module.ecies.model.external.Decrypt import org.exploit.keeper.module.ecies.model.external.Encrypt +import org.exploit.keeper.util.extension.crypto.decodeBase64 +import org.exploit.keeper.util.extension.http.hasPermission import org.exploit.keeper.util.validation.InputValidator @Singleton @@ -22,7 +22,7 @@ class ECIESController(private val support: ControllerSupport, private val manage if (!InputValidator.isValidKeyId(body.keyId)) throw TKeeperException(ErrorType.INVALID_KEY_ID, body.keyId) - if (!ctx.hasPermission(Permission.keyEncrypt(body.keyId))) + if (!ctx.hasPermission(ECIESPermission.keyEncrypt(body.keyId))) throw TKeeperException(ErrorType.ACCESS_DENIED) manager.encrypt( @@ -37,7 +37,7 @@ class ECIESController(private val support: ControllerSupport, private val manage if (!InputValidator.isValidKeyId(body.keyId)) throw TKeeperException(ErrorType.INVALID_KEY_ID, body.keyId) - if (!ctx.hasPermission(Permission.keyDecrypt(body.keyId))) + if (!ctx.hasPermission(ECIESPermission.keyDecrypt(body.keyId))) throw TKeeperException(ErrorType.ACCESS_DENIED) manager.decrypt( diff --git a/features/ecies/src/main/kotlin/org/exploit/keeper/module/ecies/controller/InternalECIESController.kt b/features/ecies/src/main/kotlin/org/exploit/keeper/module/ecies/controller/InternalECIESController.kt index 0a624696..a6d4fbf4 100644 --- a/features/ecies/src/main/kotlin/org/exploit/keeper/module/ecies/controller/InternalECIESController.kt +++ b/features/ecies/src/main/kotlin/org/exploit/keeper/module/ecies/controller/InternalECIESController.kt @@ -3,11 +3,11 @@ package org.exploit.keeper.module.ecies.controller import com.linecorp.armeria.server.ServerBuilder import jakarta.inject.Singleton import org.exploit.keeper.constant.common.ErrorType -import org.exploit.keeper.constant.permission.Permission import org.exploit.keeper.controller.internal.InternalController import org.exploit.keeper.controller.support.ControllerSupport import org.exploit.keeper.exception.TKeeperException import org.exploit.keeper.module.ecies.ECIESManager +import org.exploit.keeper.module.ecies.constant.ECIESPermission import org.exploit.keeper.module.ecies.model.internal.PartialDecryptRequest import org.exploit.keeper.util.extension.http.hasPermission @@ -18,7 +18,7 @@ class InternalECIESController( ) : InternalController { override fun register(sb: ServerBuilder) = with(support) { sb.internalPost("/v1/ecies/decrypt", PartialDecryptRequest::class.java) { ctx, body -> - if (!ctx.hasPermission(Permission.keyDecrypt(body.keyId))) + if (!ctx.hasPermission(ECIESPermission.keyDecrypt(body.keyId))) throw TKeeperException(ErrorType.ACCESS_DENIED) manager.partialDecrypt( diff --git a/features/recovery/build.gradle b/features/recovery/build.gradle new file mode 100644 index 00000000..f99574f0 --- /dev/null +++ b/features/recovery/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.0' + +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/recovery/ecc/build.gradle b/features/recovery/ecc/build.gradle new file mode 100644 index 00000000..93c81942 --- /dev/null +++ b/features/recovery/ecc/build.gradle @@ -0,0 +1,64 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' + id 'com.gradleup.shadow' version '9.4.1' + id 'com.google.devtools.ksp' version '2.3.6' +} + +group = 'org.exploit' +version = '2.4.0' + +repositories { + mavenCentral() +} + +dependencies { + compileOnly(project(':')) + compileOnly(project(':features:recovery')) + compileOnly(project(':platform-ecc')) + testImplementation(project(':')) + testImplementation(project(':features:recovery')) + testImplementation(project(':platform-ecc')) + testImplementation 'org.jetbrains.kotlin:kotlin-test' + testImplementation 'org.junit.jupiter:junit-jupiter:5.14.3' + testImplementation 'io.kotest:kotest-property:6.1.11' + testImplementation 'com.code-intelligence:jazzer-junit:0.30.0' + + 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() +} + +tasks.register('fuzzRecoveryProtocol', Test) { + group = 'verification' + description = 'Runs coverage-guided action sequences against the ECC recovery helper state machine.' + outputs.upToDateWhen { false } + + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform() + filter { + includeTestsMatching 'org.exploit.keeper.module.recovery.ecc.fuzz.ECCRecoveryProtocolFuzzTest' + } + environment 'JAZZER_FUZZ', '1' + systemProperty 'jazzer.max_duration', providers.gradleProperty('keeper.fuzz.duration').getOrElse('30s') +} diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/api/ECCRecoveryApi.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/api/ECCRecoveryApi.kt new file mode 100644 index 00000000..9752ad15 --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/api/ECCRecoveryApi.kt @@ -0,0 +1,62 @@ +package org.exploit.keeper.module.recovery.ecc.api + +import org.eclipse.jetty.http.HttpMethod +import org.exploit.jettyx.annotation.Body +import org.exploit.jettyx.annotation.Header +import org.exploit.jettyx.annotation.HttpRequest +import org.exploit.jettyx.model.HttpResponse +import org.exploit.keeper.constant.media.MsgPack +import org.exploit.keeper.module.recovery.ecc.model.* +import org.exploit.keeper.module.recovery.ecc.model.contribution.ECCRecoveryContribution +import org.exploit.keeper.module.recovery.ecc.model.contribution.ECCRecoveryContributionRequest +import org.exploit.keeper.module.recovery.ecc.model.inventory.ECCRecoveryInventory +import org.exploit.keeper.module.recovery.ecc.model.inventory.ECCRecoveryInventoryRequest +import org.exploit.keeper.module.recovery.ecc.model.manifest.ECCRecoveryManifest +import org.exploit.keeper.module.recovery.ecc.model.manifest.ECCRecoveryManifestRequest +import org.exploit.keeper.module.recovery.ecc.model.mask.ECCRecoveryMaskCreate +import org.exploit.keeper.module.recovery.ecc.model.mask.ECCRecoveryMaskReceive +import java.util.concurrent.CompletableFuture + +interface ECCRecoveryApi { + @HttpRequest(path = "/v1/recovery/ecc/inventory", method = HttpMethod.POST) + fun inventory( + @Body request: ECCRecoveryInventoryRequest, + @Header("Content-Type") contentType: String = MsgPack.MIME_TEXT, + ): CompletableFuture> + + @HttpRequest(path = "/v1/recovery/ecc/manifest", method = HttpMethod.POST) + fun manifest( + @Body request: ECCRecoveryManifestRequest, + @Header("Content-Type") contentType: String = MsgPack.MIME_TEXT, + ): CompletableFuture> + + @HttpRequest(path = "/v1/recovery/ecc/prepare", method = HttpMethod.POST) + fun prepare( + @Body request: ECCRecoveryPrepare, + @Header("Content-Type") contentType: String = MsgPack.MIME_TEXT, + ): CompletableFuture> + + @HttpRequest(path = "/v1/recovery/ecc/mask/create", method = HttpMethod.POST) + fun createMask( + @Body request: ECCRecoveryMaskCreate, + @Header("Content-Type") contentType: String = MsgPack.MIME_TEXT, + ): CompletableFuture> + + @HttpRequest(path = "/v1/recovery/ecc/mask/receive", method = HttpMethod.POST) + fun receiveMask( + @Body request: ECCRecoveryMaskReceive, + @Header("Content-Type") contentType: String = MsgPack.MIME_TEXT, + ): CompletableFuture> + + @HttpRequest(path = "/v1/recovery/ecc/contribution", method = HttpMethod.POST) + fun contribution( + @Body request: ECCRecoveryContributionRequest, + @Header("Content-Type") contentType: String = MsgPack.MIME_TEXT, + ): CompletableFuture> + + @HttpRequest(path = "/v1/recovery/ecc/abort", method = HttpMethod.POST) + fun abort( + @Body request: ECCRecoverySessionRequest, + @Header("Content-Type") contentType: String = MsgPack.MIME_TEXT, + ): CompletableFuture> +} diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/controller/ECCRecoveryRoutes.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/controller/ECCRecoveryRoutes.kt new file mode 100644 index 00000000..36eba243 --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/controller/ECCRecoveryRoutes.kt @@ -0,0 +1,42 @@ +package org.exploit.keeper.module.recovery.ecc.controller + +import com.linecorp.armeria.common.HttpMethod +import jakarta.inject.Singleton +import org.exploit.keeper.controller.external.RecoveryController.Companion.RECOVER +import org.exploit.keeper.controller.internal.InternalRecoveryController.Companion.DESCRIBE +import org.exploit.keeper.model.route.RecoveryRoute +import org.exploit.keeper.recovery.route.RecoveryRouteProvider +import org.exploit.keeper.service.auth.internal.InternalRestrictedRouteProvider + +@Singleton +class ECCRecoveryRoutes : RecoveryRouteProvider, InternalRestrictedRouteProvider { + override fun publicRoutes(): Set = setOf( + RecoveryRoute(HttpMethod.POST, RECOVER), + ) + + override fun internalRoutes(): Set = (INTERNAL_PATHS + DESCRIBE).mapTo(mutableSetOf()) { + RecoveryRoute(HttpMethod.POST, it) + } + + override fun paths(): Set = INTERNAL_PATHS + DESCRIBE + + companion object { + const val INVENTORY = "/v1/recovery/ecc/inventory" + const val MANIFEST = "/v1/recovery/ecc/manifest" + const val PREPARE = "/v1/recovery/ecc/prepare" + const val MASK_CREATE = "/v1/recovery/ecc/mask/create" + const val MASK_RECEIVE = "/v1/recovery/ecc/mask/receive" + const val CONTRIBUTION = "/v1/recovery/ecc/contribution" + const val ABORT = "/v1/recovery/ecc/abort" + + val INTERNAL_PATHS = setOf( + INVENTORY, + MANIFEST, + PREPARE, + MASK_CREATE, + MASK_RECEIVE, + CONTRIBUTION, + ABORT, + ) + } +} diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/controller/InternalECCRecoveryController.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/controller/InternalECCRecoveryController.kt new file mode 100644 index 00000000..2b6e334c --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/controller/InternalECCRecoveryController.kt @@ -0,0 +1,66 @@ +package org.exploit.keeper.module.recovery.ecc.controller + +import com.linecorp.armeria.server.ServerBuilder +import com.linecorp.armeria.server.ServiceRequestContext +import jakarta.inject.Singleton +import org.exploit.keeper.constant.RecoveryPermission +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.controller.internal.InternalController +import org.exploit.keeper.controller.support.ControllerSupport +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.module.recovery.ecc.model.ECCRecoveryPrepare +import org.exploit.keeper.module.recovery.ecc.model.ECCRecoverySessionRequest +import org.exploit.keeper.module.recovery.ecc.model.contribution.ECCRecoveryContributionRequest +import org.exploit.keeper.module.recovery.ecc.model.inventory.ECCRecoveryInventoryRequest +import org.exploit.keeper.module.recovery.ecc.model.manifest.ECCRecoveryManifestRequest +import org.exploit.keeper.module.recovery.ecc.model.mask.ECCRecoveryMaskCreate +import org.exploit.keeper.module.recovery.ecc.model.mask.ECCRecoveryMaskReceive +import org.exploit.keeper.module.recovery.ecc.service.ECCRecoveryPeerService +import org.exploit.keeper.util.extension.http.authData +import org.exploit.keeper.util.extension.http.hasPermission + +@Singleton +class InternalECCRecoveryController( + private val support: ControllerSupport, + private val recovery: ECCRecoveryPeerService, +) : InternalController { + override fun register(sb: ServerBuilder) = with(support) { + sb.internalPost(ECCRecoveryRoutes.INVENTORY, ECCRecoveryInventoryRequest::class.java, expectQuorum = false) { ctx, body -> + authorize(ctx) + recovery.inventory(body, sourcePeerId(ctx)) + } + sb.internalPost(ECCRecoveryRoutes.MANIFEST, ECCRecoveryManifestRequest::class.java, expectQuorum = false) { ctx, body -> + authorize(ctx) + recovery.manifest(body, sourcePeerId(ctx)) + } + sb.internalPost(ECCRecoveryRoutes.PREPARE, ECCRecoveryPrepare::class.java, expectQuorum = false) { ctx, body -> + authorize(ctx) + recovery.prepare(body, sourcePeerId(ctx)) + } + sb.internalPost(ECCRecoveryRoutes.MASK_CREATE, ECCRecoveryMaskCreate::class.java, expectQuorum = false) { ctx, body -> + authorize(ctx) + recovery.createMask(body, sourcePeerId(ctx)) + } + sb.internalPost(ECCRecoveryRoutes.MASK_RECEIVE, ECCRecoveryMaskReceive::class.java, expectQuorum = false) { ctx, body -> + authorize(ctx) + recovery.receiveMask(body, sourcePeerId(ctx)) + } + sb.internalPost(ECCRecoveryRoutes.CONTRIBUTION, ECCRecoveryContributionRequest::class.java, expectQuorum = false) { ctx, body -> + authorize(ctx) + recovery.contribution(body, sourcePeerId(ctx)) + } + sb.internalPost(ECCRecoveryRoutes.ABORT, ECCRecoverySessionRequest::class.java, expectQuorum = false) { ctx, body -> + authorize(ctx) + recovery.abort(body, sourcePeerId(ctx)) + } + } + + private fun authorize(ctx: ServiceRequestContext) { + if (!ctx.hasPermission(RecoveryPermission.recover())) + throw TKeeperException(ErrorType.ACCESS_DENIED) + } + + private fun sourcePeerId(ctx: ServiceRequestContext): Int = + ctx.authData()?.subject?.removePrefix("keeper-")?.toIntOrNull() + ?: throw TKeeperException(ErrorType.ACCESS_DENIED) +} diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/ECCRecoveryAck.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/ECCRecoveryAck.kt new file mode 100644 index 00000000..3ca20c73 --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/ECCRecoveryAck.kt @@ -0,0 +1,3 @@ +package org.exploit.keeper.module.recovery.ecc.model + +data class ECCRecoveryAck(val sessionId: String) diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/ECCRecoveryGeneration.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/ECCRecoveryGeneration.kt new file mode 100644 index 00000000..54d2796d --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/ECCRecoveryGeneration.kt @@ -0,0 +1,10 @@ +package org.exploit.keeper.module.recovery.ecc.model + +import org.exploit.keeper.model.keyset.KeySetMeta + +data class ECCRecoveryGeneration( + val generation: Int, + val destroyed: Boolean, + val destroySessionId: String?, + val metadata: KeySetMeta, +) diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/ECCRecoveryPrepare.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/ECCRecoveryPrepare.kt new file mode 100644 index 00000000..62a4f2be --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/ECCRecoveryPrepare.kt @@ -0,0 +1,48 @@ +package org.exploit.keeper.module.recovery.ecc.model + +data class ECCRecoveryPrepare( + val sessionId: String, + val keyId: String, + val generation: Int, + val targetPeerId: Int, + val helperIds: List, + val algorithm: String, + val commitmentsDigest: ByteArray, + val metadataDigest: ByteArray, + val expiresAt: Long, + val transcriptHash: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ECCRecoveryPrepare + + if (generation != other.generation) return false + if (targetPeerId != other.targetPeerId) return false + if (expiresAt != other.expiresAt) return false + if (sessionId != other.sessionId) return false + if (keyId != other.keyId) return false + if (helperIds != other.helperIds) return false + if (algorithm != other.algorithm) return false + if (!commitmentsDigest.contentEquals(other.commitmentsDigest)) return false + if (!metadataDigest.contentEquals(other.metadataDigest)) return false + if (!transcriptHash.contentEquals(other.transcriptHash)) return false + + return true + } + + override fun hashCode(): Int { + var result = generation + result = 31 * result + targetPeerId + result = 31 * result + expiresAt.hashCode() + result = 31 * result + sessionId.hashCode() + result = 31 * result + keyId.hashCode() + result = 31 * result + helperIds.hashCode() + result = 31 * result + algorithm.hashCode() + result = 31 * result + commitmentsDigest.contentHashCode() + result = 31 * result + metadataDigest.contentHashCode() + result = 31 * result + transcriptHash.contentHashCode() + return result + } +} \ No newline at end of file diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/ECCRecoverySessionRequest.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/ECCRecoverySessionRequest.kt new file mode 100644 index 00000000..54dfc326 --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/ECCRecoverySessionRequest.kt @@ -0,0 +1,6 @@ +package org.exploit.keeper.module.recovery.ecc.model + +class ECCRecoverySessionRequest( + val sessionId: String, + val transcriptHash: ByteArray, +) \ No newline at end of file diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/contribution/ECCRecoveryContribution.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/contribution/ECCRecoveryContribution.kt new file mode 100644 index 00000000..eaebb284 --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/contribution/ECCRecoveryContribution.kt @@ -0,0 +1,8 @@ +package org.exploit.keeper.module.recovery.ecc.model.contribution + +class ECCRecoveryContribution( + val sessionId: String, + val transcriptHash: ByteArray, + val peerId: Int, + val value: ByteArray, +) \ No newline at end of file diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/contribution/ECCRecoveryContributionRequest.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/contribution/ECCRecoveryContributionRequest.kt new file mode 100644 index 00000000..c9967fd9 --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/contribution/ECCRecoveryContributionRequest.kt @@ -0,0 +1,6 @@ +package org.exploit.keeper.module.recovery.ecc.model.contribution + +class ECCRecoveryContributionRequest( + val sessionId: String, + val transcriptHash: ByteArray, +) \ No newline at end of file diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/inventory/ECCRecoveryInventory.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/inventory/ECCRecoveryInventory.kt new file mode 100644 index 00000000..85bffeef --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/inventory/ECCRecoveryInventory.kt @@ -0,0 +1,14 @@ +package org.exploit.keeper.module.recovery.ecc.model.inventory + +import org.exploit.keeper.module.recovery.ecc.model.ECCRecoveryGeneration + +data class ECCRecoveryInventory( + val peerId: Int, + val targetPeerId: Int, + val keyId: String, + val threshold: Int, + val total: Int, + val currentGeneration: Int, + val pendingGenerations: List, + val generations: List, +) \ No newline at end of file diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/inventory/ECCRecoveryInventoryRequest.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/inventory/ECCRecoveryInventoryRequest.kt new file mode 100644 index 00000000..89bba7bb --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/inventory/ECCRecoveryInventoryRequest.kt @@ -0,0 +1,6 @@ +package org.exploit.keeper.module.recovery.ecc.model.inventory + +data class ECCRecoveryInventoryRequest( + val keyId: String, + val targetPeerId: Int, +) \ No newline at end of file diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/manifest/ECCRecoveryManifest.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/manifest/ECCRecoveryManifest.kt new file mode 100644 index 00000000..c0fce57e --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/manifest/ECCRecoveryManifest.kt @@ -0,0 +1,16 @@ +package org.exploit.keeper.module.recovery.ecc.model.manifest + +import org.exploit.keeper.model.keyset.KeySetMeta +import org.exploit.keeper.platform.ecc.model.keygen.DkgCommitments + +data class ECCRecoveryManifest( + val peerId: Int, + val targetPeerId: Int, + val keyId: String, + val generation: Int, + val threshold: Int, + val total: Int, + val algorithm: String, + val metadata: KeySetMeta, + val commitments: DkgCommitments, +) \ No newline at end of file diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/manifest/ECCRecoveryManifestRequest.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/manifest/ECCRecoveryManifestRequest.kt new file mode 100644 index 00000000..82c28aab --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/manifest/ECCRecoveryManifestRequest.kt @@ -0,0 +1,7 @@ +package org.exploit.keeper.module.recovery.ecc.model.manifest + +data class ECCRecoveryManifestRequest( + val keyId: String, + val generation: Int, + val targetPeerId: Int, +) \ No newline at end of file diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/mask/ECCRecoveryMaskCreate.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/mask/ECCRecoveryMaskCreate.kt new file mode 100644 index 00000000..65e3e7f7 --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/mask/ECCRecoveryMaskCreate.kt @@ -0,0 +1,7 @@ +package org.exploit.keeper.module.recovery.ecc.model.mask + +class ECCRecoveryMaskCreate( + val sessionId: String, + val transcriptHash: ByteArray, + val recipientPeerId: Int, +) \ No newline at end of file diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/mask/ECCRecoveryMaskReceive.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/mask/ECCRecoveryMaskReceive.kt new file mode 100644 index 00000000..2e3c855c --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/model/mask/ECCRecoveryMaskReceive.kt @@ -0,0 +1,30 @@ +package org.exploit.keeper.module.recovery.ecc.model.mask + +data class ECCRecoveryMaskReceive( + val sessionId: String, + val transcriptHash: ByteArray, + val senderPeerId: Int, + val mask: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ECCRecoveryMaskReceive + + if (senderPeerId != other.senderPeerId) return false + if (sessionId != other.sessionId) return false + if (!transcriptHash.contentEquals(other.transcriptHash)) return false + if (!mask.contentEquals(other.mask)) return false + + return true + } + + override fun hashCode(): Int { + var result = senderPeerId + result = 31 * result + sessionId.hashCode() + result = 31 * result + transcriptHash.contentHashCode() + result = 31 * result + mask.contentHashCode() + return result + } +} \ No newline at end of file diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/service/ECCRecoveryPeerService.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/service/ECCRecoveryPeerService.kt new file mode 100644 index 00000000..69150ba6 --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/service/ECCRecoveryPeerService.kt @@ -0,0 +1,472 @@ +package org.exploit.keeper.module.recovery.ecc.service + +import jakarta.annotation.PreDestroy +import jakarta.inject.Singleton +import org.exploit.bigint.BigInt +import org.exploit.bigint.util.CSPRNG +import org.exploit.keeper.constant.RecoveryErrorType +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.module.recovery.ecc.api.ECCRecoveryApi +import org.exploit.keeper.module.recovery.ecc.model.ECCRecoveryAck +import org.exploit.keeper.module.recovery.ecc.model.ECCRecoveryGeneration +import org.exploit.keeper.module.recovery.ecc.model.ECCRecoveryPrepare +import org.exploit.keeper.module.recovery.ecc.model.ECCRecoverySessionRequest +import org.exploit.keeper.module.recovery.ecc.model.contribution.ECCRecoveryContribution +import org.exploit.keeper.module.recovery.ecc.model.contribution.ECCRecoveryContributionRequest +import org.exploit.keeper.module.recovery.ecc.model.inventory.ECCRecoveryInventory +import org.exploit.keeper.module.recovery.ecc.model.inventory.ECCRecoveryInventoryRequest +import org.exploit.keeper.module.recovery.ecc.model.manifest.ECCRecoveryManifest +import org.exploit.keeper.module.recovery.ecc.model.manifest.ECCRecoveryManifestRequest +import org.exploit.keeper.module.recovery.ecc.model.mask.ECCRecoveryMaskCreate +import org.exploit.keeper.module.recovery.ecc.model.mask.ECCRecoveryMaskReceive +import org.exploit.keeper.module.recovery.ecc.util.ECCRecoveryDigests +import org.exploit.keeper.module.recovery.ecc.util.ECCRecoveryMath +import org.exploit.keeper.module.recovery.ecc.util.ECCRecoveryTranscript +import org.exploit.keeper.platform.ecc.algorithm.ECKeyAlgorithmProvider +import org.exploit.keeper.platform.ecc.store.CommitStore +import org.exploit.keeper.recovery.RecoveryMode +import org.exploit.keeper.service.client.TKeeperClients +import org.exploit.keeper.service.db.RocksKeyDB +import org.exploit.keeper.service.db.store.KeyVersionStore +import org.exploit.keeper.service.key.KeyAlgorithms +import org.exploit.keeper.util.KeySerializer +import org.exploit.keeper.util.extension.crypto.toBigInt +import org.exploit.keeper.util.extension.succeed +import org.exploit.keeper.util.validation.InputValidator +import org.rocksdb.Transaction +import java.security.MessageDigest +import java.time.Instant +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +@Singleton +class ECCRecoveryPeerService( + private val mode: RecoveryMode, + private val db: RocksKeyDB, + private val algorithms: KeyAlgorithms, + private val clients: TKeeperClients, +) { + private val initData by lazy { db.readInitData() } + private val sessions = ConcurrentHashMap() + private val activeAssets = ConcurrentHashMap() + private val cleaner = Executors.newSingleThreadScheduledExecutor { task -> + Thread.ofPlatform().name("ecc-recovery-session-cleaner").daemon(true).unstarted(task) + }.also { + it.scheduleAtFixedRate(::purgeExpired, 30, 30, TimeUnit.SECONDS) + } + + fun inventory(request: ECCRecoveryInventoryRequest, sourcePeerId: Int): ECCRecoveryInventory { + mode.requireEnabled() + requireTarget(request.targetPeerId, sourcePeerId) + requireKeyId(request.keyId) + + return db.vault.ops.runInTransaction { tx -> + val versions = db.getStore(KeyVersionStore::class) + val active = versions.activeGenerations(tx, request.keyId).toSet() + + val pending = buildSet { + addAll(versions.pendingGenerations(tx, request.keyId)) + addAll(db.meta.pendingVersionedGenerations(tx, request.keyId)) + db.meta.loadPendingKeysetMeta(tx, request.keyId)?.let { add(it.currentGeneration) } + addAll(db.getStore(CommitStore::class).pendingGenerations(tx, request.keyId)) + }.sorted() + + val current = db.vault.readLatestVersion(tx, request.keyId).activeGen + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, request.keyId) + + val metadata = versionedMetadata(tx, request.keyId) + + if (metadata.isEmpty() || current !in metadata) + throw TKeeperException(ErrorType.CORRUPTED_KEY_STATE, "Missing versioned metadata") + + val generations = metadata.entries.sortedBy { it.key }.map { (generation, meta) -> + if (db.destroy.findActiveSessionId(tx, request.keyId, generation) != null) + throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "Destroy session is active") + val destroySessionId = db.destroy.committedSessionId(tx, request.keyId, generation) + if ((destroySessionId != null) != meta.destroyed) + throw TKeeperException(ErrorType.CORRUPTED_KEY_STATE, "Destroy state mismatch") + if ((!meta.destroyed) != (generation in active)) + throw TKeeperException(ErrorType.CORRUPTED_KEY_STATE, "Material state mismatch for generation $generation") + ECCRecoveryGeneration(generation, meta.destroyed, destroySessionId, meta) + } + + if (active != generations.filterNot { it.destroyed }.map { it.generation }.toSet()) + throw TKeeperException(ErrorType.CORRUPTED_KEY_STATE, "Active generation inventory mismatch") + + ECCRecoveryInventory( + peerId = initData.peerId, + targetPeerId = request.targetPeerId, + keyId = request.keyId, + threshold = initData.threshold, + total = initData.total, + currentGeneration = current, + pendingGenerations = pending, + generations = generations, + ) + } + } + + fun manifest(request: ECCRecoveryManifestRequest, sourcePeerId: Int): ECCRecoveryManifest { + mode.requireEnabled() + requireTarget(request.targetPeerId, sourcePeerId) + requireKeyId(request.keyId) + if (request.generation <= 0) + throw TKeeperException(ErrorType.INVALID_KEY_GENERATION) + + return db.vault.ops.runInTransaction { tx -> + if (db.destroy.isCommitted(tx, request.keyId, request.generation)) + throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "Generation was destroyed") + + val raw = db.vault.readKey(tx, request.keyId, request.generation) + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, "${request.keyId}/${request.generation}") + val algorithm = try { + algorithms.findOrThrow(KeySerializer.extractAlgId(raw)) + } finally { + raw.fill(0) + } + if (algorithm !is ECKeyAlgorithmProvider<*, *, *, *>) + throw TKeeperException(ErrorType.UNSUPPORTED_KEY_ALGORITHM) + + val metadata = db.meta.loadVersionedMeta(tx, request.keyId, request.generation) + ?: throw TKeeperException(ErrorType.CORRUPTED_KEY_STATE, "Missing versioned metadata") + val commitments = db.getStore(CommitStore::class).readActive(tx, request.keyId, request.generation) + ?: throw TKeeperException(ErrorType.CORRUPTED_KEY_STATE, "Missing ECC commitments") + + ECCRecoveryManifest( + peerId = initData.peerId, + targetPeerId = request.targetPeerId, + keyId = request.keyId, + generation = request.generation, + threshold = initData.threshold, + total = initData.total, + algorithm = algorithm.name(), + metadata = metadata, + commitments = commitments, + ) + } + } + + @Synchronized + fun prepare(request: ECCRecoveryPrepare, sourcePeerId: Int): ECCRecoveryAck { + mode.requireEnabled() + requireTarget(request.targetPeerId, sourcePeerId) + validatePrepare(request) + purgeExpired() + + val existing = sessions[request.sessionId] + if (existing != null) { + if (!MessageDigest.isEqual(existing.prepare.transcriptHash, request.transcriptHash)) + throw TKeeperException(ErrorType.SESSION_ALREADY_EXISTS) + return ECCRecoveryAck(request.sessionId) + } + + val asset = Asset(request.keyId, request.generation) + val active = activeAssets.putIfAbsent(asset, request.sessionId) + if (active != null) + throw TKeeperException(ErrorType.SESSION_ALREADY_EXISTS, active) + + try { + val local = manifest( + ECCRecoveryManifestRequest(request.keyId, request.generation, request.targetPeerId), + sourcePeerId, + ) + if (!local.algorithm.equals(request.algorithm, ignoreCase = true) || + !MessageDigest.isEqual(ECCRecoveryDigests.commitments(local.commitments), request.commitmentsDigest) || + !MessageDigest.isEqual(ECCRecoveryDigests.metadata(local.metadata), request.metadataDigest) + ) throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Recovery manifest changed") + + sessions[request.sessionId] = HelperSession( + request.copy( + helperIds = request.helperIds.toList(), + commitmentsDigest = request.commitmentsDigest.copyOf(), + metadataDigest = request.metadataDigest.copyOf(), + transcriptHash = request.transcriptHash.copyOf(), + ) + ) + return ECCRecoveryAck(request.sessionId) + } catch (t: Throwable) { + activeAssets.remove(asset, request.sessionId) + throw t + } + } + + fun createMask(request: ECCRecoveryMaskCreate, sourcePeerId: Int): ECCRecoveryAck { + mode.requireEnabled() + val session = session(request.sessionId, request.transcriptHash) + requireTarget(session.prepare.targetPeerId, sourcePeerId) + val localId = initData.peerId + val recipient = request.recipientPeerId + if (recipient !in session.prepare.helperIds || localId >= recipient) + throw TKeeperException(ErrorType.INVALID_PEER_ID, "Invalid mask pair $localId->$recipient") + + val mask = session.outgoingMask(recipient) { + val algorithm = curve(session.prepare.algorithm) + CSPRNG.randomBelow(algorithm.params().curveOrder) + } + val raw = try { + mask.toUnsignedByteArray(curve(session.prepare.algorithm).factory().privateKeySize()) + } finally { + mask.destroy() + } + + try { + val peer = clients.find(recipient) + ?: throw TKeeperException(ErrorType.INVALID_PEER_ID, "$recipient") + peer.internalClient(ECCRecoveryApi::class.java).receiveMask( + ECCRecoveryMaskReceive( + sessionId = request.sessionId, + transcriptHash = request.transcriptHash, + senderPeerId = localId, + mask = raw, + ) + ).succeed().join() + return ECCRecoveryAck(request.sessionId) + } finally { + raw.fill(0) + } + } + + fun receiveMask(request: ECCRecoveryMaskReceive, sourcePeerId: Int): ECCRecoveryAck { + mode.requireEnabled() + if (request.senderPeerId != sourcePeerId) + throw TKeeperException(ErrorType.ACCESS_DENIED) + + val session = session(request.sessionId, request.transcriptHash) + val localId = initData.peerId + if (sourcePeerId !in session.prepare.helperIds || sourcePeerId >= localId) + throw TKeeperException(ErrorType.INVALID_PEER_ID, "Invalid mask pair $sourcePeerId->$localId") + + val algorithm = curve(session.prepare.algorithm) + val mask = try { + ECCRecoveryMath.decodeScalar(request.mask, algorithm.factory().privateKeySize(), algorithm.params().curveOrder) + } catch (e: IllegalArgumentException) { + throw TKeeperException(ErrorType.INVALID_SHARE, "Invalid recovery mask", e) + } finally { + request.mask.fill(0) + } + try { + session.receiveMask(sourcePeerId, mask, algorithm.factory().privateKeySize()) + } finally { + mask.destroy() + } + return ECCRecoveryAck(request.sessionId) + } + + fun contribution(request: ECCRecoveryContributionRequest, sourcePeerId: Int): ECCRecoveryContribution { + mode.requireEnabled() + val session = session(request.sessionId, request.transcriptHash) + requireTarget(session.prepare.targetPeerId, sourcePeerId) + + val raw = session.contribution ?: computeContribution(session).also { session.storeContribution(it) } + return try { + ECCRecoveryContribution( + sessionId = request.sessionId, + transcriptHash = request.transcriptHash.copyOf(), + peerId = initData.peerId, + value = raw.copyOf(), + ) + } finally { + raw.fill(0) + } + } + + fun abort(request: ECCRecoverySessionRequest, sourcePeerId: Int): ECCRecoveryAck { + mode.requireEnabled() + val session = sessions[request.sessionId] ?: return ECCRecoveryAck(request.sessionId) + requireTranscript(session, request.transcriptHash) + requireTarget(session.prepare.targetPeerId, sourcePeerId) + remove(session) + return ECCRecoveryAck(request.sessionId) + } + + private fun computeContribution(session: HelperSession): ByteArray { + val prepare = session.prepare + val masks = session.masksSnapshot() + val expectedPeers = prepare.helperIds.filter { it != initData.peerId }.toSet() + if (masks.keys != expectedPeers) + throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Recovery masks are incomplete") + + val algorithm = curve(prepare.algorithm) + val raw = db.vault.readKey(prepare.keyId, prepare.generation) + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, "${prepare.keyId}/${prepare.generation}") + val stored = try { + KeySerializer.deserialize(raw) + } finally { + raw.fill(0) + } + + try { + if (stored.algId != algorithm.id()) + throw TKeeperException(ErrorType.KEY_ALGORITHM_MISMATCH) + val share = stored.rawKey.toBigInt() + val coefficient = ECCRecoveryMath.lagrangeCoefficient( + initData.peerId, + prepare.targetPeerId, + prepare.helperIds, + algorithm.params().curveOrder, + ) + try { + val contribution = ECCRecoveryMath.maskedContribution( + share = share, + coefficient = coefficient, + outgoingMasks = masks.filterKeys { it > initData.peerId }.values, + incomingMasks = masks.filterKeys { it < initData.peerId }.values, + order = algorithm.params().curveOrder, + ) + return try { + contribution.toUnsignedByteArray(algorithm.factory().privateKeySize()) + } finally { + contribution.destroy() + } + } finally { + share.destroy() + coefficient.destroy() + masks.values.forEach(BigInt::destroy) + } + } finally { + stored.destroy() + } + } + + private fun validatePrepare(request: ECCRecoveryPrepare) { + requireKeyId(request.keyId) + runCatching { UUID.fromString(request.sessionId) } + .getOrElse { throw TKeeperException(ErrorType.INVALID_SESSION_ID) } + if (request.generation <= 0) + throw TKeeperException(ErrorType.INVALID_KEY_GENERATION) + + if (request.targetPeerId !in 1..initData.total || request.targetPeerId in request.helperIds) + throw TKeeperException(ErrorType.INVALID_PEER_ID) + + if (initData.threshold <= 1 || initData.threshold >= initData.total) + throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "Recovery requires 1 < t < n") + + if (request.helperIds != request.helperIds.sorted() || + request.helperIds.distinct().size != initData.threshold || + request.helperIds.size != initData.threshold || + request.helperIds.any { it !in 1..initData.total } || + initData.peerId !in request.helperIds + ) throw TKeeperException(ErrorType.INVALID_THRESHOLD, "Expected exactly ${initData.threshold} canonical helpers") + + val now = Instant.now().epochSecond + + if (request.expiresAt <= now || request.expiresAt > now + MAX_SESSION_SECONDS) + throw TKeeperException(ErrorType.INVALID_EXPIRATION_TIME) + + if (request.commitmentsDigest.size != 32 || request.metadataDigest.size != 32 || + request.transcriptHash.size != 32 || !ECCRecoveryTranscript.matches(request) + ) + throw TKeeperException(ErrorType.INVALID_PAYLOAD, "Invalid recovery transcript") + } + + private fun session(sessionId: String, transcriptHash: ByteArray): HelperSession { + purgeExpired() + val session = sessions[sessionId] ?: throw TKeeperException(ErrorType.SESSION_NOT_FOUND, sessionId) + + requireTranscript(session, transcriptHash) + return session + } + + private fun requireTranscript(session: HelperSession, hash: ByteArray) { + if (!MessageDigest.isEqual(session.prepare.transcriptHash, hash)) + throw TKeeperException(ErrorType.INVALID_PAYLOAD, "Recovery transcript mismatch") + } + + private fun requireTarget(targetPeerId: Int, sourcePeerId: Int) { + if (targetPeerId != sourcePeerId) + throw TKeeperException(ErrorType.ACCESS_DENIED, "Recovery target must be the authenticated peer") + } + + private fun requireKeyId(keyId: String) { + if (!InputValidator.isValidKeyId(keyId)) + throw TKeeperException(ErrorType.INVALID_KEY_ID) + } + + private fun curve(name: String): ECKeyAlgorithmProvider<*, *, *, *> = + algorithms.findOrThrow(name) as? ECKeyAlgorithmProvider<*, *, *, *> + ?: throw TKeeperException(ErrorType.UNSUPPORTED_KEY_ALGORITHM) + + private fun versionedMetadata(tx: Transaction, keyId: String) = + db.meta.loadAllVersionedMeta(tx, keyId) + + @Synchronized + private fun purgeExpired() { + val now = Instant.now().epochSecond + sessions.values.filter { it.prepare.expiresAt <= now }.forEach(::remove) + } + + @Synchronized + private fun remove(session: HelperSession) { + sessions.remove(session.prepare.sessionId, session) + activeAssets.remove(Asset(session.prepare.keyId, session.prepare.generation), session.prepare.sessionId) + session.destroy() + } + + @PreDestroy + fun close() { + cleaner.shutdownNow() + sessions.values.forEach(HelperSession::destroy) + sessions.clear() + activeAssets.clear() + } + + private data class Asset(val keyId: String, val generation: Int) + + internal class HelperSession(val prepare: ECCRecoveryPrepare) { + private val masks = HashMap() + private var contributionBytes: ByteArray? = null + + val contribution: ByteArray? + @Synchronized get() = contributionBytes?.copyOf() + + @Synchronized + fun outgoingMask(peerId: Int, create: () -> BigInt): BigInt { + masks[peerId]?.let { return it.copy() } + check(contributionBytes == null) { "Contribution already created" } + val value = create().also { masks[peerId] = it } + return value.copy() + } + + @Synchronized + fun receiveMask(peerId: Int, value: BigInt, scalarSize: Int) { + val existing = masks[peerId] + if (existing != null) { + val left = existing.toUnsignedByteArray(scalarSize) + val right = value.toUnsignedByteArray(scalarSize) + try { + if (!MessageDigest.isEqual(left, right)) + throw TKeeperException(ErrorType.INVALID_SHARE, "Conflicting recovery mask") + } finally { + left.fill(0) + right.fill(0) + } + return + } + check(contributionBytes == null) { "Contribution already created" } + masks[peerId] = value.copy() + } + + @Synchronized + fun masksSnapshot(): Map = masks.mapValues { it.value.copy() } + + @Synchronized + fun storeContribution(value: ByteArray) { + if (contributionBytes == null) contributionBytes = value.copyOf() + } + + @Synchronized + fun destroy() { + masks.values.forEach(BigInt::destroy) + masks.clear() + contributionBytes?.fill(0) + contributionBytes = null + } + } + + private companion object { + const val MAX_SESSION_SECONDS = 300L + } +} diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/service/ECCRecoveryService.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/service/ECCRecoveryService.kt new file mode 100644 index 00000000..e4d82ab0 --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/service/ECCRecoveryService.kt @@ -0,0 +1,344 @@ +package org.exploit.keeper.module.recovery.ecc.service + +import jakarta.inject.Singleton +import org.exploit.bigint.BigInt +import org.exploit.keeper.api.client.TKeeperClient +import org.exploit.keeper.constant.RecoveryErrorType +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.model.recovery.RecoverRequest +import org.exploit.keeper.model.recovery.RecoverResult +import org.exploit.keeper.module.recovery.ecc.api.ECCRecoveryApi +import org.exploit.keeper.module.recovery.ecc.model.ECCRecoveryAck +import org.exploit.keeper.module.recovery.ecc.model.ECCRecoveryPrepare +import org.exploit.keeper.module.recovery.ecc.model.ECCRecoverySessionRequest +import org.exploit.keeper.module.recovery.ecc.model.contribution.ECCRecoveryContributionRequest +import org.exploit.keeper.module.recovery.ecc.model.inventory.ECCRecoveryInventory +import org.exploit.keeper.module.recovery.ecc.model.inventory.ECCRecoveryInventoryRequest +import org.exploit.keeper.module.recovery.ecc.model.manifest.ECCRecoveryManifest +import org.exploit.keeper.module.recovery.ecc.model.manifest.ECCRecoveryManifestRequest +import org.exploit.keeper.module.recovery.ecc.model.mask.ECCRecoveryMaskCreate +import org.exploit.keeper.module.recovery.ecc.util.ECCRecoveryDigests +import org.exploit.keeper.module.recovery.ecc.util.ECCRecoveryMath +import org.exploit.keeper.module.recovery.ecc.util.ECCRecoveryTranscript +import org.exploit.keeper.platform.ecc.algorithm.ECKeyAlgorithmProvider +import org.exploit.keeper.platform.ecc.dealer.commitment.DealerKeyMaterialService +import org.exploit.keeper.platform.ecc.store.CommitStore +import org.exploit.keeper.recovery.RecoveryMode +import org.exploit.keeper.recovery.RecoveryStateReset +import org.exploit.keeper.recovery.handler.RecoveryHandler +import org.exploit.keeper.service.client.TKeeperClients +import org.exploit.keeper.service.db.RocksKeyDB +import org.exploit.keeper.service.db.store.KeyVersionStore +import org.exploit.keeper.service.key.KeyAlgorithmProvider +import org.exploit.keeper.service.key.KeyAlgorithms +import org.exploit.keeper.util.KeySerializer +import org.exploit.keeper.util.extension.perform +import org.exploit.keeper.util.extension.performIndexed +import org.exploit.keeper.util.extension.succeed +import org.exploit.keeper.util.extension.wait +import org.exploit.keeper.util.sameRecoveryMetadata +import org.exploit.keeper.util.validation.InputValidator +import java.security.MessageDigest +import java.time.Instant +import java.util.* + +@Singleton +class ECCRecoveryService( + private val mode: RecoveryMode, + private val db: RocksKeyDB, + private val clients: TKeeperClients, + private val algorithms: KeyAlgorithms, + private val material: DealerKeyMaterialService, + private val stateReset: RecoveryStateReset, +) : RecoveryHandler { + private val initData by lazy { db.readInitData() } + + override fun supports(algorithm: KeyAlgorithmProvider): Boolean = + algorithm is ECKeyAlgorithmProvider<*, *, *, *> + + override fun recover(request: RecoverRequest, algorithm: KeyAlgorithmProvider): RecoverResult { + mode.requireEnabled() + if (!supports(algorithm)) + throw TKeeperException(ErrorType.UNSUPPORTED_KEY_ALGORITHM) + if (!InputValidator.isValidKeyId(request.keyId)) + throw TKeeperException(ErrorType.INVALID_KEY_ID) + if (initData.threshold <= 1 || initData.threshold >= initData.total) + throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "Recovery requires 1 < t < n") + + val helpers = selectHelpers(request.helperIds) + val inventory = loadInventory(request.keyId, helpers) + + val manifests = ArrayList() + val recovered = ArrayList() + try { + for (item in inventory.generations) { + if (item.destroyed) continue + val manifest = loadManifest(request.keyId, item.generation, helpers) + if (!sameRecoveryMetadata(item.metadata, manifest.metadata)) + throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Inventory and manifest metadata disagree") + manifests += manifest + recovered += RecoveredGeneration(manifest, recoverGeneration(manifest, helpers)) + } + + install(request.keyId, inventory, manifests, recovered) + return RecoverResult( + keyId = request.keyId, + algorithm = algorithm.name(), + targetPeerId = initData.peerId, + currentGeneration = inventory.currentGeneration, + recoveredGenerations = recovered.map { it.manifest.generation }, + helpers = helpers.map { it.peerId }, + ) + } finally { + recovered.forEach { it.share.fill(0) } + } + } + + private fun selectHelpers(requested: List): List { + val ids = requested.sorted() + if (requested != ids || ids.distinct().size != ids.size || ids.size != initData.threshold || + initData.peerId in ids || ids.any { it !in 1..initData.total } + ) throw TKeeperException(ErrorType.INVALID_THRESHOLD, "Expected exactly ${initData.threshold} canonical helpers") + + val selected = clients.find(ids) + if (selected.size != ids.size) + throw TKeeperException(ErrorType.NOT_ENOUGH_CONFIGURED_CLIENTS) + return selected.sortedBy { it.peerId } + } + + private fun loadInventory(keyId: String, helpers: List): ECCRecoveryInventory { + val responses = helpers.performIndexed { peer -> + peer.internalClient(ECCRecoveryApi::class.java).inventory( + ECCRecoveryInventoryRequest(keyId, initData.peerId) + ) + } + val inventories = helpers.map { peer -> + responses[peer.peerId] ?: throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Missing inventory") + } + inventories.forEachIndexed { index, value -> + if (value.peerId != helpers[index].peerId || value.targetPeerId != initData.peerId || + value.keyId != keyId || value.threshold != initData.threshold || value.total != initData.total + ) throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Invalid recovery inventory identity") + if (value.pendingGenerations.isNotEmpty()) + throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "Pending generations must be resolved first") + } + + val expected = inventories.first() + if (inventories.any { !sameInventory(expected, it) }) + throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Helper inventories disagree") + if (expected.generations.none { it.generation == expected.currentGeneration && !it.destroyed }) + throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Current generation is not recoverable") + return expected + } + + private fun sameInventory(left: ECCRecoveryInventory, right: ECCRecoveryInventory): Boolean { + if (left.targetPeerId != right.targetPeerId || left.keyId != right.keyId || + left.threshold != right.threshold || left.total != right.total || + left.currentGeneration != right.currentGeneration || + left.pendingGenerations != right.pendingGenerations || left.generations.size != right.generations.size + ) return false + + return left.generations.zip(right.generations).all { (a, b) -> + a.generation == b.generation && a.destroyed == b.destroyed && + a.destroySessionId == b.destroySessionId && sameRecoveryMetadata(a.metadata, b.metadata) + } + } + + private fun loadManifest(keyId: String, generation: Int, helpers: List): ECCRecoveryManifest { + val responses = helpers.performIndexed { peer -> + peer.internalClient(ECCRecoveryApi::class.java).manifest( + ECCRecoveryManifestRequest(keyId, generation, initData.peerId) + ) + } + val manifests = helpers.map { peer -> + responses[peer.peerId] ?: throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Missing manifest") + } + manifests.forEachIndexed { index, value -> + if (value.peerId != helpers[index].peerId || value.targetPeerId != initData.peerId || + value.keyId != keyId || value.generation != generation || + value.threshold != initData.threshold || value.total != initData.total + ) throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Invalid recovery manifest identity") + } + + val expected = manifests.first() + val digest = ECCRecoveryDigests.commitments(expected.commitments) + if (manifests.any { + !it.algorithm.equals(expected.algorithm, ignoreCase = true) || + !sameRecoveryMetadata(it.metadata, expected.metadata) || + !MessageDigest.isEqual(ECCRecoveryDigests.commitments(it.commitments), digest) + } + ) throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Helper manifests disagree") + return expected + } + + private fun recoverGeneration(manifest: ECCRecoveryManifest, helpers: List): ByteArray { + val helperIds = helpers.map { it.peerId } + val sessionId = UUID.randomUUID().toString() + val unsigned = ECCRecoveryPrepare( + sessionId = sessionId, + keyId = manifest.keyId, + generation = manifest.generation, + targetPeerId = initData.peerId, + helperIds = helperIds, + algorithm = manifest.algorithm, + commitmentsDigest = ECCRecoveryDigests.commitments(manifest.commitments), + metadataDigest = ECCRecoveryDigests.metadata(manifest.metadata), + expiresAt = Instant.now().plusSeconds(SESSION_SECONDS).epochSecond, + transcriptHash = ByteArray(0), + ) + val prepare = unsigned.copy(transcriptHash = ECCRecoveryTranscript.hash(unsigned)) + + try { + helpers.perform { it.internalClient(ECCRecoveryApi::class.java).prepare(prepare) } + + distributeMasks(helpers, helperIds, sessionId, prepare.transcriptHash) + + val contributions = helpers.performIndexed { + it.internalClient(ECCRecoveryApi::class.java).contribution( + ECCRecoveryContributionRequest(sessionId, prepare.transcriptHash) + ) + } + val algorithm = curve(manifest.algorithm) + val scalars = helpers.map { helper -> + val response = contributions[helper.peerId] + ?: throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Missing contribution") + if (response.peerId != helper.peerId || response.sessionId != sessionId || + !MessageDigest.isEqual(response.transcriptHash, prepare.transcriptHash) + ) throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Invalid contribution identity") + try { + ECCRecoveryMath.decodeScalar( + response.value, + algorithm.factory().privateKeySize(), + algorithm.params().curveOrder, + ) + } finally { + response.value.fill(0) + } + } + try { + val recovered = ECCRecoveryMath.sum(scalars, algorithm.params().curveOrder) + return try { + val bytes = recovered.toUnsignedByteArray(algorithm.factory().privateKeySize()) + try { + material.ensureShareMatchesCommitments( + algorithm = algorithm, + peerId = initData.peerId, + share = bytes, + commitments = manifest.commitments, + threshold = initData.threshold, + ) + bytes + } catch (t: Throwable) { + bytes.fill(0) + throw t + } + } finally { + recovered.destroy() + } + } finally { + scalars.forEach(BigInt::destroy) + } + } finally { + helpers.forEach { helper -> + runCatching { + helper.internalClient(ECCRecoveryApi::class.java).abort( + ECCRecoverySessionRequest(sessionId, prepare.transcriptHash) + ).succeed().join() + } + } + } + } + + private fun distributeMasks( + helpers: List, + helperIds: List, + sessionId: String, + transcriptHash: ByteArray, + ) { + val pending = ArrayList>(MASK_PARALLELISM) + for (leftIndex in 0 until helperIds.lastIndex) { + val generator = helpers[leftIndex] + for (rightIndex in leftIndex + 1 until helperIds.size) { + try { + pending += generator.internalClient(ECCRecoveryApi::class.java).createMask( + ECCRecoveryMaskCreate(sessionId, transcriptHash, helperIds[rightIndex]) + ).succeed() + } catch (t: Throwable) { + pending.forEach { runCatching { it.join() } } + throw t + } + if (pending.size == MASK_PARALLELISM) { + pending.wait() + pending.clear() + } + } + } + pending.wait() + } + + private fun install( + keyId: String, + inventory: ECCRecoveryInventory, + manifests: List, + recovered: List, + ) { + val currentMeta = inventory.generations.single { it.generation == inventory.currentGeneration }.metadata + val manifestsByGeneration = manifests.associateBy { it.generation } + val recoveredByGeneration = recovered.associateBy { it.manifest.generation } + val sourceActive = inventory.generations.filterNot { it.destroyed }.map { it.generation }.toSet() + if (recoveredByGeneration.keys != sourceActive) + throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Incomplete ECC recovery result") + + db.vault.ops.runInTransaction { tx -> + val versions = db.getStore(KeyVersionStore::class) + val commits = db.getStore(CommitStore::class) + stateReset.clear(tx, keyId) + + inventory.generations.forEach { generation -> + db.meta.saveVersionedMeta(tx, generation.metadata) + + if (generation.destroyed) { + val expectedSession = requireNotNull(generation.destroySessionId) { + "Destroyed generation is missing its session id" + } + db.destroy.markCommitted(tx, keyId, generation.generation, expectedSession) + return@forEach + } + + val manifest = manifestsByGeneration[generation.generation] + ?: throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Missing ECC recovery manifest") + commits.saveActiveIfMissing(tx, keyId, generation.generation, manifest.commitments) + + val recoveredGeneration = recoveredByGeneration.getValue(generation.generation) + val algorithm = algorithms.findOrThrow(recoveredGeneration.manifest.algorithm) + val serialized = KeySerializer.serialize(algorithm.id(), recoveredGeneration.share) + try { + versions.saveActive(tx, keyId, generation.generation, serialized) + } finally { + serialized.fill(0) + } + } + + versions.setCurrentGeneration(tx, keyId, inventory.currentGeneration) + db.meta.saveKeysetMeta(tx, currentMeta) + inventory.generations.filter { it.generation != inventory.currentGeneration }.forEach { + db.expire.syncNonHead(tx, keyId, it.generation, it.metadata.policy) + } + } + } + + private fun curve(name: String): ECKeyAlgorithmProvider<*, *, *, *> = + algorithms.findOrThrow(name) as? ECKeyAlgorithmProvider<*, *, *, *> + ?: throw TKeeperException(ErrorType.UNSUPPORTED_KEY_ALGORITHM) + + private data class RecoveredGeneration( + val manifest: ECCRecoveryManifest, + val share: ByteArray, + ) + + private companion object { + const val MASK_PARALLELISM = 64 + const val SESSION_SECONDS = 240L + } +} diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/util/ECCRecoveryDigests.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/util/ECCRecoveryDigests.kt new file mode 100644 index 00000000..189bdad7 --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/util/ECCRecoveryDigests.kt @@ -0,0 +1,17 @@ +package org.exploit.keeper.module.recovery.ecc.util + +import org.exploit.keeper.model.keyset.KeySetMeta +import org.exploit.keeper.platform.ecc.model.keygen.DkgCommitments +import org.exploit.keeper.platform.ecc.util.CommitmentsSerializer +import org.exploit.keeper.util.extension.signingBytes +import org.exploit.keeper.util.extension.signingTree +import java.security.MessageDigest + +object ECCRecoveryDigests { + fun commitments(value: DkgCommitments): ByteArray = + MessageDigest.getInstance("SHA-256").digest(CommitmentsSerializer.serialize(value)) + + fun metadata(value: KeySetMeta): ByteArray = MessageDigest.getInstance("SHA-256").digest( + signingBytes(signingTree(value.copy(createdAt = 0, updatedAt = 0))) + ) +} diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/util/ECCRecoveryMath.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/util/ECCRecoveryMath.kt new file mode 100644 index 00000000..69370fbd --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/util/ECCRecoveryMath.kt @@ -0,0 +1,157 @@ +package org.exploit.keeper.module.recovery.ecc.util + +import org.exploit.bigint.BigInt +import org.exploit.bigint.util.CSPRNG + +object ECCRecoveryMath { + fun lagrangeCoefficient( + helperPeerId: Int, + targetPeerId: Int, + helperIds: List, + order: BigInt, + ): BigInt { + require(helperIds == helperIds.sorted() && helperIds.distinct().size == helperIds.size) + require(helperPeerId in helperIds && targetPeerId !in helperIds) + + var numerator = BigInt.ONE.copy() + var denominator = BigInt.ONE.copy() + try { + for (other in helperIds) { + if (other == helperPeerId) continue + numerator = replaceWithProduct(numerator, fieldElement(targetPeerId - other, order), order) + denominator = replaceWithProduct(denominator, fieldElement(helperPeerId - other, order), order) + } + + val inverse = denominator.modInverse(order) + return try { + numerator.modMul(inverse, order) + } finally { + inverse.destroy() + } + } finally { + numerator.destroy() + denominator.destroy() + } + } + + fun maskedContribution( + share: BigInt, + coefficient: BigInt, + outgoingMasks: Collection, + incomingMasks: Collection, + order: BigInt, + ): BigInt { + val blind = CSPRNG.randomNonZeroBelow(order) + var result: BigInt? = null + try { + val blindMultiple = blind.multiplySec(order) + try { + val blindedShare = share.add(blindMultiple) + try { + result = blindedShare.multiplySec(coefficient) + } finally { + blindedShare.destroy() + } + } finally { + blindMultiple.destroy() + } + + val guardFactor = BigInt.valueOf((outgoingMasks.size + incomingMasks.size + 1L)) + val guard = try { + order.multiply(guardFactor) + } finally { + guardFactor.destroy() + } + try { + result = replaceWithoutReduction(checkNotNull(result), guard, subtract = false) + } finally { + guard.destroy() + } + outgoingMasks.forEach { mask -> + result = replaceWithoutReduction(checkNotNull(result), mask, subtract = false) + } + incomingMasks.forEach { mask -> + result = replaceWithoutReduction(checkNotNull(result), mask, subtract = true) + } + + return checkNotNull(result).modPowSec(BigInt.ONE, order) + } catch (t: Throwable) { + throw t + } finally { + result?.destroy() + blind.destroy() + } + } + + fun sum(contributions: Collection, order: BigInt): BigInt { + var result = BigInt.ZERO.copy() + try { + contributions.forEach { contribution -> + result = replaceWithSum(result, contribution, order, subtract = false) + } + return result + } catch (t: Throwable) { + result.destroy() + throw t + } + } + + fun decodeScalar(raw: ByteArray, expectedSize: Int, order: BigInt): BigInt { + require(raw.size == expectedSize) { "Invalid scalar length" } + val value = BigInt(1, raw) + if (value.signum() < 0 || value.compareTo(order) >= 0) { + value.destroy() + throw IllegalArgumentException("Scalar is outside the curve field") + } + return value + } + + private fun replaceWithProduct(current: BigInt, factor: BigInt, order: BigInt): BigInt { + return try { + val product = current.multiply(factor) + try { + product.mod(order) + } finally { + product.destroy() + } + } finally { + current.destroy() + factor.destroy() + } + } + + private fun replaceWithSum(current: BigInt, value: BigInt, order: BigInt, subtract: Boolean): BigInt { + val combined = if (subtract) { + val lifted = current.add(order) + try { + lifted.subtract(value) + } finally { + lifted.destroy() + } + } else { + current.add(value) + } + return try { + combined.modPowSec(BigInt.ONE, order) + } finally { + combined.destroy() + current.destroy() + } + } + + private fun replaceWithoutReduction(current: BigInt, value: BigInt, subtract: Boolean): BigInt = + try { + if (subtract) current.subtract(value) else current.add(value) + } finally { + current.destroy() + } + + private fun fieldElement(value: Int, order: BigInt): BigInt { + val raw = BigInt.valueOf(value.toLong()) + return try { + raw.mod(order) + } finally { + raw.destroy() + } + } +} diff --git a/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/util/ECCRecoveryTranscript.kt b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/util/ECCRecoveryTranscript.kt new file mode 100644 index 00000000..a2c6e74e --- /dev/null +++ b/features/recovery/ecc/src/main/kotlin/org/exploit/keeper/module/recovery/ecc/util/ECCRecoveryTranscript.kt @@ -0,0 +1,40 @@ +package org.exploit.keeper.module.recovery.ecc.util + +import org.exploit.keeper.module.recovery.ecc.model.ECCRecoveryPrepare +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.security.MessageDigest + +object ECCRecoveryTranscript { + private const val DOMAIN = "TKEEPER_ECC_RECOVERY_V1" + + fun hash(prepare: ECCRecoveryPrepare): ByteArray = + ByteArrayOutputStream().use { bytes -> + DataOutputStream(bytes).use { out -> + out.writeField(DOMAIN) + out.writeField(prepare.sessionId) + out.writeField(prepare.keyId) + out.writeInt(prepare.generation) + out.writeInt(prepare.targetPeerId) + out.writeInt(prepare.helperIds.size) + prepare.helperIds.forEach(out::writeInt) + out.writeField(prepare.algorithm) + out.writeInt(prepare.commitmentsDigest.size) + out.write(prepare.commitmentsDigest) + out.writeInt(prepare.metadataDigest.size) + out.write(prepare.metadataDigest) + out.writeLong(prepare.expiresAt) + } + + MessageDigest.getInstance("SHA-256").digest(bytes.toByteArray()) + } + + fun matches(prepare: ECCRecoveryPrepare): Boolean = + MessageDigest.isEqual(hash(prepare), prepare.transcriptHash) + + private fun DataOutputStream.writeField(value: String) { + val raw = value.toByteArray(Charsets.UTF_8) + writeInt(raw.size) + write(raw) + } +} diff --git a/features/recovery/ecc/src/test/kotlin/org/exploit/keeper/module/recovery/ecc/ECCRecoveryMathProperties.kt b/features/recovery/ecc/src/test/kotlin/org/exploit/keeper/module/recovery/ecc/ECCRecoveryMathProperties.kt new file mode 100644 index 00000000..c18a1c1e --- /dev/null +++ b/features/recovery/ecc/src/test/kotlin/org/exploit/keeper/module/recovery/ecc/ECCRecoveryMathProperties.kt @@ -0,0 +1,215 @@ +package org.exploit.keeper.module.recovery.ecc + +import io.kotest.property.Arb +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.long +import io.kotest.property.checkAll +import kotlinx.coroutines.runBlocking +import org.exploit.bigint.BigInt +import org.exploit.keeper.module.recovery.ecc.util.ECCRecoveryMath +import org.junit.jupiter.api.Test +import java.math.BigInteger +import java.util.Random +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ECCRecoveryMathProperties { + @Test + fun `helper set must be canonical unique and exclude the target`() { + val order = BigInt(PRIME_JAVA.toString()) + try { + assertFailsWith { + ECCRecoveryMath.lagrangeCoefficient(1, 4, listOf(2, 1, 3), order) + } + assertFailsWith { + ECCRecoveryMath.lagrangeCoefficient(1, 4, listOf(1, 1, 3), order) + } + assertFailsWith { + ECCRecoveryMath.lagrangeCoefficient(1, 3, listOf(1, 2, 3), order) + } + } finally { + order.destroy() + } + } + + @Test + fun `wire scalars must use the fixed width canonical field encoding`() { + val order = BigInt.valueOf(17) + try { + assertFailsWith { + ECCRecoveryMath.decodeScalar(byteArrayOf(1, 2), 1, order) + } + assertFailsWith { + ECCRecoveryMath.decodeScalar(byteArrayOf(17), 1, order) + } + } finally { + order.destroy() + } + } + + @Test + fun `masked repair evaluates the sharing polynomial for arbitrary valid t and n`() { + runBlocking { + checkAll( + 500, + Arb.int(3, 16), + Arb.int(), + Arb.int(), + Arb.long(), + Arb.long(), + ) { total, thresholdSeed, targetSeed, polynomialSeed, maskSeed -> + val threshold = 2 + Math.floorMod(thresholdSeed, total - 2) + val target = 1 + Math.floorMod(targetSeed, total) + val helpers = (1..total).filter { it != target }.take(threshold).sorted() + val random = Random(polynomialSeed) + val coefficients = List(threshold) { BigInteger(126, random).mod(PRIME_JAVA) } + val shares = helpers.associateWith { evaluate(coefficients, BigInteger.valueOf(it.toLong())) } + val masks = pairMasks(helpers, maskSeed) + + val order = BigInt(PRIME_JAVA.toString()) + val contributions = ArrayList() + try { + for (helper in helpers) { + val share = BigInt(shares.getValue(helper).toString()) + val coefficient = ECCRecoveryMath.lagrangeCoefficient(helper, target, helpers, order) + try { + contributions += ECCRecoveryMath.maskedContribution( + share = share, + coefficient = coefficient, + outgoingMasks = masks.filterKeys { (left, _) -> left == helper }.values, + incomingMasks = masks.filterKeys { (_, right) -> right == helper }.values, + order = order, + ) + } finally { + share.destroy() + coefficient.destroy() + } + } + + val actual = ECCRecoveryMath.sum(contributions, order) + try { + assertEquals(evaluate(coefficients, BigInteger.valueOf(target.toLong())), actual.toJavaInt()) + } finally { + actual.destroy() + } + } finally { + contributions.forEach(BigInt::destroy) + masks.values.forEach(BigInt::destroy) + order.destroy() + } + } + } + } + + @Test + fun `changing every pair mask preserves only the recovered sum`() { + runBlocking { + checkAll(250, Arb.int(3, 12), Arb.int(), Arb.int(), Arb.long()) { total, thresholdSeed, targetSeed, seed -> + val threshold = 2 + Math.floorMod(thresholdSeed, total - 2) + val target = 1 + Math.floorMod(targetSeed, total) + val helpers = (1..total).filter { it != target }.take(threshold).sorted() + val coefficients = List(threshold) { BigInteger.valueOf((it + 7L) * 19L) } + + val first = repairVector(coefficients, helpers, target, seed) + val second = repairVector(coefficients, helpers, target, seed xor 0x5deece66dL) + try { + assertEquals(first.sum, second.sum) + if (first.values.size > 1) + kotlin.test.assertNotEquals(first.values, second.values) + } finally { + first.destroy() + second.destroy() + } + } + } + } + + @Test + fun `observing the same helper in two fresh sessions leaves every share possible`() { + val order = BigInteger.valueOf(17) + val coefficient = BigInteger.valueOf(5) + val share = BigInteger.valueOf(7) + val firstMask = BigInteger.valueOf(11) + val secondMask = BigInteger.valueOf(3) + val firstContribution = coefficient.multiply(share).add(firstMask).mod(order) + val secondContribution = coefficient.multiply(share).add(secondMask).mod(order) + + val possibleShares = (0 until order.toInt()).filter { candidate -> + val value = BigInteger.valueOf(candidate.toLong()) + val compatibleFirstMask = firstContribution.subtract(coefficient.multiply(value)).mod(order) + val compatibleSecondMask = secondContribution.subtract(coefficient.multiply(value)).mod(order) + + coefficient.multiply(value).add(compatibleFirstMask).mod(order) == firstContribution && + coefficient.multiply(value).add(compatibleSecondMask).mod(order) == secondContribution + } + + assertEquals((0 until order.toInt()).toList(), possibleShares) + assertEquals(firstMask.subtract(secondMask).mod(order), firstContribution.subtract(secondContribution).mod(order)) + } + + private fun repairVector( + coefficients: List, + helpers: List, + target: Int, + maskSeed: Long, + ): RepairVector { + val order = BigInt(PRIME_JAVA.toString()) + val masks = pairMasks(helpers, maskSeed) + val contributions = ArrayList() + try { + helpers.forEach { helper -> + val share = BigInt(evaluate(coefficients, BigInteger.valueOf(helper.toLong())).toString()) + val coefficient = ECCRecoveryMath.lagrangeCoefficient(helper, target, helpers, order) + try { + contributions += ECCRecoveryMath.maskedContribution( + share, + coefficient, + masks.filterKeys { it.first == helper }.values, + masks.filterKeys { it.second == helper }.values, + order, + ) + } finally { + share.destroy() + coefficient.destroy() + } + } + val sum = ECCRecoveryMath.sum(contributions, order) + return try { + RepairVector(contributions.map { it.toJavaInt() }, sum.toJavaInt()) + } finally { + sum.destroy() + } + } finally { + contributions.forEach(BigInt::destroy) + masks.values.forEach(BigInt::destroy) + order.destroy() + } + } + + private fun pairMasks(helpers: List, seed: Long): Map, BigInt> { + val random = Random(seed) + return buildMap { + for (left in helpers.indices) { + for (right in left + 1 until helpers.size) { + put( + helpers[left] to helpers[right], + BigInt(BigInteger(126, random).mod(PRIME_JAVA).toString()), + ) + } + } + } + } + + private fun evaluate(coefficients: List, x: BigInteger): BigInteger = + coefficients.asReversed().fold(BigInteger.ZERO) { acc, coefficient -> + acc.multiply(x).add(coefficient).mod(PRIME_JAVA) + } + + private data class RepairVector(val values: List, val sum: BigInteger) { + fun destroy() = Unit + } + + private companion object { + val PRIME_JAVA: BigInteger = BigInteger.TWO.pow(127).subtract(BigInteger.ONE) + } +} diff --git a/features/recovery/ecc/src/test/kotlin/org/exploit/keeper/module/recovery/ecc/ECCRecoveryMetadataTest.kt b/features/recovery/ecc/src/test/kotlin/org/exploit/keeper/module/recovery/ecc/ECCRecoveryMetadataTest.kt new file mode 100644 index 00000000..2e715452 --- /dev/null +++ b/features/recovery/ecc/src/test/kotlin/org/exploit/keeper/module/recovery/ecc/ECCRecoveryMetadataTest.kt @@ -0,0 +1,50 @@ +package org.exploit.keeper.module.recovery.ecc + +import org.exploit.keeper.model.keyset.KeySetAuthority +import org.exploit.keeper.model.keyset.KeySetMeta +import org.exploit.keeper.model.keyset.KeySetPolicy +import org.exploit.keeper.model.keyset.control.ExternalPublicKey +import org.exploit.keeper.model.keyset.control.FourEyeControlPolicy +import org.exploit.keeper.util.sameRecoveryMetadata +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ECCRecoveryMetadataTest { + @Test + fun `timestamps may differ but security metadata must match`() { + val first = metadata(createdAt = 10, updatedAt = 20) + val second = metadata(createdAt = 30, updatedAt = 40) + + assertTrue(sameRecoveryMetadata(first, second)) + assertFalse(sameRecoveryMetadata(first, second.copy(enabled = false))) + assertFalse(sameRecoveryMetadata(first, second.copy(authorities = listOf(KeySetAuthority("other"))))) + } + + @Test + fun `equivalent deserialized four eye policies compare by canonical bytes`() { + val first = metadata(10, 20).copy(policy = policy()) + val second = metadata(30, 40).copy(policy = policy()) + + assertTrue(sameRecoveryMetadata(first, second)) + assertFalse(sameRecoveryMetadata(first, second.copy(policy = policy(m = 2)))) + } + + private fun policy(m: Int = 1) = KeySetPolicy( + fourEye = FourEyeControlPolicy( + m = m, + n = 1, + keys = listOf(ExternalPublicKey("ED25519", "AA==")), + ), + ) + + private fun metadata(createdAt: Long, updatedAt: Long) = KeySetMeta( + logicalId = "key-1", + enabled = true, + currentGeneration = 3, + createdAt = createdAt, + updatedAt = updatedAt, + authorities = listOf(KeySetAuthority("authority-1", "oci://authority")), + assetOwner = "owner-1", + ) +} diff --git a/features/recovery/ecc/src/test/kotlin/org/exploit/keeper/module/recovery/ecc/ECCRecoveryTranscriptTest.kt b/features/recovery/ecc/src/test/kotlin/org/exploit/keeper/module/recovery/ecc/ECCRecoveryTranscriptTest.kt new file mode 100644 index 00000000..ad487e06 --- /dev/null +++ b/features/recovery/ecc/src/test/kotlin/org/exploit/keeper/module/recovery/ecc/ECCRecoveryTranscriptTest.kt @@ -0,0 +1,37 @@ +package org.exploit.keeper.module.recovery.ecc + +import org.exploit.keeper.module.recovery.ecc.model.ECCRecoveryPrepare +import org.exploit.keeper.module.recovery.ecc.util.ECCRecoveryTranscript +import org.junit.jupiter.api.Test +import java.security.MessageDigest +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ECCRecoveryTranscriptTest { + @Test + fun `transcript binds target helpers generation side state and expiry`() { + val unsigned = ECCRecoveryPrepare( + sessionId = "8d786d00-1d78-4f6f-9dfb-519a41654f29", + keyId = "key-1", + generation = 7, + targetPeerId = 4, + helperIds = listOf(1, 2, 3), + algorithm = "P256", + commitmentsDigest = ByteArray(32) { it.toByte() }, + metadataDigest = ByteArray(32) { (it + 1).toByte() }, + expiresAt = 123456789, + transcriptHash = ByteArray(0), + ) + val hash = ECCRecoveryTranscript.hash(unsigned) + val signed = unsigned.copy(transcriptHash = hash) + + assertTrue(ECCRecoveryTranscript.matches(signed)) + assertTrue(MessageDigest.isEqual(hash, ECCRecoveryTranscript.hash(unsigned))) + assertFalse(ECCRecoveryTranscript.matches(signed.copy(targetPeerId = 5))) + assertFalse(ECCRecoveryTranscript.matches(signed.copy(helperIds = listOf(1, 2, 5)))) + assertFalse(ECCRecoveryTranscript.matches(signed.copy(generation = 8))) + assertFalse(ECCRecoveryTranscript.matches(signed.copy(expiresAt = unsigned.expiresAt + 1))) + assertFalse(ECCRecoveryTranscript.matches(signed.copy(commitmentsDigest = ByteArray(32)))) + assertFalse(ECCRecoveryTranscript.matches(signed.copy(metadataDigest = ByteArray(32)))) + } +} diff --git a/features/recovery/ecc/src/test/kotlin/org/exploit/keeper/module/recovery/ecc/fuzz/ECCRecoveryProtocolFuzzTest.kt b/features/recovery/ecc/src/test/kotlin/org/exploit/keeper/module/recovery/ecc/fuzz/ECCRecoveryProtocolFuzzTest.kt new file mode 100644 index 00000000..039ea099 --- /dev/null +++ b/features/recovery/ecc/src/test/kotlin/org/exploit/keeper/module/recovery/ecc/fuzz/ECCRecoveryProtocolFuzzTest.kt @@ -0,0 +1,181 @@ +package org.exploit.keeper.module.recovery.ecc.fuzz + +import com.code_intelligence.jazzer.junit.FuzzTest +import org.exploit.bigint.BigInt +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.module.recovery.ecc.model.ECCRecoveryPrepare +import org.exploit.keeper.module.recovery.ecc.service.ECCRecoveryPeerService +import org.exploit.keeper.module.recovery.ecc.util.ECCRecoveryMath +import org.junit.jupiter.params.provider.MethodSource +import java.math.BigInteger +import java.util.stream.Stream +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ECCRecoveryProtocolFuzzTest { + @MethodSource("seeds") + @FuzzTest(maxDuration = "30s") + fun generatedInputsPreserveRecoveryProtocolInvariants(input: ByteArray) { + if (input.size > MAX_INPUT_SIZE) return + + val actions = if (input.isEmpty()) byteArrayOf(0) else input + fuzzScalarDecoding(actions) + fuzzHelperSession(actions) + } + + private fun fuzzScalarDecoding(input: ByteArray) { + val raw = ByteArray(unsigned(input[0]) % 4) { input.at(it + 1) } + val order = BigInt.valueOf(SCALAR_ORDER) + try { + val result = runCatching { ECCRecoveryMath.decodeScalar(raw, SCALAR_SIZE, order) } + val expected = raw.size == SCALAR_SIZE && BigInteger(1, raw) < SCALAR_ORDER_JAVA + if (!expected) { + assertIs(result.exceptionOrNull()) + return + } + + val decoded = result.getOrThrow() + try { + assertEquals(BigInteger(1, raw), decoded.toJavaInt()) + } finally { + decoded.destroy() + } + } finally { + order.destroy() + } + } + + private fun fuzzHelperSession(input: ByteArray) { + val session = ECCRecoveryPeerService.HelperSession(PREPARE) + val masks = HashMap() + var contribution: ByteArray? = null + try { + input.forEachIndexed { index, byte -> + val peerId = 1 + unsigned(input.at(index + 1)) % 4 + val value = unsigned(input.at(index + 2)).toLong() + when (unsigned(byte) % 5) { + 0 -> { + val existing = masks[peerId] + val result = runCatching { + val mask = session.outgoingMask(peerId) { BigInt.valueOf(value) } + try { + mask.longValue() + } finally { + mask.destroy() + } + } + if (existing == null && contribution != null) { + assertIs(result.exceptionOrNull()) + } else { + assertEquals(existing ?: value, result.getOrThrow()) + masks.putIfAbsent(peerId, value) + } + } + + 1 -> { + val existing = masks[peerId] + val mask = BigInt.valueOf(value) + val result = try { + runCatching { session.receiveMask(peerId, mask, SCALAR_SIZE) } + } finally { + mask.destroy() + } + when { + existing != null && existing != value -> { + val failure = assertIs(result.exceptionOrNull()) + assertEquals(ErrorType.INVALID_SHARE, failure.errorType) + } + + existing == null && contribution != null -> + assertIs(result.exceptionOrNull()) + + else -> { + assertTrue(result.isSuccess) + masks.putIfAbsent(peerId, value) + } + } + } + + 2 -> { + val candidate = byteArrayOf(input.at(index + 1), input.at(index + 2)) + session.storeContribution(candidate) + if (contribution == null) contribution = candidate.copyOf() + candidate.fill(0) + } + + 3 -> { + val actual = session.contribution + assertNullableContentEquals(contribution, actual) + actual?.fill(0) + } + + else -> assertSnapshot(masks, session.masksSnapshot()) + } + } + + val finalContribution = session.contribution + try { + assertNullableContentEquals(contribution, finalContribution) + } finally { + finalContribution?.fill(0) + } + assertSnapshot(masks, session.masksSnapshot()) + } finally { + contribution?.fill(0) + session.destroy() + } + + assertEquals(null, session.contribution) + assertSnapshot(emptyMap(), session.masksSnapshot()) + } + + private fun assertSnapshot(expected: Map, actual: Map) { + try { + assertEquals(expected, actual.mapValues { it.value.longValue() }) + } finally { + actual.values.forEach(BigInt::destroy) + } + } + + private fun assertNullableContentEquals(expected: ByteArray?, actual: ByteArray?) { + if (expected == null || actual == null) { + assertEquals(expected, actual) + } else { + assertContentEquals(expected, actual) + } + } + + private fun unsigned(value: Byte): Int = value.toInt() and 0xff + + private fun ByteArray.at(index: Int): Byte = this[index % size] + + companion object { + private const val MAX_INPUT_SIZE = 4_096 + private const val SCALAR_SIZE = 2 + private const val SCALAR_ORDER = 257L + private val SCALAR_ORDER_JAVA = BigInteger.valueOf(SCALAR_ORDER) + private val PREPARE = ECCRecoveryPrepare( + sessionId = "8d786d00-1d78-4f6f-9dfb-519a41654f29", + keyId = "fuzz-recovery", + generation = 1, + targetPeerId = 4, + helperIds = listOf(1, 2, 3), + algorithm = "P256", + commitmentsDigest = ByteArray(32), + metadataDigest = ByteArray(32), + expiresAt = Long.MAX_VALUE, + transcriptHash = ByteArray(32), + ) + + @JvmStatic + fun seeds(): Stream = Stream.of( + ByteArray(0), + byteArrayOf(0, 1, 2, 3, 4), + byteArrayOf(2, 1, 9, 0, 1, 9, 3, 4), + ByteArray(64) { it.toByte() }, + ) + } +} diff --git a/features/recovery/pqc/build.gradle b/features/recovery/pqc/build.gradle new file mode 100644 index 00000000..598362ba --- /dev/null +++ b/features/recovery/pqc/build.gradle @@ -0,0 +1,65 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' + id 'com.gradleup.shadow' version '9.4.1' + id 'com.google.devtools.ksp' version '2.3.6' +} + +group = 'org.exploit' +version = '2.4.0' + +repositories { + mavenCentral() +} + +dependencies { + compileOnly(project(':')) + compileOnly(project(':features:recovery')) + compileOnly(project(':platform-pqc')) + compileOnly 'org.exploit.anvil:t-mldsa:0.2.1' + testImplementation(project(':')) + testImplementation(project(':features:recovery')) + testImplementation(project(':platform-pqc')) + testImplementation 'org.exploit.anvil:t-mldsa:0.2.1' + testImplementation 'org.jetbrains.kotlin:kotlin-test' + testImplementation 'org.junit.jupiter:junit-jupiter:5.14.3' + testImplementation 'com.code-intelligence:jazzer-junit:0.30.0' + + 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() +} + +tasks.register('fuzzRecoveryPayloads', Test) { + group = 'verification' + description = 'Runs coverage-guided parsing and validation of hostile PQC recovery payloads.' + outputs.upToDateWhen { false } + + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform() + filter { + includeTestsMatching 'org.exploit.keeper.module.recovery.pqc.fuzz.PQCRecoveryPayloadFuzzTest' + } + environment 'JAZZER_FUZZ', '1' + systemProperty 'jazzer.max_duration', providers.gradleProperty('keeper.fuzz.duration').getOrElse('30s') +} diff --git a/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/api/PQCRecoveryApi.kt b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/api/PQCRecoveryApi.kt new file mode 100644 index 00000000..d0804206 --- /dev/null +++ b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/api/PQCRecoveryApi.kt @@ -0,0 +1,20 @@ +package org.exploit.keeper.module.recovery.pqc.api + +import org.eclipse.jetty.http.HttpMethod +import org.exploit.jettyx.annotation.Body +import org.exploit.jettyx.annotation.Header +import org.exploit.jettyx.annotation.HttpRequest +import org.exploit.jettyx.model.HttpResponse +import org.exploit.keeper.constant.media.MsgPack +import org.exploit.keeper.module.recovery.pqc.controller.PQCRecoveryRoutes +import org.exploit.keeper.module.recovery.pqc.model.PQCRecoveryBundle +import org.exploit.keeper.module.recovery.pqc.model.PQCRecoveryBundleRequest +import java.util.concurrent.CompletableFuture + +interface PQCRecoveryApi { + @HttpRequest(path = PQCRecoveryRoutes.BUNDLE, method = HttpMethod.POST) + fun bundle( + @Body request: PQCRecoveryBundleRequest, + @Header("Content-Type") contentType: String = MsgPack.MIME_TEXT, + ): CompletableFuture> +} diff --git a/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/controller/InternalPQCRecoveryController.kt b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/controller/InternalPQCRecoveryController.kt new file mode 100644 index 00000000..c5847e48 --- /dev/null +++ b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/controller/InternalPQCRecoveryController.kt @@ -0,0 +1,36 @@ +package org.exploit.keeper.module.recovery.pqc.controller + +import com.linecorp.armeria.server.ServerBuilder +import com.linecorp.armeria.server.ServiceRequestContext +import jakarta.inject.Singleton +import org.exploit.keeper.constant.RecoveryPermission +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.controller.internal.InternalController +import org.exploit.keeper.controller.support.ControllerSupport +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.module.recovery.pqc.model.PQCRecoveryBundleRequest +import org.exploit.keeper.module.recovery.pqc.service.PQCRecoveryPeerService +import org.exploit.keeper.util.extension.http.authData +import org.exploit.keeper.util.extension.http.hasPermission + +@Singleton +class InternalPQCRecoveryController( + private val support: ControllerSupport, + private val recovery: PQCRecoveryPeerService, +) : InternalController { + override fun register(sb: ServerBuilder) = with(support) { + sb.internalPost(PQCRecoveryRoutes.BUNDLE, PQCRecoveryBundleRequest::class.java, expectQuorum = false) { ctx, body -> + authorize(ctx) + recovery.bundle(body, sourcePeerId(ctx)) + } + } + + private fun authorize(ctx: ServiceRequestContext) { + if (!ctx.hasPermission(RecoveryPermission.recover())) + throw TKeeperException(ErrorType.ACCESS_DENIED) + } + + private fun sourcePeerId(ctx: ServiceRequestContext): Int = + ctx.authData()?.subject?.removePrefix("keeper-")?.toIntOrNull() + ?: throw TKeeperException(ErrorType.ACCESS_DENIED) +} diff --git a/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/controller/PQCRecoveryRoutes.kt b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/controller/PQCRecoveryRoutes.kt new file mode 100644 index 00000000..57ee904c --- /dev/null +++ b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/controller/PQCRecoveryRoutes.kt @@ -0,0 +1,27 @@ +package org.exploit.keeper.module.recovery.pqc.controller + +import com.linecorp.armeria.common.HttpMethod +import jakarta.inject.Singleton +import org.exploit.keeper.controller.external.RecoveryController.Companion.RECOVER +import org.exploit.keeper.controller.internal.InternalRecoveryController.Companion.DESCRIBE +import org.exploit.keeper.model.route.RecoveryRoute +import org.exploit.keeper.service.auth.internal.InternalRestrictedRouteProvider +import org.exploit.keeper.recovery.route.RecoveryRouteProvider + +@Singleton +class PQCRecoveryRoutes : RecoveryRouteProvider, InternalRestrictedRouteProvider { + override fun publicRoutes(): Set = setOf( + RecoveryRoute(HttpMethod.POST, RECOVER), + ) + + override fun internalRoutes(): Set = setOf( + RecoveryRoute(HttpMethod.POST, DESCRIBE), + RecoveryRoute(HttpMethod.POST, BUNDLE), + ) + + override fun paths(): Set = setOf(DESCRIBE, BUNDLE) + + companion object { + const val BUNDLE = "/v1/recovery/pqc/bundle" + } +} diff --git a/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/model/PQCRecoveryBundle.kt b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/model/PQCRecoveryBundle.kt new file mode 100644 index 00000000..62d3b05c --- /dev/null +++ b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/model/PQCRecoveryBundle.kt @@ -0,0 +1,27 @@ +package org.exploit.keeper.module.recovery.pqc.model + +import org.exploit.keeper.model.api.SensitiveResponse + +data class PQCRecoveryBundle( + val peerId: Int, + val targetPeerId: Int, + val keyId: String, + val threshold: Int, + val total: Int, + val currentGeneration: Int, + val pendingGenerations: List, + val generations: List, +) : SensitiveResponse { + override fun destroy() { + generations.forEach { generation -> + generation.publicKey?.fill(0) + generation.commitments?.fill(0) + generation.partyKey?.fill(0) + } + } + + override fun isDestroyed(): Boolean = + generations.all { generation -> + generation.partyKey == null || generation.partyKey.all { it == 0.toByte() } + } +} diff --git a/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/model/PQCRecoveryBundleRequest.kt b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/model/PQCRecoveryBundleRequest.kt new file mode 100644 index 00000000..a4b24841 --- /dev/null +++ b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/model/PQCRecoveryBundleRequest.kt @@ -0,0 +1,6 @@ +package org.exploit.keeper.module.recovery.pqc.model + +data class PQCRecoveryBundleRequest( + val keyId: String, + val targetPeerId: Int, +) \ No newline at end of file diff --git a/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/model/PQCRecoveryGeneration.kt b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/model/PQCRecoveryGeneration.kt new file mode 100644 index 00000000..2ffe472d --- /dev/null +++ b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/model/PQCRecoveryGeneration.kt @@ -0,0 +1,14 @@ +package org.exploit.keeper.module.recovery.pqc.model + +import org.exploit.keeper.model.keyset.KeySetMeta + +class PQCRecoveryGeneration( + val generation: Int, + val destroyed: Boolean, + val destroySessionId: String?, + val algorithm: String?, + val metadata: KeySetMeta, + val publicKey: ByteArray?, + val commitments: ByteArray?, + val partyKey: ByteArray?, +) diff --git a/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/service/PQCRecoveryPeerService.kt b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/service/PQCRecoveryPeerService.kt new file mode 100644 index 00000000..bbf35e89 --- /dev/null +++ b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/service/PQCRecoveryPeerService.kt @@ -0,0 +1,195 @@ +package org.exploit.keeper.module.recovery.pqc.service + +import jakarta.inject.Singleton +import org.exploit.keeper.constant.RecoveryErrorType +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.module.recovery.pqc.model.PQCRecoveryBundle +import org.exploit.keeper.module.recovery.pqc.model.PQCRecoveryBundleRequest +import org.exploit.keeper.module.recovery.pqc.model.PQCRecoveryGeneration +import org.exploit.keeper.module.recovery.pqc.util.PQCRecoveryShares +import org.exploit.keeper.platform.pqc.algorithm.MLDSAKeyAlgorithmProvider +import org.exploit.keeper.platform.pqc.store.MLDSAPublicKeyStore +import org.exploit.keeper.recovery.RecoveryMode +import org.exploit.keeper.service.db.RocksKeyDB +import org.exploit.keeper.service.db.store.KeyVersionStore +import org.exploit.keeper.service.key.KeyAlgorithms +import org.exploit.keeper.util.KeySerializer +import org.exploit.keeper.util.validation.InputValidator +import org.exploit.mldsa.model.key.threshold.ThresholdPartyKey +import org.exploit.mldsa.model.key.threshold.ThresholdPublicKey + +@Singleton +class PQCRecoveryPeerService( + private val mode: RecoveryMode, + private val db: RocksKeyDB, + private val algorithms: KeyAlgorithms, +) { + private val initData by lazy { db.readInitData() } + + fun bundle(request: PQCRecoveryBundleRequest, sourcePeerId: Int): PQCRecoveryBundle { + mode.requireEnabled() + + if (request.targetPeerId != sourcePeerId) + throw TKeeperException(ErrorType.ACCESS_DENIED, "Recovery target must be the authenticated peer") + + if (!InputValidator.isValidKeyId(request.keyId)) + throw TKeeperException(ErrorType.INVALID_KEY_ID) + + if (request.targetPeerId !in 1..initData.total || request.targetPeerId == initData.peerId) + throw TKeeperException(ErrorType.INVALID_PEER_ID) + + if (initData.threshold <= 1 || initData.threshold >= initData.total) + throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "Recovery requires 1 < t < n") + + return db.vault.ops.runInTransaction { tx -> + val versions = db.getStore(KeyVersionStore::class) + val publicKeys = db.getStore(MLDSAPublicKeyStore::class) + + val pending = buildSet { + addAll(versions.pendingGenerations(tx, request.keyId)) + addAll(db.meta.pendingVersionedGenerations(tx, request.keyId)) + db.meta.loadPendingKeysetMeta(tx, request.keyId)?.let { add(it.currentGeneration) } + addAll(publicKeys.pendingPublicGenerations(tx, request.keyId)) + addAll(publicKeys.pendingRecoveryGenerations(tx, request.keyId)) + }.sorted() + + val current = db.vault.readLatestVersion(tx, request.keyId).activeGen + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, request.keyId) + + val metadata = db.meta.loadAllVersionedMeta(tx, request.keyId) + + if (metadata.isEmpty() || current !in metadata) + throw TKeeperException(ErrorType.CORRUPTED_KEY_STATE, "Missing versioned metadata") + + if (metadata.size > MAX_RECOVERY_GENERATIONS) + throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "ML-DSA recovery history is too large") + + val sourceGenerations = metadata.keys + val sourceActive = metadata.filterValues { !it.destroyed }.keys + val sourceDestroyed = metadata.filterValues { it.destroyed }.keys + + if (versions.activeGenerations(tx, request.keyId).toSet() != sourceActive) + throw TKeeperException(ErrorType.CORRUPTED_KEY_STATE, "ML-DSA material inventory mismatch") + + if (!sourceGenerations.containsAll(publicKeys.activePublicGenerations(tx, request.keyId)) || + !sourceGenerations.containsAll(publicKeys.activeRecoveryGenerations(tx, request.keyId)) + ) throw TKeeperException(ErrorType.CORRUPTED_KEY_STATE, "ML-DSA public side-state is outside inventory") + + if (db.destroy.committedGenerations(tx, request.keyId) != sourceDestroyed) + throw TKeeperException(ErrorType.CORRUPTED_KEY_STATE, "ML-DSA destroy inventory mismatch") + + val generations = ArrayList(metadata.size) + + try { + metadata.entries.sortedBy { it.key }.forEach { (generation, meta) -> + if (db.destroy.findActiveSessionId(tx, request.keyId, generation) != null) + throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "Destroy session is active") + + val destroySessionId = db.destroy.committedSessionId(tx, request.keyId, generation) + if ((destroySessionId != null) != meta.destroyed) + throw TKeeperException(ErrorType.CORRUPTED_KEY_STATE, "Destroy state mismatch") + + generations += if (meta.destroyed) { + PQCRecoveryGeneration(generation, true, destroySessionId, null, meta, null, null, null) + } else { + activeGeneration(request, generation, meta, versions, publicKeys, tx) + } + } + + PQCRecoveryBundle( + peerId = initData.peerId, + targetPeerId = request.targetPeerId, + keyId = request.keyId, + threshold = initData.threshold, + total = initData.total, + currentGeneration = current, + pendingGenerations = pending, + generations = generations, + ) + } catch (t: Throwable) { + generations.forEach { it.partyKey?.fill(0) } + throw t + } + } + } + + private fun activeGeneration( + request: PQCRecoveryBundleRequest, + generation: Int, + metadata: org.exploit.keeper.model.keyset.KeySetMeta, + versions: KeyVersionStore, + publicKeys: MLDSAPublicKeyStore, + tx: org.rocksdb.Transaction, + ): PQCRecoveryGeneration { + val record = versions.readActive(tx, request.keyId, generation) + ?: throw TKeeperException(ErrorType.CORRUPTED_KEY_STATE, "Missing ML-DSA party key") + + if (!record.signed) { + record.data.fill(0) + throw TKeeperException(ErrorType.TAMPERED_KEEPER, "Unsigned ML-DSA recovery generation") + } + + val stored = try { + KeySerializer.deserialize(record.data) + } finally { + record.data.fill(0) + } + + try { + val algorithm = algorithms.findOrThrow(stored.algId) as? MLDSAKeyAlgorithmProvider + ?: throw TKeeperException( + RecoveryErrorType.RECOVERY_NOT_POSSIBLE, + "Recovery across ECC and PQC generations is not supported", + ) + + val publicBytes = publicKeys.readActive(tx, request.keyId, generation) + ?: throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "ML-DSA public key is missing") + + val commitments = publicKeys.readActiveRecoveryCommitments(tx, request.keyId, generation) + ?: throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "ML-DSA recovery commitments are missing") + + if (commitments.algorithmId != algorithm.id() || commitments.threshold != initData.threshold || + commitments.total != initData.total + ) throw TKeeperException(ErrorType.CORRUPTED_KEY_STATE, "ML-DSA recovery commitment parameters mismatch") + + val publicKey = ThresholdPublicKey.decode(publicBytes, algorithm.params) + val partyKey = ThresholdPartyKey.decode(stored.rawKey) + + try { + commitments.requireValidPartyKey(publicKey, partyKey, initData.peerId - 1) + + val partial = PQCRecoveryShares.partial( + partyKey, + request.targetPeerId - 1, + initData.peerId - 1, + initData.threshold, + initData.total, + ) + + return PQCRecoveryGeneration( + generation = generation, + destroyed = false, + destroySessionId = null, + algorithm = algorithm.name(), + metadata = metadata, + publicKey = publicBytes, + commitments = commitments.encode(), + partyKey = partial, + ) + } catch (t: Throwable) { + publicBytes.fill(0) + throw t + } finally { + partyKey.destroy() + publicKey.destroy() + } + } finally { + stored.destroy() + } + } + + private companion object { + const val MAX_RECOVERY_GENERATIONS = 64 + } +} diff --git a/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/service/PQCRecoveryService.kt b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/service/PQCRecoveryService.kt new file mode 100644 index 00000000..9b09b2cb --- /dev/null +++ b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/service/PQCRecoveryService.kt @@ -0,0 +1,276 @@ +package org.exploit.keeper.module.recovery.pqc.service + +import jakarta.inject.Singleton +import org.exploit.keeper.api.client.TKeeperClient +import org.exploit.keeper.constant.RecoveryErrorType +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.model.recovery.RecoverRequest +import org.exploit.keeper.model.recovery.RecoverResult +import org.exploit.keeper.module.recovery.pqc.api.PQCRecoveryApi +import org.exploit.keeper.module.recovery.pqc.model.PQCRecoveryBundle +import org.exploit.keeper.module.recovery.pqc.model.PQCRecoveryBundleRequest +import org.exploit.keeper.module.recovery.pqc.model.PQCRecoveryGeneration +import org.exploit.keeper.module.recovery.pqc.util.PQCRecoveryShares +import org.exploit.keeper.platform.pqc.algorithm.MLDSAKeyAlgorithmProvider +import org.exploit.keeper.platform.pqc.model.recovery.MLDSARecoveryCommitmentSet +import org.exploit.keeper.platform.pqc.store.MLDSAPublicKeyStore +import org.exploit.keeper.recovery.RecoveryMode +import org.exploit.keeper.recovery.RecoveryStateReset +import org.exploit.keeper.recovery.handler.RecoveryHandler +import org.exploit.keeper.service.client.TKeeperClients +import org.exploit.keeper.service.db.RocksKeyDB +import org.exploit.keeper.service.db.store.KeyVersionStore +import org.exploit.keeper.service.key.KeyAlgorithmProvider +import org.exploit.keeper.service.key.KeyAlgorithms +import org.exploit.keeper.util.KeySerializer +import org.exploit.keeper.util.extension.succeed +import org.exploit.keeper.util.sameRecoveryMetadata +import org.exploit.keeper.util.validation.InputValidator +import org.exploit.mldsa.model.key.threshold.ThresholdPublicKey +import java.security.MessageDigest +import java.util.concurrent.CompletableFuture + +@Singleton +class PQCRecoveryService( + private val mode: RecoveryMode, + private val db: RocksKeyDB, + private val clients: TKeeperClients, + private val algorithms: KeyAlgorithms, + private val stateReset: RecoveryStateReset, +) : RecoveryHandler { + private val initData by lazy { db.readInitData() } + + override fun supports(algorithm: KeyAlgorithmProvider): Boolean = + algorithm is MLDSAKeyAlgorithmProvider + + override fun recover(request: RecoverRequest, algorithm: KeyAlgorithmProvider): RecoverResult { + mode.requireEnabled() + val mldsa = algorithm as? MLDSAKeyAlgorithmProvider + ?: throw TKeeperException(ErrorType.UNSUPPORTED_KEY_ALGORITHM) + if (!InputValidator.isValidKeyId(request.keyId)) + throw TKeeperException(ErrorType.INVALID_KEY_ID) + if (initData.threshold <= 1 || initData.threshold >= initData.total) + throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "Recovery requires 1 < t < n") + + val helpers = selectHelpers(request.helperIds) + val bundles = loadBundles(request.keyId, helpers) + + val expected = bundles.first() + + val recovered = ArrayList() + try { + expected.generations.filterNot { it.destroyed }.forEach { generation -> + val generationAlgorithm = algorithms.findOrThrow( + generation.algorithm ?: throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Missing ML-DSA algorithm") + ) as? MLDSAKeyAlgorithmProvider ?: throw TKeeperException( + RecoveryErrorType.RECOVERY_NOT_POSSIBLE, + "Recovery across ECC and PQC generations is not supported", + ) + + recovered += recoverGeneration(generation, bundles, generationAlgorithm) + } + + install(request.keyId, expected, recovered) + + return RecoverResult( + keyId = request.keyId, + algorithm = mldsa.name(), + targetPeerId = initData.peerId, + currentGeneration = expected.currentGeneration, + recoveredGenerations = recovered.map { it.generation }, + helpers = helpers.map { it.peerId }, + ) + } finally { + recovered.forEach { it.partyKey.fill(0) } + bundles.forEach(PQCRecoveryBundle::destroy) + } + } + + private fun selectHelpers(requested: List): List { + val ids = requested.sorted() + + if (requested != ids || ids.distinct().size != ids.size || ids.size != initData.threshold || + initData.peerId in ids || ids.any { it !in 1..initData.total } + ) throw TKeeperException(ErrorType.INVALID_THRESHOLD, "Expected exactly ${initData.threshold} canonical helpers") + + val selected = clients.find(ids) + + if (selected.size != ids.size) + throw TKeeperException(ErrorType.NOT_ENOUGH_CONFIGURED_CLIENTS) + + return selected.sortedBy { it.peerId } + } + + private fun loadBundles(keyId: String, helpers: List): List { + val tasks = ArrayList>>(helpers.size) + try { + helpers.forEach { peer -> + tasks += peer.peerId to peer.internalClient(PQCRecoveryApi::class.java) + .bundle(PQCRecoveryBundleRequest(keyId, initData.peerId)) + .succeed() + } + CompletableFuture.allOf(*tasks.map { it.second }.toTypedArray()).join() + } catch (t: Throwable) { + settleAndDestroy(tasks) + throw t + } + val bundles = helpers.map { peer -> + tasks.single { it.first == peer.peerId }.second.join() + } + try { + bundles.forEachIndexed { index, bundle -> + if (bundle.peerId != helpers[index].peerId || bundle.targetPeerId != initData.peerId || + bundle.keyId != keyId || bundle.threshold != initData.threshold || bundle.total != initData.total + ) throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Invalid PQC recovery bundle identity") + if (bundle.pendingGenerations.isNotEmpty()) + throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "Pending generations must be resolved first") + } + val expected = bundles.first() + if (expected.generations.none { it.generation == expected.currentGeneration && !it.destroyed }) + throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Current generation is not recoverable") + if (bundles.drop(1).any { !sameManifest(expected, it) }) + throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "PQC recovery helpers disagree") + return bundles + } catch (t: Throwable) { + bundles.forEach(PQCRecoveryBundle::destroy) + throw t + } + } + + private fun settleAndDestroy(tasks: List>>) { + tasks.map { it.second.handle { value, _ -> value } } + .let { settled -> CompletableFuture.allOf(*settled.toTypedArray()).join(); settled } + .mapNotNull { it.join() } + .forEach(PQCRecoveryBundle::destroy) + } + + private fun sameManifest(left: PQCRecoveryBundle, right: PQCRecoveryBundle): Boolean { + if (left.targetPeerId != right.targetPeerId || left.keyId != right.keyId || + left.threshold != right.threshold || left.total != right.total || + left.currentGeneration != right.currentGeneration || + left.pendingGenerations != right.pendingGenerations || left.generations.size != right.generations.size + ) return false + return left.generations.zip(right.generations).all { (a, b) -> + a.generation == b.generation && a.destroyed == b.destroyed && + a.destroySessionId == b.destroySessionId && + nullableEquals(a.algorithm, b.algorithm) && sameRecoveryMetadata(a.metadata, b.metadata) && + bytesEqual(a.publicKey, b.publicKey) && bytesEqual(a.commitments, b.commitments) + } + } + + private fun recoverGeneration( + generation: PQCRecoveryGeneration, + bundles: List, + algorithm: MLDSAKeyAlgorithmProvider, + ): RecoveredGeneration { + val publicBytes = generation.publicKey + ?: throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "ML-DSA public key is missing") + val commitmentBytes = generation.commitments + ?: throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "ML-DSA recovery commitments are missing") + val publicKey = try { + ThresholdPublicKey.decode(publicBytes, algorithm.params) + } catch (e: Exception) { + throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Invalid ML-DSA public key", e) + } + try { + val commitments = decodeCommitments(commitmentBytes, algorithm) + val parts = bundles.map { bundle -> + val item = bundle.generations.singleOrNull { it.generation == generation.generation } + ?: throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Missing ML-DSA recovery generation") + val encoded = item.partyKey + ?: throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Missing ML-DSA partial party key") + bundle.peerId - 1 to encoded + } + val recovered = try { + PQCRecoveryShares.merge(publicKey, commitments, initData.peerId - 1, parts) + } catch (e: Exception) { + throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Invalid ML-DSA recovery shares", e) + } + return RecoveredGeneration(generation.generation, algorithm.id(), recovered) + } finally { + publicKey.destroy() + } + } + + private fun install( + keyId: String, + source: PQCRecoveryBundle, + recovered: List, + ) { + val expectedActive = source.generations.filterNot { it.destroyed }.map { it.generation }.toSet() + if (recovered.map { it.generation }.toSet() != expectedActive) + throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Incomplete ML-DSA recovery result") + + db.vault.ops.runInTransaction { tx -> + val versions = db.getStore(KeyVersionStore::class) + val publicKeys = db.getStore(MLDSAPublicKeyStore::class) + stateReset.clear(tx, keyId) + + val recoveredByGeneration = recovered.associateBy { it.generation } + source.generations.forEach { manifest -> + db.meta.saveVersionedMeta(tx, manifest.metadata) + + if (manifest.destroyed) { + val expectedSession = requireNotNull(manifest.destroySessionId) { + "Destroyed generation is missing its session id" + } + db.destroy.markCommitted(tx, keyId, manifest.generation, expectedSession) + return@forEach + } + + val publicKey = manifest.publicKey + ?: throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Missing ML-DSA public key") + val generationAlgorithm = algorithms.findOrThrow( + manifest.algorithm ?: throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Missing ML-DSA algorithm") + ) as? MLDSAKeyAlgorithmProvider + ?: throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "Mixed recovery history") + val commitments = decodeCommitments(manifest.commitments, generationAlgorithm) + publicKeys.saveActiveIfMissing(tx, keyId, manifest.generation, publicKey) + publicKeys.saveActiveRecoveryCommitmentsIfMissing(tx, keyId, manifest.generation, commitments) + + val item = recoveredByGeneration.getValue(manifest.generation) + val serialized = KeySerializer.serialize(item.algorithmId, item.partyKey) + try { + versions.saveActive(tx, keyId, item.generation, serialized) + } finally { + serialized.fill(0) + } + } + + val currentMeta = source.generations.single { it.generation == source.currentGeneration }.metadata + versions.setCurrentGeneration(tx, keyId, source.currentGeneration) + db.meta.saveKeysetMeta(tx, currentMeta) + source.generations.filter { it.generation != source.currentGeneration }.forEach { + db.expire.syncNonHead(tx, keyId, it.generation, it.metadata.policy) + } + } + } + + private fun decodeCommitments( + encoded: ByteArray?, + algorithm: MLDSAKeyAlgorithmProvider, + ): MLDSARecoveryCommitmentSet { + val commitments = try { + MLDSARecoveryCommitmentSet.decode(encoded ?: throw IllegalArgumentException("Missing commitments")) + } catch (e: Exception) { + throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Invalid ML-DSA recovery commitments", e) + } + if (commitments.algorithmId != algorithm.id() || commitments.threshold != initData.threshold || + commitments.total != initData.total + ) throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "ML-DSA recovery commitment parameters mismatch") + return commitments + } + + private fun nullableEquals(left: String?, right: String?): Boolean = + left == null && right == null || left != null && right != null && left.equals(right, ignoreCase = true) + + private fun bytesEqual(left: ByteArray?, right: ByteArray?): Boolean = + left == null && right == null || left != null && right != null && MessageDigest.isEqual(left, right) + + private data class RecoveredGeneration( + val generation: Int, + val algorithmId: Byte, + val partyKey: ByteArray, + ) +} diff --git a/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/util/PQCRecoveryShares.kt b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/util/PQCRecoveryShares.kt new file mode 100644 index 00000000..5ab5995d --- /dev/null +++ b/features/recovery/pqc/src/main/kotlin/org/exploit/keeper/module/recovery/pqc/util/PQCRecoveryShares.kt @@ -0,0 +1,137 @@ +package org.exploit.keeper.module.recovery.pqc.util + +import org.exploit.keeper.platform.pqc.model.recovery.MLDSARecoveryCommitmentSet +import org.exploit.mldsa.constant.MLDSAConstant +import org.exploit.mldsa.model.Polynomial +import org.exploit.mldsa.model.ShareKey +import org.exploit.mldsa.model.key.threshold.ThresholdPartyKey +import org.exploit.mldsa.model.key.threshold.ThresholdPublicKey +import org.exploit.mldsa.rss.RssSecretSharing +import org.exploit.mldsa.util.Destroyer + +internal object PQCRecoveryShares { + fun partial( + source: ThresholdPartyKey, + targetPartyIndex: Int, + sourcePartyIndex: Int, + threshold: Int, + total: Int, + ): ByteArray { + require(source.partyIndex() == sourcePartyIndex) + val expected = expectedSubsets(targetPartyIndex, sourcePartyIndex, threshold, total) + val selected = LinkedHashMap>() + val shares = source.ski() + try { + shares.forEach { (subset, value) -> + val indices = subset.indices().toList() + if (indices in expected) { + selected[ShareKey(indices.toIntArray())] = Array(value.size) { value[it].copy() } + } + } + val partyKey = ThresholdPartyKey( + source.params(), + targetPartyIndex, + source.coefficientBound(), + selected, + ) + return try { + partyKey.encode() + } finally { + partyKey.destroy() + } + } finally { + Destroyer.destroyPolyMap(shares) + Destroyer.destroyPolyMap(selected) + } + } + + fun merge( + publicKey: ThresholdPublicKey, + commitments: MLDSARecoveryCommitmentSet, + targetPartyIndex: Int, + parts: List>, + ): ByteArray { + require(parts.size == commitments.threshold) { "Expected exactly threshold ML-DSA recovery helpers" } + require(parts.map { it.first }.distinct().size == parts.size) + require(parts.all { it.first in 0 until commitments.total && it.first != targetPartyIndex }) + val merged = LinkedHashMap, Array>() + var coefficientBound: Int? = null + try { + parts.forEach { (sourcePartyIndex, encoded) -> + val partyKey = ThresholdPartyKey.decode(encoded) + try { + commitments.requireValidPartyKey(publicKey, partyKey, targetPartyIndex, sourcePartyIndex) + if (coefficientBound != null && coefficientBound != partyKey.coefficientBound()) + throw IllegalArgumentException("ML-DSA recovery coefficient bounds disagree") + coefficientBound = partyKey.coefficientBound() + + val shares = partyKey.ski() + try { + shares.forEach { (subset, value) -> + val indices = subset.indices().toList() + val existing = merged[indices] + if (existing == null) { + merged[indices] = Array(value.size) { value[it].copy() } + } else { + require(samePolynomials(existing, value)) { + "Conflicting replicated ML-DSA recovery share" + } + } + } + } finally { + Destroyer.destroyPolyMap(shares) + } + } finally { + partyKey.destroy() + } + } + + val expected = expectedSubsets(targetPartyIndex, null, commitments.threshold, commitments.total) + require(merged.keys == expected) { "Incomplete ML-DSA recovery share coverage" } + val shareMap = LinkedHashMap>(merged.size) + try { + merged.forEach { (indices, value) -> shareMap[ShareKey(indices.toIntArray())] = value } + val recovered = ThresholdPartyKey( + publicKey.params(), + targetPartyIndex, + coefficientBound ?: throw IllegalArgumentException("No ML-DSA recovery shares"), + shareMap, + ) + return try { + commitments.requireValidPartyKey(publicKey, recovered, targetPartyIndex) + recovered.encode() + } finally { + recovered.destroy() + } + } finally { + shareMap.keys.forEach(ShareKey::destroy) + } + } finally { + merged.values.forEach { polynomials -> polynomials.forEach(Polynomial::destroy) } + } + } + + private fun expectedSubsets( + targetPartyIndex: Int, + sourcePartyIndex: Int?, + threshold: Int, + total: Int, + ): Set> = + RssSecretSharing.allSubsets(total, total - threshold + 1) + .map { subset -> + try { + subset.indices().toList() + } finally { + subset.destroy() + } + } + .filter { targetPartyIndex in it && (sourcePartyIndex == null || sourcePartyIndex in it) } + .toSet() + + private fun samePolynomials(left: Array, right: Array): Boolean = + left.size == right.size && left.indices.all { polynomial -> + (0 until MLDSAConstant.N).all { coefficient -> + left[polynomial].get(coefficient) == right[polynomial].get(coefficient) + } + } +} diff --git a/features/recovery/pqc/src/test/kotlin/org/exploit/keeper/module/recovery/pqc/fuzz/PQCRecoveryPayloadFuzzTest.kt b/features/recovery/pqc/src/test/kotlin/org/exploit/keeper/module/recovery/pqc/fuzz/PQCRecoveryPayloadFuzzTest.kt new file mode 100644 index 00000000..41667e44 --- /dev/null +++ b/features/recovery/pqc/src/test/kotlin/org/exploit/keeper/module/recovery/pqc/fuzz/PQCRecoveryPayloadFuzzTest.kt @@ -0,0 +1,177 @@ +package org.exploit.keeper.module.recovery.pqc.fuzz + +import com.code_intelligence.jazzer.junit.FuzzTest +import org.exploit.keeper.module.recovery.pqc.util.PQCRecoveryShares +import org.exploit.keeper.platform.pqc.algorithm.MLDSA44KeyAlgorithmProvider +import org.exploit.keeper.platform.pqc.model.recovery.MLDSARecoveryCommitmentSet +import org.exploit.mldsa.constant.MLDSAParameters +import org.exploit.mldsa.keygen.ThresholdMLDSAKeySplitter +import org.exploit.mldsa.model.key.MLDSAPrivateKey +import org.exploit.mldsa.model.key.threshold.ThresholdPublicKey +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.params.provider.MethodSource +import java.security.MessageDigest +import java.util.stream.Stream +import kotlin.test.assertContentEquals +import kotlin.test.assertTrue + +class PQCRecoveryPayloadFuzzTest { + @MethodSource("seeds") + @FuzzTest(maxDuration = "30s") + fun hostileRecoveryPayloadsFailClosed(input: ByteArray) { + if (input.size > MAX_INPUT_SIZE) return + + val data = if (input.isEmpty()) byteArrayOf(0) else input + when (unsigned(data[0]) % 2) { + 0 -> fuzzCommitmentEncoding(data.copyOfRange(1, data.size)) + else -> fuzzPartialPartyKeys(data) + } + } + + private fun fuzzCommitmentEncoding(encoded: ByteArray) { + val decoded = try { + MLDSARecoveryCommitmentSet.decode(encoded) + } catch (_: IllegalArgumentException) { + return + } + + assertContentEquals(encoded, decoded.encode()) + } + + private fun fuzzPartialPartyKeys(input: ByteArray) { + val fixture = FIXTURE + val parts = fixture.parts.map { it.first to it.second.copyOf() }.toMutableList() + val publicKey = ThresholdPublicKey.decode(fixture.publicKey, MLDSAParameters.ML_DSA_44) + var recovered: ByteArray? = null + try { + when (unsigned(input.at(1)) % 8) { + 0 -> Unit + 1 -> replacePart(parts, 0, input.copyOfRange(minOf(2, input.size), input.size)) + 2 -> { + val mutated = parts[0].second.copyOf() + val index = unsigned(input.at(2)) % mutated.size + mutated[index] = (mutated[index].toInt() xor (1 shl (unsigned(input.at(3)) % 8))).toByte() + replacePart(parts, 0, mutated) + } + 3 -> { + val length = ((unsigned(input.at(2)) shl 8) or unsigned(input.at(3))) % + (parts[0].second.size + 1) + replacePart(parts, 0, parts[0].second.copyOf(length)) + } + 4 -> { + val tail = input.copyOfRange(minOf(2, input.size), input.size) + val suffix = if (tail.isEmpty()) byteArrayOf(0) else tail + replacePart(parts, 0, parts[0].second + suffix) + } + 5 -> parts[1] = parts[0].first to parts[1].second + 6 -> parts[0] = TOTAL + unsigned(input.at(2)) to parts[0].second + else -> { + val left = parts[0] + val right = parts[1] + parts[0] = left.first to right.second + parts[1] = right.first to left.second + } + } + + val commitments = MLDSARecoveryCommitmentSet.decode(fixture.commitments) + recovered = try { + PQCRecoveryShares.merge(publicKey, commitments, TARGET, parts) + } catch (_: IllegalArgumentException) { + return + } + + assertTrue( + MessageDigest.isEqual(fixture.expected, recovered), + "Accepted PQC recovery payload reconstructed a different party key", + ) + } finally { + recovered?.fill(0) + parts.forEach { it.second.fill(0) } + publicKey.destroy() + } + } + + private fun replacePart(parts: MutableList>, index: Int, replacement: ByteArray) { + val source = parts[index] + source.second.fill(0) + parts[index] = source.first to replacement + } + + private fun unsigned(value: Byte): Int = value.toInt() and 0xff + + private fun ByteArray.at(index: Int): Byte = this[index % size] + + private data class Fixture( + val publicKey: ByteArray, + val commitments: ByteArray, + val expected: ByteArray, + val parts: List>, + ) { + fun destroy() { + publicKey.fill(0) + commitments.fill(0) + expected.fill(0) + parts.forEach { it.second.fill(0) } + } + } + + companion object { + private const val MAX_INPUT_SIZE = 64 * 1024 + private const val TARGET = 0 + private const val THRESHOLD = 2 + private const val TOTAL = 3 + private val FIXTURE_LAZY = lazy(::createFixture) + private val FIXTURE by FIXTURE_LAZY + + private fun createFixture(): Fixture { + val algorithm = MLDSA44KeyAlgorithmProvider() + val keyPair = algorithm.factory().generate() + val thresholdKey = ThresholdMLDSAKeySplitter.split( + keyPair.privateKey() as MLDSAPrivateKey, + TOTAL, + THRESHOLD, + ) + try { + val commitments = MLDSARecoveryCommitmentSet.fromPartyKeys( + algorithm.id(), + THRESHOLD, + TOTAL, + thresholdKey.publicKey(), + thresholdKey.partyKeys(), + ) + return Fixture( + publicKey = thresholdKey.publicKey().encode(), + commitments = commitments.encode(), + expected = thresholdKey.partyKey(TARGET).encode(), + parts = (1 until TOTAL).map { source -> + source to PQCRecoveryShares.partial( + thresholdKey.partyKey(source), + TARGET, + source, + THRESHOLD, + TOTAL, + ) + }, + ) + } finally { + thresholdKey.destroy() + keyPair.destroy() + } + } + + @JvmStatic + fun seeds(): Stream = Stream.of( + byteArrayOf(0), + byteArrayOf(0) + FIXTURE.commitments, + byteArrayOf(1, 0), + byteArrayOf(1, 2, 0, 0), + byteArrayOf(1, 5), + ) + + @JvmStatic + @AfterAll + fun destroyFixture() { + if (FIXTURE_LAZY.isInitialized()) FIXTURE.destroy() + } + } +} diff --git a/features/recovery/pqc/src/test/kotlin/org/exploit/keeper/module/recovery/pqc/service/PQCRecoverySharesTest.kt b/features/recovery/pqc/src/test/kotlin/org/exploit/keeper/module/recovery/pqc/service/PQCRecoverySharesTest.kt new file mode 100644 index 00000000..4fe3eca7 --- /dev/null +++ b/features/recovery/pqc/src/test/kotlin/org/exploit/keeper/module/recovery/pqc/service/PQCRecoverySharesTest.kt @@ -0,0 +1,119 @@ +package org.exploit.keeper.module.recovery.pqc.service + +import org.exploit.keeper.module.recovery.pqc.util.PQCRecoveryShares +import org.exploit.keeper.platform.pqc.algorithm.MLDSA44KeyAlgorithmProvider +import org.exploit.keeper.platform.pqc.model.recovery.MLDSARecoveryCommitmentSet +import org.exploit.mldsa.keygen.ThresholdMLDSAKeySplitter +import org.exploit.mldsa.model.key.MLDSAPrivateKey +import java.security.MessageDigest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class PQCRecoverySharesTest { + @Test + fun `any threshold helpers reconstruct every target party key`() { + val algorithm = MLDSA44KeyAlgorithmProvider() + val keyPair = algorithm.factory().generate() + try { + for ((threshold, total) in listOf(2 to 3, 2 to 6, 3 to 6, 5 to 6)) { + val thresholdKey = ThresholdMLDSAKeySplitter.split(keyPair.privateKey() as MLDSAPrivateKey, total, threshold) + try { + val commitments = MLDSARecoveryCommitmentSet.fromPartyKeys( + algorithm.id(), threshold, total, thresholdKey.publicKey(), thresholdKey.partyKeys() + ) + for (target in 0 until total) { + combinations((0 until total).filter { it != target }, threshold).forEach { helpers -> + val parts = helpers.map { source -> + source to PQCRecoveryShares.partial( + thresholdKey.partyKey(source), target, source, threshold, total + ) + } + try { + val recovered = PQCRecoveryShares.merge( + thresholdKey.publicKey(), commitments, target, parts + ) + val expected = thresholdKey.partyKey(target).encode() + try { + assertTrue(MessageDigest.isEqual(expected, recovered)) + } finally { + expected.fill(0) + recovered.fill(0) + } + } finally { + parts.forEach { it.second.fill(0) } + } + } + } + } finally { + thresholdKey.destroy() + } + } + } finally { + keyPair.destroy() + } + } + + @Test + fun `fewer than threshold helpers cannot reconstruct target`() { + val algorithm = MLDSA44KeyAlgorithmProvider() + val keyPair = algorithm.factory().generate() + val thresholdKey = ThresholdMLDSAKeySplitter.split(keyPair.privateKey() as MLDSAPrivateKey, 4, 3) + try { + val commitments = MLDSARecoveryCommitmentSet.fromPartyKeys( + algorithm.id(), 3, 4, thresholdKey.publicKey(), thresholdKey.partyKeys() + ) + val parts = listOf(1, 2).map { source -> + source to PQCRecoveryShares.partial(thresholdKey.partyKey(source), 0, source, 3, 4) + } + try { + assertFailsWith { + PQCRecoveryShares.merge(thresholdKey.publicKey(), commitments, 0, parts) + } + } finally { + parts.forEach { it.second.fill(0) } + } + } finally { + thresholdKey.destroy() + keyPair.destroy() + } + } + + @Test + fun `tampered commitment rejects otherwise valid recovery parts`() { + val algorithm = MLDSA44KeyAlgorithmProvider() + val keyPair = algorithm.factory().generate() + val thresholdKey = ThresholdMLDSAKeySplitter.split(keyPair.privateKey() as MLDSAPrivateKey, 3, 2) + try { + val commitments = MLDSARecoveryCommitmentSet.fromPartyKeys( + algorithm.id(), 2, 3, thresholdKey.publicKey(), thresholdKey.partyKeys() + ) + val tamperedBytes = commitments.encode().also { + val firstCommitmentOffset = 4 + 5 + 1 + 2 + it[firstCommitmentOffset] = (it[firstCommitmentOffset].toInt() xor 1).toByte() + } + val tampered = MLDSARecoveryCommitmentSet.decode(tamperedBytes) + val parts = listOf(1, 2).map { source -> + source to PQCRecoveryShares.partial(thresholdKey.partyKey(source), 0, source, 2, 3) + } + try { + assertFailsWith { + PQCRecoveryShares.merge(thresholdKey.publicKey(), tampered, 0, parts) + } + } finally { + parts.forEach { it.second.fill(0) } + tamperedBytes.fill(0) + } + } finally { + thresholdKey.destroy() + keyPair.destroy() + } + } + + private fun combinations(values: List, size: Int): List> { + if (size == 0) return listOf(emptyList()) + return values.indices.flatMap { index -> + combinations(values.drop(index + 1), size - 1).map { listOf(values[index]) + it } + } + } +} diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/api/RecoveryApi.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/api/RecoveryApi.kt new file mode 100644 index 00000000..21eb84ab --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/api/RecoveryApi.kt @@ -0,0 +1,20 @@ +package org.exploit.keeper.api + +import org.eclipse.jetty.http.HttpMethod +import org.exploit.jettyx.annotation.Body +import org.exploit.jettyx.annotation.Header +import org.exploit.jettyx.annotation.HttpRequest +import org.exploit.jettyx.model.HttpResponse +import org.exploit.keeper.constant.media.MsgPack +import org.exploit.keeper.controller.internal.InternalRecoveryController +import org.exploit.keeper.model.recovery.RecoveryDescription +import org.exploit.keeper.model.recovery.RecoveryDescriptionRequest +import java.util.concurrent.CompletableFuture + +interface RecoveryApi { + @HttpRequest(path = InternalRecoveryController.DESCRIBE, method = HttpMethod.POST) + fun describe( + @Body request: RecoveryDescriptionRequest, + @Header("Content-Type") contentType: String = MsgPack.MIME_TEXT, + ): CompletableFuture> +} diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/constant/RecoveryErrorType.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/constant/RecoveryErrorType.kt new file mode 100644 index 00000000..9f966785 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/constant/RecoveryErrorType.kt @@ -0,0 +1,10 @@ +package org.exploit.keeper.constant + +import org.exploit.keeper.model.error.ErrorCode + +object RecoveryErrorType { + val RECOVERY_MODE_ACTIVE = ErrorCode("RECOVERY_MODE_ACTIVE", 503) + val RECOVERY_NOT_ENABLED = ErrorCode("RECOVERY_NOT_ENABLED", 503) + val RECOVERY_NOT_POSSIBLE = ErrorCode("RECOVERY_NOT_POSSIBLE") + val RECOVERY_FAILED = ErrorCode("RECOVERY_FAILED", 500) +} \ No newline at end of file diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/constant/RecoveryPermission.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/constant/RecoveryPermission.kt new file mode 100644 index 00000000..2f040af6 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/constant/RecoveryPermission.kt @@ -0,0 +1,7 @@ +package org.exploit.keeper.constant + +object RecoveryPermission { + private const val RECOVER = "tkeeper.recover" + + fun recover() = RECOVER +} \ No newline at end of file diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/controller/external/RecoveryController.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/controller/external/RecoveryController.kt new file mode 100644 index 00000000..b72dabbb --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/controller/external/RecoveryController.kt @@ -0,0 +1,30 @@ +package org.exploit.keeper.controller.external + +import com.linecorp.armeria.server.ServerBuilder +import jakarta.inject.Singleton +import org.exploit.keeper.constant.RecoveryPermission +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.controller.support.ControllerSupport +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.model.recovery.RecoverRequest +import org.exploit.keeper.recovery.RecoveryService +import org.exploit.keeper.util.extension.http.hasPermission + +@Singleton +class RecoveryController( + private val support: ControllerSupport, + private val recovery: RecoveryService, +) : ExternalController { + override fun register(sb: ServerBuilder) = with(support) { + sb.publicPost(RECOVER, RecoverRequest::class.java, ensureReady = true) { ctx, body -> + if (!ctx.hasPermission(RecoveryPermission.recover())) + throw TKeeperException(ErrorType.ACCESS_DENIED) + + recovery.recover(body) + } + } + + companion object { + const val RECOVER = "/v1/keeper/recovery/recover" + } +} diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/controller/internal/InternalRecoveryController.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/controller/internal/InternalRecoveryController.kt new file mode 100644 index 00000000..86ea20f9 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/controller/internal/InternalRecoveryController.kt @@ -0,0 +1,36 @@ +package org.exploit.keeper.controller.internal + +import com.linecorp.armeria.server.ServerBuilder +import com.linecorp.armeria.server.ServiceRequestContext +import jakarta.inject.Singleton +import org.exploit.keeper.constant.RecoveryPermission +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.controller.support.ControllerSupport +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.model.recovery.RecoveryDescriptionRequest +import org.exploit.keeper.recovery.RecoveryService +import org.exploit.keeper.util.extension.http.authData +import org.exploit.keeper.util.extension.http.hasPermission + +@Singleton +class InternalRecoveryController( + private val support: ControllerSupport, + private val recovery: RecoveryService, +) : InternalController { + override fun register(sb: ServerBuilder) = with(support) { + sb.internalPost(DESCRIBE, RecoveryDescriptionRequest::class.java, expectQuorum = false) { ctx, body -> + if (!ctx.hasPermission(RecoveryPermission.recover())) + throw TKeeperException(ErrorType.ACCESS_DENIED) + + recovery.describe(body, sourcePeerId(ctx)) + } + } + + private fun sourcePeerId(ctx: ServiceRequestContext): Int = + ctx.authData()?.subject?.removePrefix("keeper-")?.toIntOrNull() + ?: throw TKeeperException(ErrorType.ACCESS_DENIED) + + companion object { + const val DESCRIBE = "/v1/recovery/describe" + } +} diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/model/recovery/RecoverRequest.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/model/recovery/RecoverRequest.kt new file mode 100644 index 00000000..b488606a --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/model/recovery/RecoverRequest.kt @@ -0,0 +1,6 @@ +package org.exploit.keeper.model.recovery + +data class RecoverRequest( + val keyId: String, + val helperIds: List +) diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/model/recovery/RecoverResult.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/model/recovery/RecoverResult.kt new file mode 100644 index 00000000..47645738 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/model/recovery/RecoverResult.kt @@ -0,0 +1,10 @@ +package org.exploit.keeper.model.recovery + +data class RecoverResult( + val keyId: String, + val algorithm: String, + val targetPeerId: Int, + val currentGeneration: Int, + val recoveredGenerations: List, + val helpers: List +) diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/model/recovery/RecoveryDescription.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/model/recovery/RecoveryDescription.kt new file mode 100644 index 00000000..c83612c9 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/model/recovery/RecoveryDescription.kt @@ -0,0 +1,11 @@ +package org.exploit.keeper.model.recovery + +data class RecoveryDescription( + val peerId: Int, + val targetPeerId: Int, + val keyId: String, + val threshold: Int, + val total: Int, + val currentGeneration: Int, + val algorithm: String +) diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/model/recovery/RecoveryDescriptionRequest.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/model/recovery/RecoveryDescriptionRequest.kt new file mode 100644 index 00000000..5909ec78 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/model/recovery/RecoveryDescriptionRequest.kt @@ -0,0 +1,6 @@ +package org.exploit.keeper.model.recovery + +data class RecoveryDescriptionRequest( + val keyId: String, + val targetPeerId: Int +) diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/model/route/RecoveryRoute.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/model/route/RecoveryRoute.kt new file mode 100644 index 00000000..817282d4 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/model/route/RecoveryRoute.kt @@ -0,0 +1,5 @@ +package org.exploit.keeper.model.route + +import com.linecorp.armeria.common.HttpMethod + +data class RecoveryRoute(val method: HttpMethod, val path: String) \ No newline at end of file diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/RecoveryMode.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/RecoveryMode.kt new file mode 100644 index 00000000..a51a0ff2 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/RecoveryMode.kt @@ -0,0 +1,14 @@ +package org.exploit.keeper.recovery + +import jakarta.inject.Singleton +import org.exploit.keeper.constant.RecoveryErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.recovery.config.RecoveryConfig + +@Singleton +class RecoveryMode(private val config: RecoveryConfig) { + fun requireEnabled() { + if (!config.recovery) + throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_ENABLED) + } +} diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/RecoveryService.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/RecoveryService.kt new file mode 100644 index 00000000..237aadd7 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/RecoveryService.kt @@ -0,0 +1,127 @@ +package org.exploit.keeper.recovery + +import jakarta.inject.Singleton +import org.exploit.keeper.api.RecoveryApi +import org.exploit.keeper.api.client.TKeeperClient +import org.exploit.keeper.constant.RecoveryErrorType +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.model.recovery.RecoverRequest +import org.exploit.keeper.model.recovery.RecoverResult +import org.exploit.keeper.model.recovery.RecoveryDescription +import org.exploit.keeper.model.recovery.RecoveryDescriptionRequest +import org.exploit.keeper.recovery.handler.RecoveryHandler +import org.exploit.keeper.service.client.TKeeperClients +import org.exploit.keeper.service.db.RocksKeyDB +import org.exploit.keeper.service.key.KeyAlgorithms +import org.exploit.keeper.util.KeySerializer +import org.exploit.keeper.util.extension.performIndexed +import org.exploit.keeper.util.validation.InputValidator + +@Singleton +class RecoveryService( + private val mode: RecoveryMode, + private val db: RocksKeyDB, + private val clients: TKeeperClients, + private val algorithms: KeyAlgorithms, + private val handlers: List, +) { + private val initData by lazy { + db.readInitData() + } + + fun recover(request: RecoverRequest): RecoverResult { + mode.requireEnabled() + + if (!InputValidator.isValidKeyId(request.keyId)) + throw TKeeperException(ErrorType.INVALID_KEY_ID) + + if (initData.threshold <= 1 || initData.threshold >= initData.total) + throw TKeeperException(RecoveryErrorType.RECOVERY_NOT_POSSIBLE, "Recovery requires 1 < t < n") + + val helpers = selectHelpers(request.helperIds) + val description = describe(request.keyId, helpers) + val algorithm = algorithms.findOrThrow(description.algorithm) + + val handler = handlers.singleOrNull { it.supports(algorithm) } + ?: throw TKeeperException( + RecoveryErrorType.RECOVERY_NOT_POSSIBLE, + "No module supports ${algorithm.name()} recovery", + ) + + return handler.recover(request.copy(helperIds = helpers.map { it.peerId }), algorithm) + } + + fun describe(request: RecoveryDescriptionRequest, sourcePeerId: Int): RecoveryDescription { + mode.requireEnabled() + + if (!InputValidator.isValidKeyId(request.keyId)) + throw TKeeperException(ErrorType.INVALID_KEY_ID) + + if (request.targetPeerId != sourcePeerId) + throw TKeeperException(ErrorType.ACCESS_DENIED, "Recovery target must be the authenticated peer") + + return db.vault.ops.runInTransaction { tx -> + val current = db.vault.readLatestVersion(tx, request.keyId).activeGen + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, request.keyId) + val raw = db.vault.readKey(tx, request.keyId, current) + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, "${request.keyId}/$current") + val algorithm = try { + algorithms.findOrThrow(KeySerializer.extractAlgId(raw)).name() + } finally { + raw.fill(0) + } + + RecoveryDescription( + peerId = initData.peerId, + targetPeerId = request.targetPeerId, + keyId = request.keyId, + threshold = initData.threshold, + total = initData.total, + currentGeneration = current, + algorithm = algorithm, + ) + } + } + + private fun describe(keyId: String, helpers: List): RecoveryDescription { + val responses = helpers.performIndexed { peer -> + peer.internalClient(RecoveryApi::class.java).describe( + RecoveryDescriptionRequest(keyId, initData.peerId) + ) + } + + val values = helpers.map { peer -> + responses[peer.peerId] ?: throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Missing recovery description") + } + + values.forEachIndexed { index, value -> + if (value.peerId != helpers[index].peerId || value.targetPeerId != initData.peerId || + value.keyId != keyId || value.threshold != initData.threshold || value.total != initData.total + ) throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Invalid recovery description identity") + } + + val expected = values.first() + if (values.any { + it.currentGeneration != expected.currentGeneration || + !it.algorithm.equals(expected.algorithm, ignoreCase = true) + } + ) throw TKeeperException(RecoveryErrorType.RECOVERY_FAILED, "Helper recovery descriptions disagree") + + return expected + } + + private fun selectHelpers(requested: List): List { + val ids = requested.sorted() + if (requested != ids || ids.distinct().size != ids.size || ids.size != initData.threshold || + initData.peerId in ids || ids.any { it !in 1..initData.total } + ) throw TKeeperException(ErrorType.INVALID_THRESHOLD, "Expected exactly ${initData.threshold} canonical helpers") + + val selected = clients.find(ids) + + if (selected.size != ids.size) + throw TKeeperException(ErrorType.NOT_ENOUGH_CONFIGURED_CLIENTS) + + return selected.sortedBy { it.peerId } + } +} diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/RecoveryStateReset.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/RecoveryStateReset.kt new file mode 100644 index 00000000..8d6bd543 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/RecoveryStateReset.kt @@ -0,0 +1,19 @@ +package org.exploit.keeper.recovery + +import jakarta.inject.Singleton +import org.exploit.keeper.service.consistency.KeySyncHandler +import org.exploit.keeper.service.db.RocksKeyDB +import org.rocksdb.Transaction + +@Singleton +class RecoveryStateReset( + private val db: RocksKeyDB, + private val handlers: List, +) { + fun clear(tx: Transaction, keyId: String) { + db.vault.clearForRecovery(tx, keyId) + db.meta.clearForRecovery(tx, keyId) + db.destroy.clearForRecovery(tx, keyId) + handlers.forEach { it.onRecoveryReset(tx, keyId) } + } +} diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/config/RecoveryConfig.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/config/RecoveryConfig.kt new file mode 100644 index 00000000..eca0a672 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/config/RecoveryConfig.kt @@ -0,0 +1,3 @@ +package org.exploit.keeper.recovery.config + +class RecoveryConfig(val recovery: Boolean = false) \ No newline at end of file diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/config/factory/RecoveryConfigFactory.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/config/factory/RecoveryConfigFactory.kt new file mode 100644 index 00000000..6215efce --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/config/factory/RecoveryConfigFactory.kt @@ -0,0 +1,17 @@ +package org.exploit.keeper.recovery.config.factory + +import com.typesafe.config.Config +import io.micronaut.context.annotation.Factory +import jakarta.inject.Singleton +import org.exploit.keeper.recovery.config.RecoveryConfig +import org.exploit.keeper.util.extension.config.bool + +@Factory +class RecoveryConfigFactory { + @Singleton + fun recovery(config: Config): RecoveryConfig { + val k = config.getConfig("keeper") + + return RecoveryConfig(k.bool("recovery", false)) + } +} diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/decorator/RecoveryModeDecorator.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/decorator/RecoveryModeDecorator.kt new file mode 100644 index 00000000..04bdc80c --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/decorator/RecoveryModeDecorator.kt @@ -0,0 +1,24 @@ +package org.exploit.keeper.recovery.decorator + +import com.linecorp.armeria.common.HttpRequest +import com.linecorp.armeria.common.HttpResponse +import com.linecorp.armeria.server.DecoratingHttpServiceFunction +import com.linecorp.armeria.server.HttpService +import com.linecorp.armeria.server.ServiceRequestContext +import org.exploit.keeper.constant.RecoveryErrorType +import org.exploit.keeper.controller.support.ControllerSupport +import org.exploit.keeper.model.route.RecoveryRoute +import org.exploit.keeper.model.internal.transport.Codec + +class RecoveryModeDecorator( + private val support: ControllerSupport, + private val codec: Codec, + private val allowed: Set, +) : DecoratingHttpServiceFunction { + override fun serve(delegate: HttpService, ctx: ServiceRequestContext, req: HttpRequest): HttpResponse { + val route = RecoveryRoute(req.method(), ctx.path()) + + return if (route in allowed) delegate.serve(ctx, req) else + support.finishError(RecoveryErrorType.RECOVERY_MODE_ACTIVE, codec = codec) + } +} \ No newline at end of file diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/decorator/provider/RecoveryModeDecoratorProvider.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/decorator/provider/RecoveryModeDecoratorProvider.kt new file mode 100644 index 00000000..51e97e71 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/decorator/provider/RecoveryModeDecoratorProvider.kt @@ -0,0 +1,49 @@ +package org.exploit.keeper.recovery.decorator.provider + +import com.linecorp.armeria.common.HttpMethod +import com.linecorp.armeria.server.ServerBuilder +import jakarta.inject.Singleton +import org.exploit.keeper.constant.common.ServerType +import org.exploit.keeper.constant.common.ServerType.INTERNAL +import org.exploit.keeper.constant.common.ServerType.PUBLIC +import org.exploit.keeper.controller.support.ControllerSupport +import org.exploit.keeper.model.route.RecoveryRoute +import org.exploit.keeper.model.internal.transport.Codec +import org.exploit.keeper.recovery.config.RecoveryConfig +import org.exploit.keeper.recovery.decorator.RecoveryModeDecorator +import org.exploit.keeper.recovery.route.RecoveryRouteProvider +import org.exploit.keeper.service.decorator.BeforeRequestDecoratorProvider + +@Singleton +class RecoveryModeDecoratorProvider( + private val support: ControllerSupport, + private val recoveryRoutes: List, + private val config: RecoveryConfig, +): BeforeRequestDecoratorProvider { + override fun addDecorator(serverType: ServerType, sb: ServerBuilder, codec: Codec) { + if (!config.recovery) return + + val recoveryAllowed = when(serverType) { + PUBLIC -> recoveryRoutes.flatMap { it.publicRoutes() }.toSet() + PUBLIC_RECOVERY_BASE + INTERNAL -> recoveryRoutes.flatMap { it.internalRoutes() }.toSet() + INTERNAL_RECOVERY_BASE + } + + sb.decorator(RecoveryModeDecorator(support, codec, recoveryAllowed)) + } + + private companion object { + val PUBLIC_RECOVERY_BASE = setOf( + RecoveryRoute(HttpMethod.GET, "/v1/keeper/system/health"), + RecoveryRoute(HttpMethod.GET, "/v1/keeper/system/status"), + RecoveryRoute(HttpMethod.GET, "/v1/keeper/system/unseal"), + RecoveryRoute(HttpMethod.POST, "/v1/keeper/system/unseal"), + RecoveryRoute(HttpMethod.POST, "/v1/keeper/system/seal"), + RecoveryRoute(HttpMethod.GET, "/v1/keeper/publicKey"), + ) + + val INTERNAL_RECOVERY_BASE = setOf( + RecoveryRoute(HttpMethod.GET, "/v1/health/status"), + RecoveryRoute(HttpMethod.GET, "/v1/integrity/publicKey"), + ) + } +} diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/handler/RecoveryHandler.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/handler/RecoveryHandler.kt new file mode 100644 index 00000000..f7759572 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/handler/RecoveryHandler.kt @@ -0,0 +1,11 @@ +package org.exploit.keeper.recovery.handler + +import org.exploit.keeper.model.recovery.RecoverRequest +import org.exploit.keeper.model.recovery.RecoverResult +import org.exploit.keeper.service.key.KeyAlgorithmProvider + +interface RecoveryHandler { + fun supports(algorithm: KeyAlgorithmProvider): Boolean + + fun recover(request: RecoverRequest, algorithm: KeyAlgorithmProvider): RecoverResult +} \ No newline at end of file diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/route/RecoveryRouteProvider.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/route/RecoveryRouteProvider.kt new file mode 100644 index 00000000..9379c5b2 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/route/RecoveryRouteProvider.kt @@ -0,0 +1,9 @@ +package org.exploit.keeper.recovery.route + +import org.exploit.keeper.model.route.RecoveryRoute + +interface RecoveryRouteProvider { + fun publicRoutes(): Set = emptySet() + + fun internalRoutes(): Set = emptySet() +} \ No newline at end of file diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/server/RecoveryStateValidator.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/server/RecoveryStateValidator.kt new file mode 100644 index 00000000..e63cf1f4 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/recovery/server/RecoveryStateValidator.kt @@ -0,0 +1,39 @@ +package org.exploit.keeper.recovery.server + +import jakarta.inject.Singleton +import org.exploit.keeper.config.keeper.KeeperConfig +import org.exploit.keeper.recovery.config.RecoveryConfig +import org.exploit.keeper.recovery.route.RecoveryRouteProvider +import org.exploit.keeper.server.handler.ServerEventHandler + +@Singleton +class RecoveryStateValidator( + private val keeperConfig: KeeperConfig, + private val recoveryConfig: RecoveryConfig, + private val recoveryRoutes: List, +): ServerEventHandler { + override fun onStart() { + if (!recoveryConfig.recovery) return + + require(recoveryRoutes.isNotEmpty()) { + "keeper.recovery=true requires a recovery feature in the runtime artifact" + } + + require(keeperConfig.servers.public.tls?.enabled == true) { + "Recovery mode requires TLS on the public recovery API" + } + + val internalTls = keeperConfig.servers.internal.tls + require(internalTls?.enabled == true && internalTls.clientAuth) { + "Recovery mode requires internal TLS with client-auth" + } + + require(keeperConfig.client.tls && keeperConfig.client.keyStorePath != null) { + "Recovery mode requires outbound client TLS with a key store" + } + + require(keeperConfig.peers.isNotEmpty() && keeperConfig.peers.all { !it.tlsSpkiSha256.isNullOrBlank() }) { + "Recovery mode requires tls-spki-sha256 for every peer" + } + } +} diff --git a/features/recovery/src/main/kotlin/org/exploit/keeper/util/RecoveryMetadata.kt b/features/recovery/src/main/kotlin/org/exploit/keeper/util/RecoveryMetadata.kt new file mode 100644 index 00000000..9369f7c7 --- /dev/null +++ b/features/recovery/src/main/kotlin/org/exploit/keeper/util/RecoveryMetadata.kt @@ -0,0 +1,20 @@ +package org.exploit.keeper.util + +import org.exploit.keeper.model.keyset.KeySetMeta +import java.security.MessageDigest + +fun sameRecoveryMetadata(left: KeySetMeta, right: KeySetMeta): Boolean = + left.logicalId == right.logicalId && + left.enabled == right.enabled && + left.currentGeneration == right.currentGeneration && + left.authorities.map { it.id to it.oci } == right.authorities.map { it.id to it.oci } && + samePolicy(left, right) && + left.destroyed == right.destroyed && + left.assetOwner == right.assetOwner + +private fun samePolicy(left: KeySetMeta, right: KeySetMeta): Boolean { + val leftPolicy = left.policy ?: return right.policy == null + val rightPolicy = right.policy ?: return false + + return MessageDigest.isEqual(leftPolicy.bytes(), rightPolicy.bytes()) +} diff --git a/features/seal-aws/build.gradle b/features/seal-aws/build.gradle index 688d0180..c87c2c88 100644 --- a/features/seal-aws/build.gradle +++ b/features/seal-aws/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.3.2' +version = '2.4.0' repositories { mavenCentral() diff --git a/features/seal-gcloud/build.gradle b/features/seal-gcloud/build.gradle index 1d43e6f1..9bfcff02 100644 --- a/features/seal-gcloud/build.gradle +++ b/features/seal-gcloud/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.3.2' +version = '2.4.0' repositories { mavenCentral() diff --git a/features/ui/build.gradle b/features/ui/build.gradle index 43db379c..e6634297 100644 --- a/features/ui/build.gradle +++ b/features/ui/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'org.exploit' -version = '2.3.2' +version = '2.4.0' repositories { mavenCentral() diff --git a/features/ui/src/main/resources/ui/assets/app/consistency.js b/features/ui/src/main/resources/ui/assets/app/consistency.js index 22be678c..8cd557b4 100644 --- a/features/ui/src/main/resources/ui/assets/app/consistency.js +++ b/features/ui/src/main/resources/ui/assets/app/consistency.js @@ -91,6 +91,12 @@ export async function init({ api, Auth, showAlert, setTitle, clearAlerts }) { } }, 0); } + else if (verdict === "RECOVER_NEEDED") { + resultDiv.innerHTML = ` +
+ Recovery Required: Key state is missing from one or more keepers. +
`; + } else if (verdict === "MISSING") { resultDiv.innerHTML = `
diff --git a/integration-tests/README.md b/integration-tests/README.md index 2ab5ade2..9ba6c69c 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -27,7 +27,8 @@ Build both required images from the repository root: This builds: -- `exploit/tkeeper:dev` contains all features plus failure injection for the Compose suites; +- `exploit/tkeeper:dev` contains default and explicit features, both recovery platform modules, + and failure injection for the Compose suites; - `exploit/tkeeper:production-it` uses the production UBI Dockerfile and a production-only ECC jar for transport security tests. Run all functional tests: @@ -53,14 +54,17 @@ 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 production feature -- the explicit opt-in `:features:auth-dev` module +- every default production feature +- the explicit opt-in `:features:auth-dev` 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. Never deploy the integration image as a production runtime. +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. -Verify that the production transport test artifact excludes `auth-dev` and failure injection while the integration artifact contains both: +Verify that the production transport test artifact excludes `auth-dev`, recovery, and failure +injection while the integration artifact contains all three: ```bash ./gradlew artifactIsolationTest @@ -73,7 +77,8 @@ 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 | -| `FailureInjectionTests` | corruption, sequential and concurrent malicious signing packages, FROST commitment/share/nonce-state inputs, threshold ML-DSA commit/reveal and state-reuse inputs, GG20 Paillier/ZK-proof inputs, in-flight protocol restart checkpoints, demotion, and consistency recovery | +| `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 | | `QuorumPromotionTests` | mono-to-threshold promotion | | `KeyImportTests` | trusted-dealer import | @@ -83,6 +88,13 @@ The functional suite is split by boundary: See the [functional test sources](functional/src/test/kotlin/org/exploit/test/functional/) for the complete set. +Run only the share-recovery scenario: + +```bash +./gradlew :integration-tests:functional:test \ + --tests 'org.exploit.test.functional.RecoveryFailureInjectionTests' +``` + The corrupted-share Schnorr and ECDSA scenarios make up to ten signing attempts because a 2-of-3 coordinator may select the other healthy peer. Assuming independent 50/50 peer selection, there is still about a 0.1% chance that the corrupted peer is never selected and either test fails; stable latency bias can make the actual probability higher. ## Test topology diff --git a/integration-tests/build.gradle b/integration-tests/build.gradle index da3cb999..022f85a9 100644 --- a/integration-tests/build.gradle +++ b/integration-tests/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'org.exploit' -version = '2.3.2' +version = '2.4.0' ext { projectName = "Integration Tests" diff --git a/integration-tests/failure-injection/build.gradle b/integration-tests/failure-injection/build.gradle index 2e532243..38e65cb9 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.3.2' +version = '2.4.0' repositories { mavenCentral() @@ -21,6 +21,7 @@ repositories { dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test' compileOnly(project(':')) + compileOnly(project(':features:recovery')) compileOnly(project(':platform-ecc')) compileOnly(project(':platform-pqc')) compileOnly(project(':features:ecies')) diff --git a/integration-tests/failure-injection/src/main/kotlin/org/exploit/keeper/module/failure/FailureInjectionController.kt b/integration-tests/failure-injection/src/main/kotlin/org/exploit/keeper/module/failure/FailureInjectionController.kt index 372eb9c6..708577ac 100644 --- a/integration-tests/failure-injection/src/main/kotlin/org/exploit/keeper/module/failure/FailureInjectionController.kt +++ b/integration-tests/failure-injection/src/main/kotlin/org/exploit/keeper/module/failure/FailureInjectionController.kt @@ -24,6 +24,26 @@ class FailureInjectionController( injector.tamperVersionMetaSignature(body) } + sb.publicPost("/v1/keeper/failure-injection/meta/delete-active", type = FailureKeyRequest::class.java) { ctx, body -> + requirePermission(ctx) + injector.deleteActiveMetadata(body) + } + + sb.publicPost("/v1/keeper/failure-injection/destroy/delete-marker", type = FailureKeyRequest::class.java) { ctx, body -> + requirePermission(ctx) + injector.deleteDestroyMarker(body) + } + + sb.publicPost("/v1/keeper/failure-injection/recovery/delete-side-state", type = FailureKeyRequest::class.java) { ctx, body -> + requirePermission(ctx) + injector.deleteRecoverySideState(body) + } + + sb.publicPost("/v1/keeper/failure-injection/recovery/probe-reset-rollback", type = FailureKeyRequest::class.java) { ctx, body -> + requirePermission(ctx) + injector.probeRecoveryResetRollback(body) + } + sb.publicPost("/v1/keeper/failure-injection/key/demote-current", type = FailureKeyRequest::class.java) { ctx, body -> requirePermission(ctx) injector.demoteCurrentToPending(body) @@ -34,6 +54,11 @@ class FailureInjectionController( injector.deleteActiveKeyMaterial(body) } + sb.publicPost("/v1/keeper/failure-injection/key/delete-active-state", type = FailureKeyRequest::class.java) { ctx, body -> + requirePermission(ctx) + injector.deleteActiveKeyState(body) + } + sb.publicPost("/v1/keeper/failure-injection/downgrade-to-pre-hardening", type = FailureKeyRequest::class.java) { ctx, body -> requirePermission(ctx) injector.downgradeActiveKeyToPreHardeningFormat(body) @@ -99,6 +124,11 @@ class FailureInjectionController( injector.replaceActiveKeyMaterial(body) } + sb.publicPost("/v1/keeper/failure-injection/key/inject-active-material", type = FailureKeyCopyRequest::class.java) { ctx, body -> + requirePermission(ctx) + injector.injectActiveKeyMaterial(body) + } + sb.publicPost("/v1/keeper/failure-injection/pqc/tamper-active-public-key", type = FailureKeyRequest::class.java) { ctx, body -> requirePermission(ctx) injector.tamperActiveMLDSAPublicKey(body) @@ -186,6 +216,14 @@ class FailureInjectionController( requirePermission(ctx) injector.probeMLDSASigning(body) } + + sb.publicPost( + "/v1/keeper/failure-injection/protocol/state-machine", + type = FailureProtocolStateRequest::class.java + ) { ctx, body -> + requirePermission(ctx) + injector.probeProtocolState(body) + } } private fun requirePermission(ctx: com.linecorp.armeria.server.ServiceRequestContext) { diff --git a/integration-tests/failure-injection/src/main/kotlin/org/exploit/keeper/module/failure/FailureInjectionService.kt b/integration-tests/failure-injection/src/main/kotlin/org/exploit/keeper/module/failure/FailureInjectionService.kt index 07e6c104..ff5625f2 100644 --- a/integration-tests/failure-injection/src/main/kotlin/org/exploit/keeper/module/failure/FailureInjectionService.kt +++ b/integration-tests/failure-injection/src/main/kotlin/org/exploit/keeper/module/failure/FailureInjectionService.kt @@ -1,5 +1,7 @@ package org.exploit.keeper.module.failure +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.inject.Named import jakarta.inject.Singleton import org.bouncycastle.crypto.digests.SHAKEDigest import org.exploit.anvil.util.Digests @@ -9,54 +11,68 @@ import org.exploit.crypto.paillier.zk.proof.NoSmallFactorProof import org.exploit.crypto.paillier.zk.proof.PaillierRangeProof import org.exploit.crypto.spi.exception.IdentifiableAbortException import org.exploit.ecc.spi.point.PointOps +import org.exploit.jettyx.model.HttpResponse +import org.exploit.keeper.api.client.TKeeperClient import org.exploit.keeper.constant.authority.OtherKeySetAuthorityId import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.constant.dkg.KeyGenMode import org.exploit.keeper.constant.signature.HashMethod import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.factory.JacksonFactory import org.exploit.keeper.model.api.incoming.Store import org.exploit.keeper.model.command.AuthorityCommand import org.exploit.keeper.model.command.artifact.ArbitraryData +import org.exploit.keeper.model.error.ErrorMessage import org.exploit.keeper.model.internal.InitSigningSession import org.exploit.keeper.model.internal.KeySetAuthorities +import org.exploit.keeper.model.internal.keygen.InitKeyGen import org.exploit.keeper.model.keyset.KeySetAuthority import org.exploit.keeper.model.keyset.KeySetMeta import org.exploit.keeper.module.ecies.ECIESManager import org.exploit.keeper.module.ecies.constant.CipherType import org.exploit.keeper.platform.ecc.algorithm.ECKeyAlgorithmProvider +import org.exploit.keeper.platform.ecc.api.ECKeyGenApi import org.exploit.keeper.platform.ecc.api.FrostApi +import org.exploit.keeper.platform.ecc.api.GG20Api +import org.exploit.keeper.platform.ecc.keygen.threshold.ThresholdKeyGenSessionManager import org.exploit.keeper.platform.ecc.model.frost.FrostCommitmentDto -import org.exploit.keeper.platform.ecc.model.keygen.DkgCommitments import org.exploit.keeper.platform.ecc.model.gg20.mta.GG20MtAComputeRequest +import org.exploit.keeper.platform.ecc.model.keygen.DkgCommitments import org.exploit.keeper.platform.ecc.signer.frost.FrostSessionManager -import org.exploit.keeper.platform.ecc.store.CommitStore import org.exploit.keeper.platform.ecc.signer.gg20.GG20SessionManager +import org.exploit.keeper.platform.ecc.store.CommitStore import org.exploit.keeper.platform.ecc.util.CommitmentsSerializer import org.exploit.keeper.platform.ecc.util.extension.decodePointSafeIdentity import org.exploit.keeper.platform.ecc.util.extension.dto import org.exploit.keeper.platform.pqc.algorithm.MLDSAKeyAlgorithmProvider +import org.exploit.keeper.platform.pqc.api.MLDSAKeyGenApi +import org.exploit.keeper.platform.pqc.api.MLDSASigningApi +import org.exploit.keeper.platform.pqc.keygen.threshold.ThresholdMLDSAKeyGenSessionManager import org.exploit.keeper.platform.pqc.model.signature.MLDSASignRound1 import org.exploit.keeper.platform.pqc.model.signature.MLDSASignRound1Batch import org.exploit.keeper.platform.pqc.model.signature.MLDSASignRound2 import org.exploit.keeper.platform.pqc.model.signature.MLDSASignRound2Batch import org.exploit.keeper.platform.pqc.signer.threshold.ThresholdMLDSASessionManager import org.exploit.keeper.platform.pqc.store.MLDSAPublicKeyStore +import org.exploit.keeper.recovery.RecoveryStateReset +import org.exploit.keeper.service.client.TKeeperClients import org.exploit.keeper.service.db.RocksKeyDB import org.exploit.keeper.service.db.store.KeyStoreOps import org.exploit.keeper.service.db.store.KeyVersionStore import org.exploit.keeper.service.db.store.MetaStore -import org.exploit.keeper.service.client.TKeeperClients import org.exploit.keeper.service.dealer.KeyStorageService import org.exploit.keeper.service.integrity.KeeperInternalSigner +import org.exploit.keeper.service.key.KeyAlgorithmProvider import org.exploit.keeper.service.key.KeyAlgorithms import org.exploit.keeper.service.pub.PublicKeyService import org.exploit.keeper.service.signature.registry.SigningSessionManager import org.exploit.keeper.util.BoundSecretSerializer import org.exploit.keeper.util.KeySerializer import org.exploit.keeper.util.SignedSerializer -import org.exploit.keeper.util.extension.crypto.toBase64 +import org.exploit.keeper.util.extension.context import org.exploit.keeper.util.extension.crypto.decodeBase64 +import org.exploit.keeper.util.extension.crypto.toBase64 import org.exploit.keeper.util.extension.crypto.toBigInt -import org.exploit.keeper.util.extension.context import org.exploit.keeper.util.extension.equalsIgnoreCase import org.exploit.keeper.util.validation.InputValidator import org.exploit.mldsa.model.key.threshold.ThresholdPublicKey @@ -67,23 +83,23 @@ import org.rocksdb.Transaction import java.nio.ByteBuffer import java.nio.ByteOrder import java.time.Instant -import java.util.Locale -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit +import java.util.* +import java.util.concurrent.* @Singleton class FailureInjectionService( + @param:Named(JacksonFactory.INTERNAL) private val internalMapper: ObjectMapper, private val db: RocksKeyDB, private val algorithms: KeyAlgorithms, private val storage: KeyStorageService, private val signer: KeeperInternalSigner, private val publicKeys: PublicKeyService, private val signingSessions: SigningSessionManager, + private val eccKeyGenSessions: ThresholdKeyGenSessionManager, + private val pqcKeyGenSessions: ThresholdMLDSAKeyGenSessionManager, private val clients: TKeeperClients, - private val ecies: ECIESManager + private val ecies: ECIESManager, + private val recoveryStateReset: RecoveryStateReset, ) { private val initData by lazy { db.readInitData() } private val generationPointerSnapshots = ConcurrentHashMap() @@ -107,11 +123,89 @@ class FailureInjectionService( return result("meta.tamper-version", logicalId, generation = generation) } + fun deleteActiveMetadata(req: FailureKeyRequest): FailureInjectionResult { + val logicalId = validateKeyId(req.keyId) + var generation = 0 + + db.vault.ops.runInTransaction { tx -> + generation = req.generation + ?: db.vault.findLatestGeneration(tx, logicalId) + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, logicalId) + + db.meta.loadVersionedMeta(tx, logicalId, generation) + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, "$logicalId/$generation metadata") + db.meta.deleteActiveKeyset(tx, logicalId, generation) + } + + return result("meta.delete-active", logicalId, generation = generation) + } + + fun deleteDestroyMarker(req: FailureKeyRequest): FailureInjectionResult { + val logicalId = validateKeyId(req.keyId) + val generation = validateGeneration(req.generation) + + db.vault.ops.runInTransaction { tx -> + if (!db.destroy.isCommitted(tx, logicalId, generation)) + throw TKeeperException(ErrorType.KEY_NOT_FOUND, "$logicalId/$generation destroy marker") + db.destroy.deleteCommitted(tx, logicalId, generation) + } + + return result("destroy.delete-marker", logicalId, generation = generation) + } + + fun deleteRecoverySideState(req: FailureKeyRequest): FailureInjectionResult { + val logicalId = validateKeyId(req.keyId) + var generation = 0 + + db.vault.ops.runInTransaction { tx -> + generation = req.generation + ?: db.vault.findLatestGeneration(tx, logicalId) + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, logicalId) + val raw = db.vault.readKey(tx, logicalId, generation) + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, "$logicalId/$generation") + val algorithm = try { + algorithms.findOrThrow(KeySerializer.extractAlgId(raw)) + } finally { + raw.fill(0) + } + + when (algorithm) { + is ECKeyAlgorithmProvider<*, *, *, *> -> + db.getStore(CommitStore::class).deleteActive(tx, logicalId, generation) + + is MLDSAKeyAlgorithmProvider -> { + val publicKeys = db.getStore(MLDSAPublicKeyStore::class) + publicKeys.deleteActive(tx, logicalId, generation) + publicKeys.deleteActiveRecoveryCommitments(tx, logicalId, generation) + } + + else -> throw TKeeperException(ErrorType.UNSUPPORTED_KEY_ALGORITHM) + } + } + + return result("recovery.delete-side-state", logicalId, generation = generation) + } + + fun probeRecoveryResetRollback(req: FailureKeyRequest): FailureInjectionResult { + val logicalId = validateKeyId(req.keyId) + val probe = IllegalStateException("recovery reset rollback probe") + + val failure = runCatching { + db.vault.ops.runInTransaction { tx -> + recoveryStateReset.clear(tx, logicalId) + throw probe + } + }.exceptionOrNull() + check(failure === probe) { "Recovery reset rollback probe did not fail as expected" } + + return result("recovery.probe-reset-rollback", logicalId) + } + fun demoteCurrentToPending(req: FailureKeyRequest): FailureInjectionResult { val logicalId = validateKeyId(req.keyId) var activeBefore = 0 - var activeAfter = 0 + var activeAfter: Int? = null var pendingGeneration = 0 db.vault.ops.runInTransaction { tx -> @@ -126,14 +220,7 @@ class FailureInjectionService( ) } - if (targetGeneration <= 1) { - throw TKeeperException( - ErrorType.INVALID_KEY_GENERATION, - "No previous active generation for $logicalId" - ) - } - - val previousGeneration = targetGeneration - 1 + val previousGeneration = (targetGeneration - 1).takeIf { it > 0 } val versions = db.getStore(KeyVersionStore::class) val activeKey = db.vault.readKey(tx, logicalId, targetGeneration) @@ -160,22 +247,26 @@ class FailureInjectionService( db.meta.savePendingKeysetMeta(tx, pendingMeta) db.meta.savePendingVersionedMeta(tx, pendingMeta) - val previousMeta = db.meta.loadVersionedMeta(tx, logicalId, previousGeneration) - ?: throw TKeeperException( - ErrorType.KEY_NOT_FOUND, - "$logicalId/$previousGeneration metadata" - ) - versions.deleteActive(tx, logicalId, targetGeneration) - versions.setCurrentGeneration(tx, logicalId, previousGeneration) + if (previousGeneration == null) { + versions.clearCurrentGeneration(tx, logicalId) + db.meta.deleteActiveKeyset(tx, logicalId, targetGeneration) + } else { + val previousMeta = db.meta.loadVersionedMeta(tx, logicalId, previousGeneration) + ?: throw TKeeperException( + ErrorType.KEY_NOT_FOUND, + "$logicalId/$previousGeneration metadata" + ) - db.meta.saveKeysetMeta( - tx, - previousMeta.copy( - currentGeneration = previousGeneration, - updatedAt = Instant.now().epochSecond + versions.setCurrentGeneration(tx, logicalId, previousGeneration) + db.meta.saveKeysetMeta( + tx, + previousMeta.copy( + currentGeneration = previousGeneration, + updatedAt = Instant.now().epochSecond + ) ) - ) + } activeBefore = current activeAfter = previousGeneration @@ -219,6 +310,34 @@ class FailureInjectionService( ) } + fun deleteActiveKeyState(req: FailureKeyRequest): FailureInjectionResult { + val logicalId = validateKeyId(req.keyId) + var generation = 0 + + db.vault.ops.runInTransaction { tx -> + val current = db.vault.findLatestGeneration(tx, logicalId) + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, logicalId) + generation = req.generation ?: current + + if (generation != current) { + throw TKeeperException( + ErrorType.INVALID_KEY_GENERATION, + "Can delete only current generation. current=$current requested=$generation" + ) + } + + db.vault.deleteKey(tx, logicalId, generation) + db.vault.clearGeneration(tx, logicalId) + } + + return result( + "key.delete-active-state", + logicalId, + generation = generation, + activeBefore = generation + ) + } + fun corruptActiveKeyMaterial(req: FailureKeyRequest): FailureInjectionResult { val logicalId = validateKeyId(req.keyId) var generation = 0 @@ -862,6 +981,28 @@ class FailureInjectionService( ) } + fun injectActiveKeyMaterial(req: FailureKeyCopyRequest): FailureInjectionResult { + val logicalId = validateKeyId(req.keyId) + val sourceId = validateKeyId(req.sourceKeyId) + val generation = validateGeneration(req.generation) + + db.vault.ops.runInTransaction { tx -> + val sourceGeneration = req.sourceGeneration + ?: db.vault.findLatestGeneration(tx, sourceId) + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, sourceId) + val source = db.vault.readKey(tx, sourceId, sourceGeneration) + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, "$sourceId/$sourceGeneration") + + try { + db.getStore(KeyVersionStore::class).saveActive(tx, logicalId, generation, source) + } finally { + source.fill(0) + } + } + + return result("key.inject-active-material", logicalId, generation = generation) + } + fun tamperActiveMLDSAPublicKey(req: FailureKeyRequest): FailureInjectionResult { val logicalId = validateKeyId(req.keyId) val generation = req.generation @@ -1268,7 +1409,7 @@ class FailureInjectionService( val client = manager.session(sessionId).client val localZkSetup = client.context().crypto().zkSetup() client.context().mta().initiator().storeZKSetup(initiatorPeerId, localZkSetup) - client.mta().init() + manager.respondent.setup.complete(sessionId) val validRequest = GG20MtAComputeRequest( sessionId = sessionId, @@ -1425,6 +1566,324 @@ class FailureInjectionService( ) } + fun probeProtocolState(req: FailureProtocolStateRequest): FailureProtocolProbeResult { + val logicalId = validateKeyId(req.keyId) + val sessionId = validateKeyId(req.sessionId) + val generation = activeGeneration(logicalId, req.generation) + val algorithm = keyAlgorithm(logicalId, generation) + val protocol = protocolName(req.vector) + return when (req.action) { + FailureProtocolStateAction.STAGE -> { + stageProtocolState(req.vector, logicalId, sessionId, generation, algorithm) + protocolProbeResult(initData.peerId, protocol, req.vector, active = true) + } + FailureProtocolStateAction.ATTACK -> attackProtocolState( + req.vector, + logicalId, + sessionId, + generation, + algorithm, + protocol, + ) + FailureProtocolStateAction.CLEAR -> { + clearProtocolState(req.vector, sessionId) + protocolProbeResult(initData.peerId, protocol, req.vector, active = false) + } + } + } + + private fun stageProtocolState( + vector: FailureProtocolStateVector, + keyId: String, + sessionId: String, + generation: Int, + algorithm: KeyAlgorithmProvider, + ) { + when (vector) { + FailureProtocolStateVector.ECC_DKG_COMPLETE_BEFORE_COMPUTE -> { + require(algorithm is ECKeyAlgorithmProvider<*, *, *, *>) { "Expected ECC key" } + eccKeyGenSessions.init(protocolKeyGenInit(sessionId, algorithm.name())) + } + FailureProtocolStateVector.PQC_DKG_REPLAY_ROUND1, + FailureProtocolStateVector.PQC_DKG_CONCURRENT_ROUND1 -> { + require(algorithm is MLDSAKeyAlgorithmProvider) { "Expected ML-DSA key" } + pqcKeyGenSessions.init( + protocolKeyGenInit(sessionId, algorithm.name()), + PROTOCOL_COORDINATOR_PEER_ID + ) + } + FailureProtocolStateVector.FROST_SIGN_BEFORE_COMMITMENT_COLLECTION -> { + require(algorithm is ECKeyAlgorithmProvider<*, *, *, *>) { "Expected ECC key" } + signingSessions.participate( + protocolSigningInit( + keyId, sessionId, generation, algorithm.name(), SCHNORR_SCHEME, + PROTOCOL_TARGET_PEER_ID + ) + ) + } + FailureProtocolStateVector.GG20_SIGN_BEFORE_SETUP -> { + require(algorithm is ECKeyAlgorithmProvider<*, *, *, *>) { "Expected ECC key" } + signingSessions.participate( + protocolSigningInit( + keyId, sessionId, generation, algorithm.name(), ECDSA_SCHEME, + PROTOCOL_TARGET_PEER_ID + ) + ) + } + FailureProtocolStateVector.MLDSA_SIGN_REPLAY_ROUND1, + FailureProtocolStateVector.MLDSA_SIGN_CONCURRENT_ROUND1 -> { + require(algorithm is MLDSAKeyAlgorithmProvider) { "Expected ML-DSA key" } + signingSessions.participate( + protocolSigningInit( + keyId, sessionId, generation, algorithm.name(), MLDSA_SCHEME, + PROTOCOL_TARGET_PEER_ID + ) + ) + } + } + } + + private fun attackProtocolState( + vector: FailureProtocolStateVector, + keyId: String, + sessionId: String, + generation: Int, + algorithm: KeyAlgorithmProvider, + protocol: String, + ): FailureProtocolProbeResult { + val remote = clients.find(PROTOCOL_TARGET_PEER_ID) + ?: throw TKeeperException(ErrorType.NOT_ENOUGH_CONFIGURED_CLIENTS) + + initRemoteProtocolState(vector, keyId, sessionId, generation, algorithm, remote) + + fun request(): CompletableFuture> = when (vector) { + FailureProtocolStateVector.ECC_DKG_COMPLETE_BEFORE_COMPUTE -> + remote.internalClient(ECKeyGenApi::class.java).complete(sessionId) + FailureProtocolStateVector.PQC_DKG_REPLAY_ROUND1, + FailureProtocolStateVector.PQC_DKG_CONCURRENT_ROUND1 -> + remote.internalClient(MLDSAKeyGenApi::class.java).round1(sessionId) + FailureProtocolStateVector.FROST_SIGN_BEFORE_COMMITMENT_COLLECTION -> + remote.internalClient(FrostApi::class.java).computeZ(sessionId) + FailureProtocolStateVector.GG20_SIGN_BEFORE_SETUP -> + remote.internalClient(GG20Api::class.java).computeS(sessionId) + FailureProtocolStateVector.MLDSA_SIGN_REPLAY_ROUND1, + FailureProtocolStateVector.MLDSA_SIGN_CONCURRENT_ROUND1 -> + remote.internalClient(MLDSASigningApi::class.java).round1(sessionId) + } + + val responses = when (vector) { + FailureProtocolStateVector.PQC_DKG_REPLAY_ROUND1, + FailureProtocolStateVector.MLDSA_SIGN_REPLAY_ROUND1 -> + listOf(request().join(), request().join()) + FailureProtocolStateVector.PQC_DKG_CONCURRENT_ROUND1, + FailureProtocolStateVector.MLDSA_SIGN_CONCURRENT_ROUND1 -> { + val pending = List(CONCURRENT_REPLAY_ATTEMPTS) { request() } + CompletableFuture.allOf(*pending.toTypedArray()).join() + pending.map { it.join() } + } + else -> listOf(request().join()) + } + + return protocolResponses(PROTOCOL_TARGET_PEER_ID, protocol, vector, responses) + } + + private fun initRemoteProtocolState( + vector: FailureProtocolStateVector, + keyId: String, + sessionId: String, + generation: Int, + algorithm: KeyAlgorithmProvider, + remote: TKeeperClient, + ) { + val request = when (vector) { + FailureProtocolStateVector.ECC_DKG_COMPLETE_BEFORE_COMPUTE -> + remote.internalClient(ECKeyGenApi::class.java) + .init(protocolKeyGenInit(sessionId, algorithm.name())) + FailureProtocolStateVector.PQC_DKG_REPLAY_ROUND1, + FailureProtocolStateVector.PQC_DKG_CONCURRENT_ROUND1 -> + remote.internalClient(MLDSAKeyGenApi::class.java) + .init(protocolKeyGenInit(sessionId, algorithm.name())) + FailureProtocolStateVector.FROST_SIGN_BEFORE_COMMITMENT_COLLECTION -> + remote.internalClient(FrostApi::class.java).init( + protocolSigningInit( + keyId, sessionId, generation, algorithm.name(), SCHNORR_SCHEME, + PROTOCOL_TARGET_PEER_ID + ) + ) + FailureProtocolStateVector.GG20_SIGN_BEFORE_SETUP -> + remote.internalClient(GG20Api::class.java).init( + protocolSigningInit( + keyId, sessionId, generation, algorithm.name(), ECDSA_SCHEME, + PROTOCOL_TARGET_PEER_ID + ) + ) + FailureProtocolStateVector.MLDSA_SIGN_REPLAY_ROUND1, + FailureProtocolStateVector.MLDSA_SIGN_CONCURRENT_ROUND1 -> + remote.internalClient(MLDSASigningApi::class.java).init( + protocolSigningInit( + keyId, sessionId, generation, algorithm.name(), MLDSA_SCHEME, + PROTOCOL_TARGET_PEER_ID + ) + ) + } + requireProtocolSuccess(request) + } + + private fun clearProtocolState( + vector: FailureProtocolStateVector, + sessionId: String, + ) { + when (vector) { + FailureProtocolStateVector.ECC_DKG_COMPLETE_BEFORE_COMPUTE -> + eccKeyGenSessions.abortSession(sessionId) + FailureProtocolStateVector.PQC_DKG_REPLAY_ROUND1, + FailureProtocolStateVector.PQC_DKG_CONCURRENT_ROUND1 -> pqcKeyGenSessions.abortSession(sessionId) + FailureProtocolStateVector.FROST_SIGN_BEFORE_COMMITMENT_COLLECTION -> + signingSessions.findThresholdSession(FROST_SESSION, sessionId)?.destroy(sessionId) + FailureProtocolStateVector.GG20_SIGN_BEFORE_SETUP -> + signingSessions.findThresholdSession(GG20_SESSION, sessionId)?.destroy(sessionId) + FailureProtocolStateVector.MLDSA_SIGN_REPLAY_ROUND1, + FailureProtocolStateVector.MLDSA_SIGN_CONCURRENT_ROUND1 -> + signingSessions.findThresholdSession(MLDSA_SESSION, sessionId)?.destroy(sessionId) + } + + ensureProtocolStateCleared(vector, sessionId) + } + + private fun protocolName(vector: FailureProtocolStateVector): String = when (vector) { + FailureProtocolStateVector.ECC_DKG_COMPLETE_BEFORE_COMPUTE -> "ECC_DKG" + FailureProtocolStateVector.PQC_DKG_REPLAY_ROUND1, + FailureProtocolStateVector.PQC_DKG_CONCURRENT_ROUND1 -> "PQC_DKG" + FailureProtocolStateVector.FROST_SIGN_BEFORE_COMMITMENT_COLLECTION -> "FROST" + FailureProtocolStateVector.GG20_SIGN_BEFORE_SETUP -> "GG20" + FailureProtocolStateVector.MLDSA_SIGN_REPLAY_ROUND1, + FailureProtocolStateVector.MLDSA_SIGN_CONCURRENT_ROUND1 -> "MLDSA_SIGNING" + } + + private fun protocolKeyGenInit(keyId: String, algorithm: String) = InitKeyGen( + keyId = keyId, + algorithm = algorithm, + authorities = KeySetAuthorities(listOf(KeySetAuthority(OtherKeySetAuthorityId.ARBITRARY))), + targetGeneration = 1, + mode = KeyGenMode.CREATE + ) + + private fun protocolSigningInit( + keyId: String, + sessionId: String, + generation: Int, + algorithm: String, + scheme: String, + remotePeerId: Int + ): InitSigningSession { + return InitSigningSession( + sessionId = sessionId, + keyId = keyId, + algorithm = algorithm, + generation = generation, + method = scheme, + command = AuthorityCommand.arbitrary( + ArbitraryData( + scheme = scheme, + hash = if (scheme == MLDSA_SCHEME) HashMethod.NONE else HashMethod.SHA256, + data64 = "failure-protocol-state-$sessionId".toByteArray().toBase64() + ) + ), + participants = protocolParticipants(remotePeerId), + additionalContext = if (scheme == MLDSA_SCHEME) ByteArray(0) else context(keyId, sessionId), + tweak = null + ) + } + + private fun protocolParticipants(remotePeerId: Int): List = + (listOf(initData.peerId, remotePeerId) + (1..initData.total)) + .distinct() + .take(initData.threshold) + .sorted() + + private fun requireProtocolSuccess(request: CompletableFuture>) { + val response = request.join() + check(response.status in 200..299) { + "Expected successful protocol response, got ${response.status}: ${response.rawResponse?.contentAsString}" + } + } + + private fun protocolResponses( + peerId: Int, + protocol: String, + vector: FailureProtocolStateVector, + responses: List>, + ): FailureProtocolProbeResult { + val errors = responses + .filterNot { it.status in 200..299 } + .map { response -> + val bytes = response.rawResponse?.content + ?: throw IllegalStateException("Missing protocol error body") + internalMapper.readValue(bytes, ErrorMessage::class.java) + } + val errorTypes = errors.map { it.error }.distinct() + val errorDetails = errors.mapNotNull { it.details }.distinct() + + return FailureProtocolProbeResult( + peerId = peerId, + protocol = protocol, + vector = vector.name, + rejected = errors.isNotEmpty(), + errorType = errorTypes.singleOrNull() ?: errorTypes.joinToString(","), + errorDetails = errorDetails.singleOrNull() ?: errorDetails.joinToString("; "), + acceptedAttempts = responses.size - errors.size, + rejectedAttempts = errors.size, + active = true, + ) + } + + private fun ensureProtocolStateCleared(vector: FailureProtocolStateVector, sessionId: String) { + when (vector) { + FailureProtocolStateVector.ECC_DKG_COMPLETE_BEFORE_COMPUTE -> { + val error = runCatching { eccKeyGenSessions.complete(sessionId) }.exceptionOrNull() + check(error is TKeeperException && error.errorType == ErrorType.SESSION_NOT_FOUND) { + "ECC DKG session remained active after cleanup" + } + } + FailureProtocolStateVector.PQC_DKG_REPLAY_ROUND1, + FailureProtocolStateVector.PQC_DKG_CONCURRENT_ROUND1 -> { + val error = runCatching { pqcKeyGenSessions.round1(sessionId) }.exceptionOrNull() + check(error is TKeeperException && error.errorType == ErrorType.SESSION_NOT_FOUND) { + "PQC DKG session remained active after cleanup" + } + } + FailureProtocolStateVector.FROST_SIGN_BEFORE_COMMITMENT_COLLECTION -> + check(signingSessions.findThresholdSession(FROST_SESSION, sessionId) == null) { + "FROST session remained active after cleanup" + } + FailureProtocolStateVector.GG20_SIGN_BEFORE_SETUP -> + check(signingSessions.findThresholdSession(GG20_SESSION, sessionId) == null) { + "GG20 session remained active after cleanup" + } + FailureProtocolStateVector.MLDSA_SIGN_REPLAY_ROUND1, + FailureProtocolStateVector.MLDSA_SIGN_CONCURRENT_ROUND1 -> + check(signingSessions.findThresholdSession(MLDSA_SESSION, sessionId) == null) { + "ML-DSA signing session remained active after cleanup" + } + } + } + + private fun protocolProbeResult( + peerId: Int, + protocol: String, + vector: FailureProtocolStateVector, + active: Boolean?, + error: ErrorMessage? = null + ) = FailureProtocolProbeResult( + peerId = peerId, + protocol = protocol, + vector = vector.name, + rejected = error != null, + errorType = error?.error, + errorDetails = error?.details, + active = active + ) + private fun

> exerciseFrostSigning( manager: FrostSessionManager

, remoteApi: FrostApi, @@ -1805,19 +2264,6 @@ class FailureInjectionService( generation = requestedGeneration ?: current - if (generation != current) { - throw TKeeperException( - ErrorType.INVALID_KEY_GENERATION, - "Can mutate only active generation. current=$current requested=$generation" - ) - } - - val head = db.meta.loadKeysetMeta(tx, logicalId) - ?: throw TKeeperException( - ErrorType.KEY_NOT_FOUND, - "$logicalId metadata" - ) - val version = db.meta.loadVersionedMeta( tx, logicalId, @@ -1829,10 +2275,11 @@ class FailureInjectionService( val now = Instant.now().epochSecond - db.meta.saveKeysetMeta( - tx, - mutate(head.copy(updatedAt = now)) - ) + if (generation == current) { + val head = db.meta.loadKeysetMeta(tx, logicalId) + ?: throw TKeeperException(ErrorType.KEY_NOT_FOUND, "$logicalId metadata") + db.meta.saveKeysetMeta(tx, mutate(head.copy(updatedAt = now))) + } db.meta.saveVersionedMeta( tx, @@ -2202,6 +2649,8 @@ class FailureInjectionService( const val ECDSA_SCHEME = "ECDSA" const val SCHNORR_SCHEME = "SCHNORR" const val MLDSA_SCHEME = MLDSAKeyAlgorithmProvider.SIGNATURE_SCHEME + const val PROTOCOL_TARGET_PEER_ID = 1 + const val PROTOCOL_COORDINATOR_PEER_ID = 2 const val CONCURRENT_REPLAY_ATTEMPTS = 8 val SIGNING_SCHEMES = setOf(ECDSA_SCHEME, SCHNORR_SCHEME, MLDSA_SCHEME) val ROUND1_REJECTION_VECTORS = setOf( diff --git a/integration-tests/failure-injection/src/main/kotlin/org/exploit/keeper/module/failure/FailureProtocolProbe.kt b/integration-tests/failure-injection/src/main/kotlin/org/exploit/keeper/module/failure/FailureProtocolProbe.kt index 7f77706a..c5f95739 100644 --- a/integration-tests/failure-injection/src/main/kotlin/org/exploit/keeper/module/failure/FailureProtocolProbe.kt +++ b/integration-tests/failure-injection/src/main/kotlin/org/exploit/keeper/module/failure/FailureProtocolProbe.kt @@ -100,6 +100,26 @@ data class FailureMLDSASigningRequest( val generation: Int? = null ) +data class FailureProtocolStateRequest( + val keyId: String, + val vector: FailureProtocolStateVector, + val sessionId: String, + val action: FailureProtocolStateAction, + val generation: Int? = null +) + +enum class FailureProtocolStateAction { STAGE, ATTACK, CLEAR } + +enum class FailureProtocolStateVector { + ECC_DKG_COMPLETE_BEFORE_COMPUTE, + PQC_DKG_REPLAY_ROUND1, + PQC_DKG_CONCURRENT_ROUND1, + FROST_SIGN_BEFORE_COMMITMENT_COLLECTION, + GG20_SIGN_BEFORE_SETUP, + MLDSA_SIGN_REPLAY_ROUND1, + MLDSA_SIGN_CONCURRENT_ROUND1 +} + enum class FailureMLDSASigningVector { VALID_BASELINE, TAMPER_OWN_COMMITMENT, diff --git a/integration-tests/functional/build.gradle b/integration-tests/functional/build.gradle index e3d396f4..5cfa985a 100644 --- a/integration-tests/functional/build.gradle +++ b/integration-tests/functional/build.gradle @@ -4,7 +4,7 @@ plugins { } group = 'org.exploit' -version = '2.3.2' +version = '2.4.0' repositories { mavenCentral() @@ -37,6 +37,11 @@ test { maxParallelForks = 1 systemProperty("junit.jupiter.execution.parallel.enabled", "false") + testLogging { + events "failed" + exceptionFormat = "full" + } + if (org.gradle.internal.os.OperatingSystem.current().isMacOsX()) { doFirst { def home = System.getProperty("user.home") diff --git a/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/FailureInjectionTests.kt b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/FailureInjectionTests.kt index d4530f44..0d2bc123 100644 --- a/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/FailureInjectionTests.kt +++ b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/FailureInjectionTests.kt @@ -114,6 +114,22 @@ class FailureInjectionTests : TKeeperComposeEnvironment() { } } + @Test + fun consistencyFixPromotesPendingWhenActiveIsMissing() { + client(1).dkg().generate(generate(PENDING_ONLY_KEY, KeyGenMode.CREATE)) + + val injection = failure(3).demoteCurrentToPending(PENDING_ONLY_KEY) + assertEquals(1, injection.activeBefore) + assertNull(injection.activeAfter) + assertEquals(1, injection.pendingGeneration) + + val fixed = client(1).consistency().fix(PENDING_ONLY_KEY) + + assertEquals(ConsistencyVerdict.OK, fixed.verdict()) + assertEquals(setOf(1), fixed.versions().values.map { it.activeGen() }.toSet()) + assertTrue(fixed.versions().values.all { it.pendingGen() == null }) + } + @Test fun pqcConsistencyFixPromotesDemotedPendingGeneration() { client(1).dkg().generate(generatePQC(PQC_CONSISTENCY_KEY, KeyGenMode.CREATE)) @@ -159,6 +175,24 @@ class FailureInjectionTests : TKeeperComposeEnvironment() { assertNotNull(item.status()) } + @Test + fun consistencyFixDetectsKeyMissingFromOneKeeper() { + client(1).dkg().generate(generate(MISSING_KEY_STATE_KEY, KeyGenMode.CREATE)) + + val injection = failure(3).deleteActiveKeyState(MISSING_KEY_STATE_KEY) + assertEquals(3, injection.peerId) + assertEquals(1, injection.activeBefore) + + val fixed = client(1).consistency().fix(MISSING_KEY_STATE_KEY) + + assertEquals(ConsistencyVerdict.RECOVER_NEEDED, fixed.verdict()) + assertEquals(1, fixed.targetGeneration()) + assertEquals(1, fixed.versions()["1"]?.activeGen()) + assertEquals(1, fixed.versions()["2"]?.activeGen()) + assertNull(fixed.versions()["3"]?.activeGen()) + assertNull(fixed.versions()["3"]?.pendingGen()) + } + @Test fun arbitraryAuthorityInjectedOnOneCoordinatorPeerDoesNotReachThreshold() { client(1).dkg().generate( @@ -397,8 +431,8 @@ class FailureInjectionTests : TKeeperComposeEnvironment() { assertTrue(result.rejected, "ECIES accepted malicious participant vector $vector") assertEquals( if (vector == FailureParticipantVector.OMIT_LOCAL_PARTICIPANT) - ErrorType.NOT_PARTICIPANT.name - else ErrorType.INVALID_REQUEST_BODY.name, + ErrorType.NOT_PARTICIPANT + else ErrorType.INVALID_REQUEST_BODY, result.errorType, "ECIES rejected $vector for the wrong reason" ) @@ -475,6 +509,100 @@ class FailureInjectionTests : TKeeperComposeEnvironment() { ) } + @ParameterizedTest(name = "keeper protocol state rejects {0}") + @EnumSource(FailureProtocolStateVector::class) + fun keeperProtocolStateRejectsReorderedReplayAndConcurrentTransitions( + vector: FailureProtocolStateVector + ) { + val (keyId, scheme) = when (vector) { + FailureProtocolStateVector.ECC_DKG_COMPLETE_BEFORE_COMPUTE -> + MALICIOUS_SIGNING_PACKAGE_KEY to SignatureSchemes.ECDSA + FailureProtocolStateVector.PQC_DKG_REPLAY_ROUND1, + FailureProtocolStateVector.PQC_DKG_CONCURRENT_ROUND1, + FailureProtocolStateVector.MLDSA_SIGN_REPLAY_ROUND1, + FailureProtocolStateVector.MLDSA_SIGN_CONCURRENT_ROUND1 -> + MALICIOUS_MLDSA_TRANSCRIPT_KEY to SignatureSchemes.MLDSA + FailureProtocolStateVector.FROST_SIGN_BEFORE_COMMITMENT_COLLECTION -> + MALICIOUS_SIGNING_PACKAGE_KEY to SignatureSchemes.SCHNORR + FailureProtocolStateVector.GG20_SIGN_BEFORE_SETUP -> + MALICIOUS_GG20_MTA_KEY to SignatureSchemes.ECDSA + } + val sessionId = "ps-${UUID.randomUUID()}" + val stagedPeers = when (vector) { + FailureProtocolStateVector.PQC_DKG_REPLAY_ROUND1, + FailureProtocolStateVector.PQC_DKG_CONCURRENT_ROUND1 -> listOf(2, 3) + else -> emptyList() + } + stagedPeers.forEach { peerId -> + val staged = failure(peerId).probeProtocolState( + keyId, + vector, + sessionId, + FailureProtocolStateAction.STAGE + ) + assertEquals(peerId, staged.peerId) + assertFalse(staged.rejected, "Could not stage $vector on keeper-$peerId") + assertEquals(true, staged.active) + } + + try { + val attacked = failure(2).probeProtocolState( + keyId, + vector, + sessionId, + FailureProtocolStateAction.ATTACK + ) + assertEquals(1, attacked.peerId, "Attack must cross the keeper-2 -> keeper-1 transport") + assertStateGuardRejection(vector, attacked) + + when (vector) { + FailureProtocolStateVector.PQC_DKG_CONCURRENT_ROUND1, + FailureProtocolStateVector.MLDSA_SIGN_CONCURRENT_ROUND1 -> { + assertEquals(1, attacked.acceptedAttempts, "$vector admitted multiple transition winners") + assertEquals(7, attacked.rejectedAttempts, "$vector did not reject every losing transition") + } + FailureProtocolStateVector.PQC_DKG_REPLAY_ROUND1, + FailureProtocolStateVector.MLDSA_SIGN_REPLAY_ROUND1 -> { + assertEquals(1, attacked.acceptedAttempts, "$vector rejected its first valid transition") + assertEquals(1, attacked.rejectedAttempts, "$vector accepted the replay") + } + else -> { + assertEquals(0, attacked.acceptedAttempts, "$vector accepted an out-of-order transition") + assertEquals(1, attacked.rejectedAttempts) + } + } + } finally { + (stagedPeers + 1).asReversed().forEach { peerId -> + val cleared = failure(peerId).probeProtocolState( + keyId, + vector, + sessionId, + FailureProtocolStateAction.CLEAR + ) + assertFalse(cleared.rejected, "Could not clear $vector on keeper-$peerId") + assertEquals(false, cleared.active) + } + } + + signAndVerify( + keyId = keyId, + command = arbitraryCommand("after-protocol-state-${vector.name.lowercase()}", scheme), + scheme = scheme + ) + } + + private fun assertStateGuardRejection( + vector: FailureProtocolStateVector, + result: FailureProtocolProbeResult + ) { + assertTrue(result.rejected, "$vector was accepted") + assertEquals( + ErrorType.ACCESS_DENIED, + result.errorType, + "$vector did not fail closed at the internal protocol boundary" + ) + } + @ParameterizedTest(name = "{0} state is purged by keeper restart") @EnumSource(FailureSessionCheckpoint::class) fun inFlightProtocolStateDoesNotSurviveKeeperRestart(checkpoint: FailureSessionCheckpoint) { @@ -506,7 +634,7 @@ class FailureInjectionTests : TKeeperComposeEnvironment() { FailureSessionCheckpointAction.RESUME ) assertTrue(stale.rejected, "$checkpoint resumed after process restart") - assertEquals(ErrorType.SESSION_NOT_FOUND.name, stale.errorType) + assertEquals(ErrorType.SESSION_NOT_FOUND, stale.errorType) assertEquals(false, stale.active) val restaged = failure(2).protocolSessionCheckpoint( @@ -746,10 +874,10 @@ class FailureInjectionTests : TKeeperComposeEnvironment() { ) { val result = failure(2).probeSigningPackage(keyId, scheme, vector) val expectedError = when (vector) { - FailureSigningPackageVector.OMIT_LOCAL_PARTICIPANT -> ErrorType.NOT_PARTICIPANT.name + FailureSigningPackageVector.OMIT_LOCAL_PARTICIPANT -> ErrorType.NOT_PARTICIPANT FailureSigningPackageVector.REPLAY_SESSION_ID, - FailureSigningPackageVector.CONCURRENT_REPLAY_SESSION_ID -> ErrorType.SESSION_ALREADY_EXISTS.name - else -> ErrorType.INVALID_REQUEST_BODY.name + FailureSigningPackageVector.CONCURRENT_REPLAY_SESSION_ID -> ErrorType.SESSION_ALREADY_EXISTS + else -> ErrorType.INVALID_REQUEST_BODY } assertTrue(result.rejected, "$scheme accepted malicious signing package vector $vector") @@ -796,8 +924,8 @@ class FailureInjectionTests : TKeeperComposeEnvironment() { when (vector) { FailureMLDSASigningVector.TAMPER_OWN_COMMITMENT, FailureMLDSASigningVector.TAMPER_COMMITMENT_OPENING -> "SecurityException" - FailureMLDSASigningVector.REUSE_ROUND1_STATE -> "IllegalStateException" - FailureMLDSASigningVector.REUSE_ROUND2_STATE -> "NullPointerException" + FailureMLDSASigningVector.REUSE_ROUND1_STATE, + FailureMLDSASigningVector.REUSE_ROUND2_STATE -> ErrorType.INVALID_REQUEST_BODY FailureMLDSASigningVector.VALID_BASELINE -> error("Baseline is not a malicious transcript vector") else -> "IllegalArgumentException" } @@ -932,8 +1060,10 @@ class FailureInjectionTests : TKeeperComposeEnvironment() { private const val TAMPERED_META_KEY = "failure-tampered-meta" private const val CONSISTENCY_KEY = "failure-consistency" + private const val PENDING_ONLY_KEY = "failure-pending-only" private const val PQC_CONSISTENCY_KEY = "failure-pqc-consistency" private const val MISSING_MATERIAL_KEY = "failure-missing-material" + private const val MISSING_KEY_STATE_KEY = "failure-missing-key-state" private const val AUTHORITY_BYPASS_KEY = "failure-authority-bypass" private const val FOUR_EYE_BYPASS_KEY = "failure-four-eye-bypass" private const val TIME_POLICY_BYPASS_KEY = "failure-time-policy-bypass" diff --git a/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/ProductionTransportSecurityTests.kt b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/ProductionTransportSecurityTests.kt index 45d0263b..98df1650 100644 --- a/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/ProductionTransportSecurityTests.kt +++ b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/ProductionTransportSecurityTests.kt @@ -2,30 +2,12 @@ package org.exploit.test.functional import org.exploit.test.security.ProductionTlsCluster 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.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.PeerState -import org.exploit.tkeeper.sdk.model.Sign -import org.exploit.tkeeper.sdk.model.SignatureSchemes -import org.exploit.tkeeper.sdk.model.StoreState -import org.exploit.tkeeper.sdk.model.Verify +import org.exploit.tkeeper.sdk.model.* import org.exploit.tkeeper.sdk.model.command.Command import org.exploit.tkeeper.sdk.model.command.artifact.ArbitraryData -import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.* import org.junit.jupiter.api.Assertions.assertAll -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.MethodOrderer -import org.junit.jupiter.api.Order -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.junit.jupiter.api.TestMethodOrder -import org.junit.jupiter.api.assertDoesNotThrow -import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.function.Executable import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments @@ -39,7 +21,7 @@ import java.security.KeyStore import java.security.MessageDigest import java.security.cert.X509Certificate import java.time.Duration -import java.util.Base64 +import java.util.* import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors @@ -48,11 +30,7 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.stream.Stream import javax.net.ssl.SSLSocket import kotlin.io.path.inputStream -import kotlin.test.assertContains -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue +import kotlin.test.* @Tag("production-security") @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -212,7 +190,7 @@ class ProductionTransportSecurityTests { ) assertEquals(403, response.statusCode()) - assertContains(response.body(), ErrorType.ACCESS_DENIED.name) + assertContains(response.body(), ErrorType.ACCESS_DENIED) } @Test @@ -565,7 +543,7 @@ class ProductionTransportSecurityTests { private fun assertUnauthenticated(response: HttpResponse) { assertEquals(401, response.statusCode()) - assertContains(response.body(), ErrorType.UNAUTHENTICATED.name) + assertContains(response.body(), ErrorType.UNAUTHENTICATED) } private fun signingRequest(message: String): Sign { diff --git a/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/RecoveryFailureInjectionTests.kt b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/RecoveryFailureInjectionTests.kt new file mode 100644 index 00000000..ce8a8e89 --- /dev/null +++ b/integration-tests/functional/src/test/kotlin/org/exploit/test/functional/RecoveryFailureInjectionTests.kt @@ -0,0 +1,297 @@ +package org.exploit.test.functional + +import org.exploit.test.security.ProductionTlsCluster +import org.exploit.tkeeper.sdk.exception.TKeeperException +import org.exploit.tkeeper.sdk.model.* +import org.exploit.tkeeper.sdk.model.KeyAlgorithms.* +import org.exploit.tkeeper.sdk.model.command.Command +import org.exploit.tkeeper.sdk.model.command.artifact.ArbitraryData +import org.junit.jupiter.api.* +import org.slf4j.LoggerFactory +import java.time.Instant +import java.util.* +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@Tag("recovery") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class RecoveryFailureInjectionTests { + private lateinit var cluster: ProductionTlsCluster + + @BeforeAll + fun startCluster() { + cluster = ProductionTlsCluster.createRecoveryFailure() + cluster.start() + cluster.bootstrap() + } + + @AfterAll + fun stopCluster() { + if (::cluster.isInitialized) cluster.close() + } + + @Test + fun recoversComplementaryDamageAndHistoryAcrossTwoPeers() { + generateHistory(ECC_KEY, listOf(SECP256K1, SECP256K1, SECP256K1)) + generateHistory(PQC_KEY, listOf(MLDSA44, MLDSA65, MLDSA44)) + generateHistory(PQC_DONOR_KEY, listOf(MLDSA44)) + generateHistory(NEIGHBOR_KEY, listOf(SECP256K1)) + + val eccPublicKeys = publicKeys(ECC_KEY) + val pqcPublicKeys = publicKeys(PQC_KEY) + + cluster.client(1).destroy().destroy(KeyReference(ECC_KEY, DESTROYED_GENERATION)) + cluster.client(1).destroy().destroy(KeyReference(PQC_KEY, DESTROYED_GENERATION)) + + val eccInventory = historicalInventory(1, ECC_KEY) + val pqcInventory = historicalInventory(1, PQC_KEY) + val neighborInventory = historicalInventory(1, NEIGHBOR_KEY) + val neighborPublicKey = cluster.client(1).central().getPublicKey(NEIGHBOR_KEY, 1, null).data64() + + // Reset and install share a transaction: failure after reset must retain the old state. + val rollbackInventory = historicalInventory(4, ECC_KEY) + cluster.failure(4).probeRecoveryResetRollback(ECC_KEY) + assertEquals(rollbackInventory, historicalInventory(4, ECC_KEY)) + + // keeper-4 has present-but-wrong material, metadata, policies, side-state, and destroy state. + cluster.failure(4).corruptActiveKeyMaterial(ECC_KEY) + cluster.failure(4).clearPolicy(ECC_KEY, PREVIOUS_GENERATION) + cluster.failure(4).deleteDestroyMarker(ECC_KEY, DESTROYED_GENERATION) + cluster.failure(4).injectActiveKeyMaterial( + ECC_KEY, + ECC_KEY, + DESTROYED_GENERATION, + PREVIOUS_GENERATION, + ) + cluster.failure(4).deleteRecoverySideState(ECC_KEY, CURRENT_GENERATION) + cluster.failure(4).tamperHeadMeta(ECC_KEY) + + cluster.failure(4).replaceActiveKeyMaterial(PQC_KEY, PQC_DONOR_KEY) + cluster.failure(4).clearPolicy(PQC_KEY, PREVIOUS_GENERATION) + cluster.failure(4).deleteDestroyMarker(PQC_KEY, DESTROYED_GENERATION) + cluster.failure(4).injectActiveKeyMaterial( + PQC_KEY, + PQC_KEY, + DESTROYED_GENERATION, + PREVIOUS_GENERATION, + ) + cluster.failure(4).tamperActiveMLDSAPublicKey(PQC_KEY) + + // keeper-5 has a rolled-back head, orphan pending state, and a missing historical share. + cluster.failure(5).demoteCurrentToPending(ECC_KEY) + cluster.failure(5).deleteActiveKeyMaterial(ECC_KEY, PREVIOUS_GENERATION) + cluster.failure(5).demoteCurrentToPending(PQC_KEY) + cluster.failure(5).deleteActiveKeyMaterial(PQC_KEY, PREVIOUS_GENERATION) + + cluster.switchRecoveryMode(true) + + val blocked = assertThrows { + cluster.client(1).signature().sign(Sign.of(ECC_KEY, command("blocked", SignatureSchemes.ECDSA))) + } + assertEquals(ErrorType.RECOVERY_MODE_ACTIVE, blocked.errorType) + + val implicitHelpers = assertThrows { + cluster.client(4).recovery().recover(RecoverRequest(ECC_KEY, emptyList())) + } + assertEquals(ErrorType.INVALID_THRESHOLD, implicitHelpers.errorType) + + // A damaged keeper cannot form helper consensus even though its recovery routes are reachable. + assertThrows { + cluster.client(5).recovery().recover(RecoverRequest(ECC_KEY, listOf(1, 2, 4))) + } + + val eccMetaRecovery = cluster.client(4).recovery() + .recover(RecoverRequest(ECC_KEY, HEALTHY_HELPERS)) + assertEquals(listOf(PREVIOUS_GENERATION, CURRENT_GENERATION), eccMetaRecovery.recoveredGenerations()) + + val eccShareRecovery = cluster.client(5).recovery() + .recover(RecoverRequest(ECC_KEY, HEALTHY_HELPERS)) + assertEquals(listOf(PREVIOUS_GENERATION, CURRENT_GENERATION), eccShareRecovery.recoveredGenerations()) + + assertThrows { + cluster.client(5).recovery().recover(RecoverRequest(PQC_KEY, listOf(1, 2, 4))) + } + + val pqcMetaRecovery = cluster.client(4).recovery() + .recover(RecoverRequest(PQC_KEY, HEALTHY_HELPERS)) + assertEquals(listOf(PREVIOUS_GENERATION, CURRENT_GENERATION), pqcMetaRecovery.recoveredGenerations()) + + val pqcShareRecovery = cluster.client(5).recovery() + .recover(RecoverRequest(PQC_KEY, HEALTHY_HELPERS)) + assertEquals(listOf(PREVIOUS_GENERATION, CURRENT_GENERATION), pqcShareRecovery.recoveredGenerations()) + + // Public-key reads are deliberately available while every other key operation remains blocked. + assertPublicKeys(ECC_KEY, eccPublicKeys) + assertPublicKeys(PQC_KEY, pqcPublicKeys) + + cluster.switchRecoveryMode(false) + + signAndVerify(4, ECC_KEY, SignatureSchemes.ECDSA) + signAndVerify(5, ECC_KEY, SignatureSchemes.ECDSA) + signAndVerify(4, PQC_KEY, SignatureSchemes.MLDSA) + signAndVerify(5, PQC_KEY, SignatureSchemes.MLDSA) + assertPublicKeys(ECC_KEY, eccPublicKeys) + assertPublicKeys(PQC_KEY, pqcPublicKeys) + + // Prefix-adjacent key ids must remain untouched by recovery of ECC_KEY. + assertEquals(neighborInventory, historicalInventory(4, NEIGHBOR_KEY)) + assertEquals( + neighborPublicKey, + cluster.client(4).central().getPublicKey(NEIGHBOR_KEY, 1, null).data64(), + ) + assertOwnerIndex(4, NEIGHBOR_KEY, generation = 1) + signAndVerify(4, NEIGHBOR_KEY, SignatureSchemes.ECDSA) + + for (peerId in listOf(4, 5)) { + assertEquals(eccInventory, historicalInventory(peerId, ECC_KEY)) + assertEquals(pqcInventory, historicalInventory(peerId, PQC_KEY)) + assertOwnerIndex(peerId, ECC_KEY) + assertOwnerIndex(peerId, PQC_KEY) + } + + for (peerId in 1..TOTAL) { + val ecc = inventory(peerId, ECC_KEY) + val pqc = inventory(peerId, PQC_KEY) + assertEquals(CURRENT_GENERATION, ecc.currentGeneration(), "ECC head differs on keeper-$peerId") + assertEquals(CURRENT_GENERATION, pqc.currentGeneration(), "PQC head differs on keeper-$peerId") + assertTrue(ecc.hasActiveKey(), "ECC current material is missing on keeper-$peerId") + assertTrue(pqc.hasActiveKey(), "PQC current material is missing on keeper-$peerId") + + assertDestroyed(peerId, ECC_KEY) + assertDestroyed(peerId, PQC_KEY) + } + } + + private fun generateHistory(keyId: String, algorithms: List) { + algorithms.forEachIndexed { index, algorithm -> + val generation = index + 1 + val mode = if (index == 0) KeyGenMode.CREATE else KeyGenMode.ROTATE + val apply = Instant.now().plusSeconds(3_600L + index * 100L) + val policy = KeySetPolicy( + NotAfter(TimestampUnit.SECONDS, apply.epochSecond), + NotAfter(TimestampUnit.SECONDS, apply.plusSeconds(1_000).epochSecond), + index != 1, + ) + try { + cluster.client(1).dkg().generate( + Generate( + keyId, + algorithm, + KeySetAuthorities.arbitrary(), + mode, + policy, + owner(keyId, generation), + null, + ) + ) + } catch (failure: Exception) { + throw failureWithKeeperLogs( + "DKG failed: keeper-1 $keyId/$generation $algorithm $mode, ${failure.message}", + failure, + ) + } + } + } + + private fun publicKeys(keyId: String): Map = + (PREVIOUS_GENERATION..CURRENT_GENERATION).associateWith { generation -> + cluster.client(1).central().getPublicKey(keyId, generation, null).data64() + } + + private fun assertPublicKeys(keyId: String, expected: Map) { + for (peerId in 1..TOTAL) { + for (generation in PREVIOUS_GENERATION..CURRENT_GENERATION) { + assertEquals( + expected.getValue(generation), + cluster.client(peerId).central().getPublicKey(keyId, generation, null).data64(), + "$keyId/$generation differs on keeper-$peerId", + ) + } + } + } + + private fun signAndVerify(peerId: Int, keyId: String, scheme: String) { + val context = "keeper-$peerId $keyId $scheme" + val command = command("$keyId-after-recovery", scheme) + val signature = try { + LOGGER.info("Recovery signature started: {}", context) + cluster.client(peerId).signature().sign(Sign.of(keyId, command)) + } catch (failure: TKeeperException) { + LOGGER.error("Recovery signature failed: $context, ${failure.message}", failure) + throw failureWithKeeperLogs( + "Recovery signature failed: $context, ${failure.message}", + failure, + ) + } + + assertEquals(scheme, signature.type(), "$context returned a different signature scheme") + assertTrue( + cluster.client(peerId).signature() + .verify(Verify.of(keyId, command, signature.signature64())) + .valid(), + "$context produced an invalid signature after recovery", + ) + LOGGER.info("Recovery signature completed: {}", context) + } + + private fun failureWithKeeperLogs(message: String, failure: Exception): AssertionError { + val tails = (1..TOTAL).joinToString("\n") { peerId -> + val tail = runCatching { cluster.logs(peerId).lines().takeLast(200).joinToString("\n") } + .getOrDefault("") + "===== prod-keeper-$peerId log tail =====\n$tail" + } + return AssertionError("$message\n$tails", failure) + } + + private fun inventory(peerId: Int, keyId: String) = + cluster.client(peerId).compliance() + .inventory(keyId, false, null, null, 10) + .inventory() + .items() + .single { it.logicalId() == keyId } + + private fun historicalInventory(peerId: Int, keyId: String) = + cluster.client(peerId).compliance() + .inventory(keyId, true, null, null, 10) + .inventory() + .items() + + private fun assertOwnerIndex(peerId: Int, keyId: String, generation: Int = CURRENT_GENERATION) { + val items = cluster.client(peerId).compliance() + .inventoryForOwner(owner(keyId, generation), 10) + .inventory() + .items() + assertEquals(listOf(keyId), items.map { it.logicalId() }) + } + + private fun owner(keyId: String, generation: Int) = "owner:$keyId:$generation" + + private fun assertDestroyed(peerId: Int, keyId: String) { + val error = assertThrows { + cluster.client(peerId).central().getPublicKey(keyId, DESTROYED_GENERATION, null) + } + assertEquals(ErrorType.KEY_NOT_FOUND, error.errorType, "$keyId/1 was resurrected on keeper-$peerId") + } + + private fun command(label: String, scheme: String): Command = + Command.arbitrary( + ArbitraryData( + scheme, + HashMethod.SHA256, + Base64.getEncoder().encodeToString(label.toByteArray()), + ) + ) + + private companion object { + private val LOGGER = LoggerFactory.getLogger(RecoveryFailureInjectionTests::class.java) + const val ECC_KEY = "recovery-it-ecc-history" + const val PQC_KEY = "recovery-it-pqc-history" + const val PQC_DONOR_KEY = "recovery-it-pqc-donor" + const val NEIGHBOR_KEY = "$ECC_KEY:neighbor" + const val DESTROYED_GENERATION = 1 + const val PREVIOUS_GENERATION = 2 + const val CURRENT_GENERATION = 3 + const val TOTAL = 5 + val HEALTHY_HELPERS = listOf(1, 2, 3) + } +} diff --git a/integration-tests/performance/build.gradle b/integration-tests/performance/build.gradle index b9ab2e80..8b477161 100644 --- a/integration-tests/performance/build.gradle +++ b/integration-tests/performance/build.gradle @@ -6,7 +6,7 @@ plugins { } group = 'org.exploit' -version = '2.3.2' +version = '2.4.0' repositories { mavenCentral() diff --git a/integration-tests/src/testFixtures/kotlin/org/exploit/test/failure/FailureInjectionClient.kt b/integration-tests/src/testFixtures/kotlin/org/exploit/test/failure/FailureInjectionClient.kt index 2b869c02..4658fe45 100644 --- a/integration-tests/src/testFixtures/kotlin/org/exploit/test/failure/FailureInjectionClient.kt +++ b/integration-tests/src/testFixtures/kotlin/org/exploit/test/failure/FailureInjectionClient.kt @@ -9,9 +9,10 @@ import java.net.http.HttpResponse class FailureInjectionClient( private val baseUrl: String, - private val devToken: String + private val token: String, + private val http: HttpClient = HttpClient.newHttpClient(), + private val tokenHeader: String = "X-DEV-TOKEN", ) { - private val http = HttpClient.newHttpClient() private val mapper = jacksonObjectMapper() fun tamperHeadMeta(keyId: String): FailureInjectionResult = @@ -60,6 +61,21 @@ class FailureInjectionClient( fun deleteActiveKeyMaterial(keyId: String, generation: Int? = null): FailureInjectionResult = post("/v1/keeper/failure-injection/key/delete-active-material", FailureKeyRequest(keyId, generation)) + fun deleteActiveKeyState(keyId: String, generation: Int? = null): FailureInjectionResult = + post("/v1/keeper/failure-injection/key/delete-active-state", FailureKeyRequest(keyId, generation)) + + fun deleteActiveMetadata(keyId: String, generation: Int? = null): FailureInjectionResult = + post("/v1/keeper/failure-injection/meta/delete-active", FailureKeyRequest(keyId, generation)) + + fun deleteDestroyMarker(keyId: String, generation: Int): FailureInjectionResult = + post("/v1/keeper/failure-injection/destroy/delete-marker", FailureKeyRequest(keyId, generation)) + + fun deleteRecoverySideState(keyId: String, generation: Int? = null): FailureInjectionResult = + post("/v1/keeper/failure-injection/recovery/delete-side-state", FailureKeyRequest(keyId, generation)) + + fun probeRecoveryResetRollback(keyId: String): FailureInjectionResult = + post("/v1/keeper/failure-injection/recovery/probe-reset-rollback", FailureKeyRequest(keyId)) + fun corruptActiveKeyMaterial(keyId: String, generation: Int? = null): FailureInjectionResult = post("/v1/keeper/failure-injection/key/corrupt-active-material", FailureKeyRequest(keyId, generation)) @@ -95,6 +111,16 @@ class FailureInjectionClient( FailureKeyCopyRequest(keyId, sourceKeyId, generation, sourceGeneration) ) + fun injectActiveKeyMaterial( + keyId: String, + sourceKeyId: String, + generation: Int, + sourceGeneration: Int? = null + ): FailureInjectionResult = post( + "/v1/keeper/failure-injection/key/inject-active-material", + FailureKeyCopyRequest(keyId, sourceKeyId, generation, sourceGeneration) + ) + fun tamperActiveMLDSAPublicKey(keyId: String, generation: Int? = null): FailureInjectionResult = post("/v1/keeper/failure-injection/pqc/tamper-active-public-key", FailureKeyRequest(keyId, generation)) @@ -182,10 +208,21 @@ class FailureInjectionClient( FailureMLDSASigningRequest(keyId, vector, generation) ) + fun probeProtocolState( + keyId: String, + vector: FailureProtocolStateVector, + sessionId: String, + action: FailureProtocolStateAction, + generation: Int? = null + ): FailureProtocolProbeResult = post( + "/v1/keeper/failure-injection/protocol/state-machine", + FailureProtocolStateRequest(keyId, vector, sessionId, action, generation) + ) + private inline fun post(path: String, body: Any): T { val req = HttpRequest.newBuilder(URI.create(baseUrl + path)) .header("Content-Type", "application/json") - .header("X-DEV-TOKEN", devToken) + .header(tokenHeader, token) .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body))) .build() @@ -335,6 +372,26 @@ data class FailureMLDSASigningRequest( val generation: Int? = null ) +data class FailureProtocolStateRequest( + val keyId: String, + val vector: FailureProtocolStateVector, + val sessionId: String, + val action: FailureProtocolStateAction, + val generation: Int? = null +) + +enum class FailureProtocolStateAction { STAGE, ATTACK, CLEAR } + +enum class FailureProtocolStateVector { + ECC_DKG_COMPLETE_BEFORE_COMPUTE, + PQC_DKG_REPLAY_ROUND1, + PQC_DKG_CONCURRENT_ROUND1, + FROST_SIGN_BEFORE_COMMITMENT_COLLECTION, + GG20_SIGN_BEFORE_SETUP, + MLDSA_SIGN_REPLAY_ROUND1, + MLDSA_SIGN_CONCURRENT_ROUND1 +} + enum class FailureMLDSASigningVector { VALID_BASELINE, TAMPER_OWN_COMMITMENT, diff --git a/integration-tests/src/testFixtures/kotlin/org/exploit/test/security/ProductionSecurityMaterial.kt b/integration-tests/src/testFixtures/kotlin/org/exploit/test/security/ProductionSecurityMaterial.kt index 23946c92..d30deb4b 100644 --- a/integration-tests/src/testFixtures/kotlin/org/exploit/test/security/ProductionSecurityMaterial.kt +++ b/integration-tests/src/testFixtures/kotlin/org/exploit/test/security/ProductionSecurityMaterial.kt @@ -229,7 +229,8 @@ class ProductionSecurityMaterial private constructor( private val BASE64_URL = Base64.getUrlEncoder().withoutPadding() private const val TRUST_STORE_PASSWORD = "public-ca-only" - fun create(publicHost: String): ProductionSecurityMaterial { + fun create(publicHost: String, keeperCount: Int = 3): ProductionSecurityMaterial { + require(keeperCount >= 3) { "keeperCount must include the three production transport peers" } val root = Files.createTempDirectory("tkeeper-production-security-") val password = randomPassword() val keytool = keytoolPath() @@ -256,7 +257,7 @@ class ProductionSecurityMaterial private constructor( sanFor(publicHost) ) - val keeperStores = (1..3).associateWith { peerId -> + val keeperStores = (1..keeperCount).associateWith { peerId -> val alias = "prod-keeper-$peerId" val store = root.resolve("$alias.p12") createLeaf( diff --git a/integration-tests/src/testFixtures/kotlin/org/exploit/test/security/ProductionTlsCluster.kt b/integration-tests/src/testFixtures/kotlin/org/exploit/test/security/ProductionTlsCluster.kt index 98bfd250..e80e237c 100644 --- a/integration-tests/src/testFixtures/kotlin/org/exploit/test/security/ProductionTlsCluster.kt +++ b/integration-tests/src/testFixtures/kotlin/org/exploit/test/security/ProductionTlsCluster.kt @@ -5,6 +5,7 @@ import org.eclipse.jetty.util.ssl.SslContextFactory import org.exploit.jettyx.Jettyx import org.exploit.jettyx.http2.Http2Version import org.exploit.jettyx.jackson.JacksonHttpMapper +import org.exploit.test.failure.FailureInjectionClient import org.exploit.tkeeper.sdk.TKeeperClient import org.exploit.tkeeper.sdk.auth.JwtTokenAuth import org.exploit.tkeeper.sdk.model.KeeperInitData @@ -32,7 +33,7 @@ import java.security.MessageDigest import java.security.SecureRandom import java.security.cert.X509Certificate import java.time.Duration -import java.util.Base64 +import java.util.* import java.util.concurrent.ConcurrentHashMap import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext @@ -43,7 +44,10 @@ class ProductionTlsCluster private constructor( val security: ProductionSecurityMaterial, private val network: Network, private val containers: Map, - private val configFiles: Map + private val configFiles: Map, + private val threshold: Int, + private val total: Int, + adminPermissions: List, ) : AutoCloseable { enum class StartupFailureVariant(val expectedLog: String) { PUBLIC_TLS_DISABLED("Public server TLS must be enabled for the configured auth provider"), @@ -51,7 +55,7 @@ class ProductionTlsCluster private constructor( HTTP_PEER_URL("Peer URLs must use https when keeper.client.tls is enabled"), PEER_URL_WITH_CREDENTIALS("Peer URLs must be absolute HTTP(S) URLs without credentials or fragments"), PEER_URL_WITH_FRAGMENT("Peer URLs must be absolute HTTP(S) URLs without credentials or fragments"), - DUPLICATE_PEER_URL("Duplicate node URLs found"), + DUPLICATE_PEER_URL("Peer TLS endpoints must be unique when certificate binding is enabled"), INTERNAL_CLIENT_AUTH_DISABLED( "Internal TLS with client-auth must be enabled when peer certificate binding is configured" ), @@ -64,11 +68,17 @@ class ProductionTlsCluster private constructor( } private val clients = ConcurrentHashMap() + private val failureClients = ConcurrentHashMap() private val rawClients = ConcurrentHashMap.newKeySet() private val defaultRawClient: HttpClient by lazy { rawClient() } private val unsealShares = ConcurrentHashMap>() - val adminToken: String = security.adminToken() + @Volatile + private var recoveryEnabled = false + + val adminToken: String = security.token( + claims = ProductionSecurityMaterial.JwtClaims(permissions = adminPermissions) + ) fun start() { try { @@ -85,7 +95,7 @@ class ProductionTlsCluster private constructor( for (peerId in containers.keys.sorted()) { val keeper = client(peerId) if (keeper.system().status().state == StoreState.UNINITIALIZED) { - val init = keeper.system().init(KeeperInitData(peerId, THRESHOLD, TOTAL)) + val init = keeper.system().init(KeeperInitData(peerId, threshold, total)) require(init is ShamirInitData) { "Expected Shamir init data for prod-keeper-$peerId" } unsealShares[peerId] = init.shares64() } @@ -112,6 +122,16 @@ class ProductionTlsCluster private constructor( TKeeperClient(jettyx, publicBaseUrl(peerId), JwtTokenAuth(adminToken)) } + fun failure(peerId: Int): FailureInjectionClient = + failureClients.computeIfAbsent(peerId) { + FailureInjectionClient( + baseUrl = publicBaseUrl(peerId), + token = adminToken, + http = rawClient(), + tokenHeader = JWT_HEADER, + ) + } + fun rawClient( trustStore: Path = security.trustStore, keyStore: Path? = null, @@ -248,7 +268,7 @@ class ProductionTlsCluster private constructor( "prod-keeper-$peerId unexpectedly stayed ready across process restart" } val shares = requireNotNull(unsealShares[peerId]) { "Missing unseal shares for prod-keeper-$peerId" } - client(peerId).system().unseal(Unseal(shares.take(THRESHOLD))) + client(peerId).system().unseal(Unseal(shares.take(threshold))) check(client(peerId).system().ready()) { "prod-keeper-$peerId is not ready after unseal" } } } @@ -313,10 +333,41 @@ class ProductionTlsCluster private constructor( } fun restoreOutboundTls(peerId: Int = 1) { - copyConfig(peerId, renderConfig(peerId, security, security.keeperSpkiPins)) + copyConfig(peerId, currentConfig(peerId)) restart(peerId) } + fun switchRecoveryMode(enabled: Boolean) { + containers.keys.sorted().forEach { peerId -> + copyConfig(peerId, renderConfig( + peerId = peerId, + security = security, + pins = security.keeperSpkiPins, + total = total, + threshold = threshold, + recovery = enabled, + )) + resetClient(peerId) + } + recoveryEnabled = enabled + + containers.keys.sorted().forEach { peerId -> + container(peerId).dockerClient.restartContainerCmd(container(peerId).containerId).exec() + } + containers.keys.sorted().forEach { peerId -> + waitForHealthy(peerId, "prod-keeper-$peerId did not become healthy after recovery mode switch") + } + containers.keys.sorted().forEach { peerId -> + val shares = requireNotNull(unsealShares[peerId]) { "Missing unseal shares for prod-keeper-$peerId" } + client(peerId).system().unseal(Unseal(shares.take(threshold))) + val ready = if (enabled) + client(peerId).system().status().state == StoreState.UNSEALED + else + client(peerId).system().ready() + check(ready) { "prod-keeper-$peerId is not ready after recovery mode switch" } + } + } + fun assertStartupRejected( variant: StartupFailureVariant, peerId: Int = 1 @@ -355,7 +406,7 @@ class ProductionTlsCluster private constructor( } } - copyConfig(peerId, renderConfig(peerId, security, security.keeperSpkiPins)) + copyConfig(peerId, currentConfig(peerId)) container.dockerClient.startContainerCmd(container.containerId).exec() waitForHealthy( peerId, @@ -363,7 +414,7 @@ class ProductionTlsCluster private constructor( ) val shares = requireNotNull(unsealShares[peerId]) { "Missing unseal shares for prod-keeper-$peerId" } - client(peerId).system().unseal(Unseal(shares.take(THRESHOLD))) + client(peerId).system().unseal(Unseal(shares.take(threshold))) check(client(peerId).system().ready()) { "prod-keeper-$peerId is not ready after startup configuration recovery" } @@ -405,6 +456,7 @@ class ProductionTlsCluster private constructor( override fun close() { clients.values.forEach { runCatching { it.close() } } clients.clear() + failureClients.clear() rawClients.forEach { runCatching { it.shutdownNow() } } rawClients.clear() containers.values.toList().asReversed().forEach { runCatching { it.stop() } } @@ -413,8 +465,24 @@ class ProductionTlsCluster private constructor( } private fun replaceConfig(peerId: Int, pins: Map) { - copyConfig(peerId, renderConfig(peerId, security, pins)) - } + copyConfig(peerId, renderConfig( + peerId = peerId, + security = security, + pins = pins, + total = total, + threshold = threshold, + recovery = recoveryEnabled, + )) + } + + private fun currentConfig(peerId: Int): String = renderConfig( + peerId = peerId, + security = security, + pins = security.keeperSpkiPins, + total = total, + threshold = threshold, + recovery = recoveryEnabled, + ) private fun copyConfig(peerId: Int, config: String) { val path = configFiles.getValue(peerId) @@ -501,6 +569,7 @@ class ProductionTlsCluster private constructor( private fun resetClient(peerId: Int) { clients.remove(peerId)?.close() + failureClients.remove(peerId) } private fun waitForHealthy(peerId: Int, message: String) { @@ -545,22 +614,47 @@ class ProductionTlsCluster private constructor( private const val PEM_PUBLIC_PEER = 3 private val LOGGER = LoggerFactory.getLogger(ProductionTlsCluster::class.java) - fun create(): ProductionTlsCluster { + fun create(): ProductionTlsCluster = createCluster( + total = TOTAL, + threshold = THRESHOLD, + imageName = "exploit/tkeeper:production-it", + adminPermissions = ProductionSecurityMaterial.ADMIN_PERMISSIONS, + ) + + fun createRecoveryFailure(): ProductionTlsCluster = createCluster( + total = 5, + threshold = 3, + imageName = "exploit/tkeeper:dev", + adminPermissions = RECOVERY_FAILURE_PERMISSIONS, + ) + + private fun createCluster( + total: Int, + threshold: Int, + imageName: String, + adminPermissions: List, + ): ProductionTlsCluster { val publicHost = DockerClientFactory.instance().dockerHostIpAddress() - val security = ProductionSecurityMaterial.create(publicHost) + val security = ProductionSecurityMaterial.create(publicHost, total) val network = Network.newNetwork() try { Testcontainers.exposeHostPorts(security.jwksPort) - val configFiles = (1..TOTAL).associateWith { peerId -> + val configFiles = (1..total).associateWith { peerId -> security.root.resolve("keeper-$peerId.conf").also { path -> - Files.writeString(path, renderConfig(peerId, security, security.keeperSpkiPins)) + Files.writeString(path, renderConfig( + peerId = peerId, + security = security, + pins = security.keeperSpkiPins, + total = total, + threshold = threshold, + )) } } - val containers = (1..TOTAL).associateWith { peerId -> - val container = KeeperContainer() + val containers = (1..total).associateWith { peerId -> + val container = KeeperContainer(imageName) .withNetwork(network) .withNetworkAliases("prod-keeper-$peerId") .withAccessToHost(true) @@ -571,6 +665,7 @@ class ProductionTlsCluster private constructor( "JAVA_TOOL_OPTIONS", listOf( "-Djava.library.path=/usr/local/lib:/usr/lib64:/lib64:/lib:/usr/lib", + "-Dcom.linecorp.armeria.defaultRequestTimeoutMillis=30000", "-Djavax.net.ssl.trustStore=$CONTAINER_TRUST_STORE", "-Djavax.net.ssl.trustStorePassword=${security.trustStorePassword}", "-Djavax.net.ssl.trustStoreType=PKCS12" @@ -613,7 +708,15 @@ class ProductionTlsCluster private constructor( container } - return ProductionTlsCluster(security, network, containers, configFiles) + return ProductionTlsCluster( + security = security, + network = network, + containers = containers, + configFiles = configFiles, + threshold = threshold, + total = total, + adminPermissions = adminPermissions, + ) } catch (t: Throwable) { runCatching { network.close() } security.close() @@ -626,14 +729,18 @@ class ProductionTlsCluster private constructor( security: ProductionSecurityMaterial, pins: Map, clientTrustStore: String = CONTAINER_TRUST_STORE, - peerHosts: Map = (1..TOTAL).associateWith { "prod-keeper-$it" } + peerHosts: Map? = null, + total: Int = TOTAL, + threshold: Int = THRESHOLD, + recovery: Boolean = false, ): String { - val peers = (1..TOTAL) + val resolvedPeerHosts = peerHosts ?: (1..total).associateWith { "prod-keeper-$it" } + val peers = (1..total) .filter { it != peerId } .joinToString(",\n") { remoteId -> """ { id = $remoteId - internal-url = "https://${peerHosts.getValue(remoteId)}:$INTERNAL_PORT" + internal-url = "https://${resolvedPeerHosts.getValue(remoteId)}:$INTERNAL_PORT" tls-spki-sha256 = "${pins.getValue(remoteId)}" }""" } @@ -675,6 +782,7 @@ boot { token = "production-integration-boot-token" } keeper { instance-id = $peerId parallelism = 4 + recovery = $recovery database { path = "/tmp/tkeeper/db" } audit { @@ -684,11 +792,12 @@ keeper { providers { selected = "shamir" - shamir { total = $TOTAL, threshold = $THRESHOLD } + shamir { total = $total, threshold = $threshold } } client { tls = true + connect-timeout = 5000 trust-store-path = "$clientTrustStore" trust-store-password = "${security.trustStorePassword}" trust-store-type = "PKCS12" @@ -728,10 +837,26 @@ $peers } """.trimStart() } + + private val RECOVERY_FAILURE_PERMISSIONS = listOf( + "tkeeper.system.init", + "tkeeper.system.unseal", + "tkeeper.system.seal", + "tkeeper.dkg.create", + "tkeeper.dkg.refresh", + "tkeeper.dkg.rotate", + "tkeeper.key.*.public", + "tkeeper.key.*.sign", + "tkeeper.key.*.verify", + "tkeeper.key.*.destroy", + "tkeeper.compliance.inventory", + "tkeeper.recover", + "tkeeper.failure.inject", + ) } - private class KeeperContainer : - GenericContainer(DockerImageName.parse("exploit/tkeeper:production-it")) { + private class KeeperContainer(imageName: String) : + GenericContainer(DockerImageName.parse(imageName)) { override fun getContainerInfo(): InspectContainerResponse = if (containerId == null) super.getContainerInfo() else currentContainerInfo } diff --git a/openapi.yaml b/openapi.yaml index 829864a0..47b571f2 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -21,6 +21,7 @@ tags: - name: Inventory - name: ControlPlane - name: Consistency + - name: Recovery - name: Expiration - name: Integrity @@ -181,6 +182,7 @@ paths: tags: [Keys] operationId: publicKey summary: Get public key + description: Read-only key operation that remains available in recovery mode for generation comparison. x-permissions: template: tkeeper.key.{keyId}.public parameters: @@ -539,6 +541,39 @@ paths: default: $ref: '#/components/responses/ErrorResponse' + /v1/keeper/recovery/recover: + post: + tags: [Recovery] + operationId: recover + summary: Rebuild key-scoped state on this peer + description: | + Call this endpoint on the damaged keeper. The keeper asks exactly `threshold` remote helpers + for the key description, requires them to agree on the algorithm and current generation, and + selects the matching recovery flow locally. Available only when the artifact includes the + corresponding recovery feature and every participating keeper runs with + `keeper.recovery=true`. Supports any configured quorum with `1 < threshold < total`. + All non-destroyed generations must belong to one crypto platform; mixed ECC/PQC rotation + history fails closed without installing a partial recovery. + After all operator-selected helpers agree, the target discards and atomically rebuilds the + logical key's shares, metadata, policies, destroy state, public side-state, and derived indexes. + x-permissions: + required: [tkeeper.recover] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RecoverRequest' + responses: + '200': + description: All non-destroyed generations are present and verified on the target peer + content: + application/json: + schema: + $ref: '#/components/schemas/RecoverResult' + default: + $ref: '#/components/responses/ErrorResponse' + /v1/keeper/expires: get: tags: [Expiration] @@ -823,6 +858,10 @@ components: - KEEPER_NOT_INITIALIZED - KEEPER_SEALED - RESTART_REQUIRED + - RECOVERY_MODE_ACTIVE + - RECOVERY_NOT_ENABLED + - RECOVERY_NOT_POSSIBLE + - RECOVERY_FAILED - KEY_NOT_FOUND - SESSION_MANAGER_NOT_FOUND - SESSION_NOT_FOUND @@ -1958,7 +1997,48 @@ components: ConsistencyVerdict: type: string - enum: [OK, SYNC_NEEDED, ROTATE_NEEDED, MISSING] + enum: [OK, SYNC_NEEDED, ROTATE_NEEDED, RECOVER_NEEDED, MISSING] + + RecoverRequest: + type: object + additionalProperties: false + required: [keyId, helperIds] + properties: + keyId: + $ref: '#/components/schemas/KeyId' + helperIds: + type: array + description: Operator-selected canonical list of exactly `threshold` remote peer IDs. + minItems: 1 + items: + type: integer + format: int32 + minimum: 1 + + RecoverResult: + type: object + required: [keyId, algorithm, targetPeerId, currentGeneration, recoveredGenerations, helpers] + properties: + keyId: + $ref: '#/components/schemas/KeyId' + algorithm: + type: string + description: Algorithm selected from the helper consensus. + targetPeerId: + type: integer + format: int32 + currentGeneration: + $ref: '#/components/schemas/Generation' + recoveredGenerations: + type: array + items: + $ref: '#/components/schemas/Generation' + helpers: + type: array + items: + type: integer + format: int32 + minimum: 1 KeyVersion: type: object diff --git a/platform-ecc/build.gradle b/platform-ecc/build.gradle index 75f42512..a9ed6569 100644 --- a/platform-ecc/build.gradle +++ b/platform-ecc/build.gradle @@ -6,7 +6,7 @@ plugins { } group = 'org.exploit' -version = '2.3.2' +version = '2.4.0' repositories { mavenCentral() @@ -78,3 +78,18 @@ tasks.register('fuzzProtocolStateMachines', Test) { environment 'JAZZER_FUZZ', '1' systemProperty 'jazzer.max_duration', providers.gradleProperty('keeper.fuzz.duration').getOrElse('30s') } + +tasks.register('fuzzKeeperProtocolStateMachines', Test) { + group = 'verification' + description = 'Runs coverage-guided action sequences against keeper-owned ECC protocol phases.' + outputs.upToDateWhen { false } + + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform() + filter { + includeTestsMatching 'org.exploit.keeper.platform.ecc.fuzz.KeeperProtocolStateFuzzTest' + } + environment 'JAZZER_FUZZ', '1' + systemProperty 'jazzer.max_duration', providers.gradleProperty('keeper.fuzz.duration').getOrElse('30s') +} diff --git a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/consistency/ECKeyCommitsSyncHandler.kt b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/consistency/ECKeyCommitsSyncHandler.kt index 19f6f012..c86b3fcb 100644 --- a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/consistency/ECKeyCommitsSyncHandler.kt +++ b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/consistency/ECKeyCommitsSyncHandler.kt @@ -112,4 +112,8 @@ class ECKeyCommitsSyncHandler( override fun onActiveRolledBack(tx: Transaction, keyId: String, generation: Int) { commits.deleteActive(tx, keyId, generation) } -} \ No newline at end of file + + override fun onRecoveryReset(tx: Transaction, keyId: String) { + commits.clearForRecovery(tx, keyId) + } +} diff --git a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/dealer/commitment/DealerKeyMaterialService.kt b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/dealer/commitment/DealerKeyMaterialService.kt index fca76e40..06651983 100644 --- a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/dealer/commitment/DealerKeyMaterialService.kt +++ b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/dealer/commitment/DealerKeyMaterialService.kt @@ -38,7 +38,11 @@ class DealerKeyMaterialService { algorithm as ECKeyAlgorithmProvider<*, *, *, *> val privateKey = algorithm.factory().createPrivateKey(seed) - return publicKeyCommitment(algorithm, privateKey.d()) + return try { + publicKeyCommitment(algorithm, privateKey.d()) + } finally { + privateKey.destroy() + } } fun publicKeyCommitmentForShare(algorithm: KeyAlgorithmProvider, share: ByteArray): ByteArray { @@ -48,7 +52,16 @@ class DealerKeyMaterialService { algorithm as ECKeyAlgorithmProvider<*, *, *, *> val privateKey = algorithm.factory().createPrivateKey(share) - return publicKeyCommitment(algorithm, privateKey.encode().toBigInt()) + return try { + val encoded = privateKey.encode() + try { + publicKeyCommitment(algorithm, encoded.toBigInt()) + } finally { + encoded.fill(0) + } + } finally { + privateKey.destroy() + } } fun ensureCommitmentsMatchCurve(algorithm: KeyAlgorithmProvider, commitments: DkgCommitments, threshold: Int? = null) { @@ -195,7 +208,6 @@ class DealerKeyMaterialService { scalar.destroy() } - private fun

> derivePublicShare( curve: ECKeyAlgorithmProvider, peerId: Int, @@ -229,4 +241,4 @@ class DealerKeyMaterialService { private fun randomScalar(q: BigInt): BigInt = BigInt(q.bitLength(), CSPRNG.get()).mod(q) -} \ No newline at end of file +} diff --git a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/keygen/threshold/ThresholdKeyGenSessionManager.kt b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/keygen/threshold/ThresholdKeyGenSessionManager.kt index ed0c46dc..e033795c 100644 --- a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/keygen/threshold/ThresholdKeyGenSessionManager.kt +++ b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/keygen/threshold/ThresholdKeyGenSessionManager.kt @@ -56,7 +56,7 @@ class ThresholdKeyGenSessionManager( private val sessions = InMemoryTemporaryMap( config.session.dkg.expire.seconds, TimeUnit.SECONDS - ) { it.session.destroy() } + ) { it.destroy() } fun versions(keyId: String): KeyVersion = db.vault.readLatestVersion(keyId) @@ -76,30 +76,32 @@ class ThresholdKeyGenSessionManager( } fun share(idx: Int, sessionId: String): ShamirShareDto = - session(sessionId).session.shareFor(idx) + session(sessionId).let { wrapper -> + wrapper.protocol.sharing { wrapper.session.shareFor(idx) } + } fun store(keyId: String, peerId: Int, share: ShamirShareDto) { val wrapper = session(keyId) + var accepted = false + try { + wrapper.protocol.sharing { + val zeroConst = wrapper.mode == KeyGenMode.REFRESH + val valid = NodeKeyGen.verifyShare( + wrapper.algorithm.params(), + initData.peerId, + share.share, + share.commitments, + zeroConst + ) - val zeroConst = wrapper.mode == KeyGenMode.REFRESH - val valid = NodeKeyGen.verifyShare( - wrapper.algorithm.params(), - initData.peerId, - share.share, - share.commitments, - zeroConst - ) - - if (!valid) { - share.share.destroy() - throw TKeeperException(ErrorType.INVALID_SHARE) - } + if (!valid) + throw TKeeperException(ErrorType.INVALID_SHARE) - try { - wrapper.session.store(peerId, share.share, share.commitments) - } catch (e: Exception) { - share.share.destroy() - throw e + wrapper.session.store(peerId, share.share, share.commitments) + accepted = true + } + } finally { + if (!accepted) share.share.destroy() } } @@ -107,11 +109,13 @@ class ThresholdKeyGenSessionManager( val wrapper = session(keyId) try { - val result = wrapper.session.computeResult() - try { - persistPendingResult(keyId, wrapper, result) - } finally { - result.share.destroy() + wrapper.protocol.compute { + val result = wrapper.session.computeResult() + try { + persistPendingResult(keyId, wrapper, result) + } finally { + result.share.destroy() + } } } catch (e: Exception) { LOGGER.error("Exception during key generation for $keyId", e) @@ -125,8 +129,10 @@ class ThresholdKeyGenSessionManager( val gen = wrapper.targetGeneration try { - promotePending(keyId, gen, wrapper.policy) - eventScope.call(DKGPromotedEvent(keyId, gen)) + wrapper.protocol.complete { + promotePending(keyId, gen, wrapper.policy) + eventScope.call(DKGPromotedEvent(keyId, gen)) + } } finally { release(keyId) } @@ -138,11 +144,11 @@ class ThresholdKeyGenSessionManager( } fun abortSession(keyId: String) { - sessions.remove(keyId)?.session?.destroy() + sessions.remove(keyId)?.destroy() } fun abort(keyId: String, generation: Int) { - sessions.remove(keyId)?.session?.destroy() + sessions.remove(keyId)?.destroy() db.vault.ops.runInTransaction { tx -> consistency.dropPending(tx, keyId, generation) @@ -341,7 +347,7 @@ class ThresholdKeyGenSessionManager( ) private fun release(keyId: String) { - sessions.remove(keyId)?.session?.destroy() + sessions.remove(keyId)?.destroy() } @PreDestroy diff --git a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/model/keygen/WrappedKeyGenSession.kt b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/model/keygen/WrappedKeyGenSession.kt index f8890872..59939548 100644 --- a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/model/keygen/WrappedKeyGenSession.kt +++ b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/model/keygen/WrappedKeyGenSession.kt @@ -5,13 +5,20 @@ import org.exploit.keeper.model.internal.KeySetAuthorities import org.exploit.keeper.model.keyset.KeySetPolicy import org.exploit.keeper.platform.ecc.algorithm.ECKeyAlgorithmProvider import org.exploit.keeper.platform.ecc.keygen.threshold.session.ECKeyGenSession +import org.exploit.keeper.platform.ecc.protocol.ECDkgProtocolState -data class WrappedKeyGenSession( +internal data class WrappedKeyGenSession( val mode: KeyGenMode, val authorities: KeySetAuthorities, val algorithm: ECKeyAlgorithmProvider<*, *, *, *>, val session: ECKeyGenSession<*>, val targetGeneration: Int, val policy: KeySetPolicy?, - val assetOwner: String? = null -) \ No newline at end of file + val assetOwner: String? = null, + internal val protocol: ECDkgProtocolState = ECDkgProtocolState(), +) { + fun destroy() { + protocol.terminate() + session.destroy() + } +} diff --git a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/protocol/ECCKeeperProtocolState.kt b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/protocol/ECCKeeperProtocolState.kt new file mode 100644 index 00000000..8d7a69e0 --- /dev/null +++ b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/protocol/ECCKeeperProtocolState.kt @@ -0,0 +1,145 @@ +package org.exploit.keeper.platform.ecc.protocol + +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.util.extension.ensure +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +internal class ECDkgProtocolState { + enum class Phase { SHARING, PENDING, TERMINATED } + + private val lock = ReentrantLock() + private var phase = Phase.SHARING + + fun sharing(action: () -> T): T = inPhase(Phase.SHARING, action) + + fun compute(action: () -> T): T = transition(Phase.SHARING, Phase.PENDING, action) + + fun complete(action: () -> T): T = transition(Phase.PENDING, Phase.TERMINATED, action) + + fun terminate() = lock.withLock { phase = Phase.TERMINATED } + + fun phase(): Phase = lock.withLock { phase } + + private fun inPhase(expected: Phase, action: () -> T): T = lock.withLock { + requireProtocolState(phase == expected) { "Expected ECC DKG phase $expected, got $phase" } + action() + } + + private fun transition(expected: Phase, next: Phase, action: () -> T): T = lock.withLock { + requireProtocolState(phase == expected) { "Expected ECC DKG phase $expected, got $phase" } + action().also { phase = next } + } +} + +internal class FrostProtocolState { + enum class Phase { COMMITMENTS, COLLECTING, READY, SIGNING, TERMINATED } + + private val lock = ReentrantLock() + private var phase = Phase.COMMITMENTS + + fun requireCommitmentPhase() = lock.withLock { + requireProtocolState(phase == Phase.COMMITMENTS || phase == Phase.COLLECTING || phase == Phase.READY) { + "FROST commitments are not available in phase $phase" + } + } + + fun beginCollection() = lock.withLock { + requireProtocolState(phase == Phase.COMMITMENTS) { "Expected FROST commitment phase, got $phase" } + phase = Phase.COLLECTING + } + + fun finishCollection(success: Boolean) = lock.withLock { + if (phase == Phase.TERMINATED) return@withLock + requireProtocolState(phase == Phase.COLLECTING) { "FROST collection is not active" } + phase = if (success) Phase.READY else Phase.COMMITMENTS + } + + fun finish(action: () -> T): T = lock.withLock { + requireProtocolState(phase == Phase.READY) { "Expected FROST ready phase, got $phase" } + phase = Phase.SIGNING + try { + action() + } finally { + phase = Phase.TERMINATED + } + } + + fun terminate() = lock.withLock { phase = Phase.TERMINATED } + + fun phase(): Phase = lock.withLock { phase } +} + +internal class GG20ProtocolState { + enum class Phase { SETUP, MTA, OFFLINE, LAMBDA, SIGNING, TERMINATED } + + private val lock = ReentrantLock() + private var phase = Phase.SETUP + private var partialProduced = false + + fun requireSetup() = requirePhase(Phase.SETUP) + + fun storeSetup(action: () -> T): T = inPhase(setOf(Phase.SETUP), action) + + fun completeSetup(action: () -> T): T = transition(setOf(Phase.SETUP), Phase.MTA, action) + + fun requireMta() = requirePhase(Phase.MTA) + + fun mta(action: () -> T): T = inPhase(setOf(Phase.MTA), action) + + fun enterOffline() = advance(setOf(Phase.MTA, Phase.OFFLINE), Phase.OFFLINE) + + fun storeOffline(action: () -> T): T = transition( + setOf(Phase.MTA, Phase.OFFLINE), Phase.OFFLINE, action + ) + + fun enterLambda() = advance(setOf(Phase.OFFLINE, Phase.LAMBDA), Phase.LAMBDA) + + fun storeLambda(action: () -> T): T = transition( + setOf(Phase.OFFLINE, Phase.LAMBDA), Phase.LAMBDA, action + ) + + fun verify(action: () -> T): T = transition(setOf(Phase.LAMBDA), Phase.SIGNING, action) + + fun partial(finalize: Boolean, action: () -> T): T = lock.withLock { + requireProtocolState(phase == Phase.LAMBDA || phase == Phase.SIGNING) { + "GG20 partial signature is not available in phase $phase" + } + requireProtocolState(!partialProduced) { "GG20 partial signature already computed" } + + try { + return action().also { + partialProduced = true + if (!finalize) phase = Phase.SIGNING + } + } finally { + if (finalize) phase = Phase.TERMINATED + } + } + + fun terminate() = lock.withLock { phase = Phase.TERMINATED } + + fun phase(): Phase = lock.withLock { phase } + + private fun requirePhase(expected: Phase) = lock.withLock { + requireProtocolState(phase == expected) { "Expected GG20 phase $expected, got $phase" } + } + + private fun advance(expected: Set, next: Phase) = lock.withLock { + requireProtocolState(phase in expected) { "Expected GG20 phase in $expected, got $phase" } + phase = next + } + + private fun inPhase(expected: Set, action: () -> T): T = lock.withLock { + requireProtocolState(phase in expected) { "Expected GG20 phase in $expected, got $phase" } + action() + } + + private fun transition(expected: Set, next: Phase, action: () -> T): T = lock.withLock { + requireProtocolState(phase in expected) { "Expected GG20 phase in $expected, got $phase" } + action().also { phase = next } + } +} + +private inline fun requireProtocolState(condition: Boolean, message: () -> String) = + ensure(condition, ErrorType.INVALID_REQUEST_BODY, message) diff --git a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/FrostCommitmentHandler.kt b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/FrostCommitmentHandler.kt index fdd0030d..f4ec511d 100644 --- a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/FrostCommitmentHandler.kt +++ b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/FrostCommitmentHandler.kt @@ -24,6 +24,7 @@ class FrostCommitmentHandler

>( fun calculate(sessionId: String): FrostCommitmentDto = lock.withLock { val session = factory.session(sessionId) + session.protocol.requireCommitmentPhase() val commitment = session.client.context().commitment() .getPeerCommitments(sessionId) .firstOrNull { it.idx() == peerId.get() } @@ -42,36 +43,51 @@ class FrostCommitmentHandler

>( fun collect(sessionId: String): Round { val session = factory.session(sessionId) - val peers = clients.forBroadcast(session.client.context().crypto().participants()) + session.protocol.beginCollection() + var success = false - val (commitments, dead) = peers.performAndReturnDead { - it.internalClient(FrostApi::class.java).calculateCommitment(sessionId) - } + try { + val peers = clients.forBroadcast(session.client.context().crypto().participants()) + + val (commitments, dead) = peers.performAndReturnDead { + it.internalClient(FrostApi::class.java).calculateCommitment(sessionId) + } - val imposters = HashSet() + val imposters = HashSet() - for ((idx, response) in commitments) { - val commit = response.body + for ((idx, response) in commitments) { + val commit = response.body - val unwrappedCommitment = ParticipantCommitment(idx, - session.algorithm.params().decodePoint(commit.d), - session.algorithm.params().decodePoint(commit.e), - session.algorithm.params().decodePoint(commit.r), - commit.sigma - ) + val unwrappedCommitment = ParticipantCommitment( + idx, + session.algorithm.params().decodePoint(commit.d), + session.algorithm.params().decodePoint(commit.e), + session.algorithm.params().decodePoint(commit.r), + commit.sigma, + ) - try { - session.client.signature().storeCommitment(idx, session.opId, unwrappedCommitment) - } catch (e: IdentifiableAbortException) { - LOGGER.warn("Imposter found: keeper-${e.id}") - imposters.add(idx) + try { + session.client.signature().storeCommitment(idx, session.opId, unwrappedCommitment) + } catch (e: IdentifiableAbortException) { + LOGGER.warn("Imposter found: keeper-${e.id}") + imposters.add(idx) + } } - } - return Round(null, imposters = imposters, dead = dead, restart = dead.isNotEmpty() || imposters.isNotEmpty()) + val result = Round( + null, + imposters = imposters, + dead = dead, + restart = dead.isNotEmpty() || imposters.isNotEmpty(), + ) + success = !result.restart + return result + } finally { + session.protocol.finishCollection(success) + } } private companion object { val LOGGER: Logger = LoggerFactory.getLogger(FrostCommitmentHandler::class.java) } -} \ No newline at end of file +} diff --git a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/FrostSessionClient.kt b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/FrostSessionClient.kt index e80d2ac6..7a52c375 100644 --- a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/FrostSessionClient.kt +++ b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/FrostSessionClient.kt @@ -3,6 +3,7 @@ package org.exploit.keeper.platform.ecc.signer.frost import org.exploit.ecc.spi.point.PointOps import org.exploit.keeper.model.internal.key.DeserializedKey import org.exploit.keeper.platform.ecc.algorithm.ECKeyAlgorithmProvider +import org.exploit.keeper.platform.ecc.protocol.FrostProtocolState import org.exploit.tss.frost.FrostClient class FrostSessionClient

>( @@ -13,9 +14,12 @@ class FrostSessionClient

>( val keyId: String, val generation: Int, val tweak: String?, - private val keyShare: DeserializedKey + private val keyShare: DeserializedKey, ) : AutoCloseable { + internal val protocol = FrostProtocolState() + override fun close() { + protocol.terminate() try { client.close() } finally { diff --git a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/FrostSignaturePartHandler.kt b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/FrostSignaturePartHandler.kt index 38eace93..25738529 100644 --- a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/FrostSignaturePartHandler.kt +++ b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/FrostSignaturePartHandler.kt @@ -10,7 +10,9 @@ class FrostSignaturePartHandler

>( fun computeZ(sessionId: String): ComputedZ { val session = sessionFactory.session(sessionId) return try { - ComputedZ(peerId(), session.client.signature().computeZ(session.opId)) + session.protocol.finish { + ComputedZ(peerId(), session.client.signature().computeZ(session.opId)) + } } finally { sessionFactory.clear(sessionId) } diff --git a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/initiator/FrostSignatureFinalizer.kt b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/initiator/FrostSignatureFinalizer.kt index 6d8e0c9e..a39525fe 100644 --- a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/initiator/FrostSignatureFinalizer.kt +++ b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/frost/initiator/FrostSignatureFinalizer.kt @@ -14,34 +14,33 @@ class FrostSignatureFinalizer

>( ) { fun completeSignature(clients: List, sessionId: String): Round { val session = factory.session(sessionId) + return session.protocol.finish { + val z = session.client.signature().computeZ(session.opId) + session.client.aggregator().storeZ(session.client.context().crypto().idx(), session.opId, z) - val z = session.client.signature().computeZ(session.opId) - session.client.aggregator().storeZ(session.client.context().crypto().idx(), session.opId, z) - - val (computedZResponse, dead) = clients.performAndReturnDead { - it.internalClient(FrostApi::class.java).computeZ(sessionId) - } + val (computedZResponse, dead) = clients.performAndReturnDead { + it.internalClient(FrostApi::class.java).computeZ(sessionId) + } - if (dead.isNotEmpty()) - return Round(null, dead = dead, restart = true) + if (dead.isNotEmpty()) + return@finish Round(null, dead = dead, restart = true) - val imposters = HashSet() - val computedZ = computedZResponse.mapValues { (_, v) -> v.body } + val imposters = HashSet() + val computedZ = computedZResponse.mapValues { (_, v) -> v.body } - for ((peerId, z) in computedZ) { - try { - session.client.aggregator().storeZ(peerId, session.opId, z.value) - } catch (_: Exception) { - LoggerFactory.getLogger(FrostSignatureFinalizer::class.java).warn("Failed to assign z for $peerId") - imposters.add(peerId) + for ((peerId, computed) in computedZ) { + try { + session.client.aggregator().storeZ(peerId, session.opId, computed.value) + } catch (_: Exception) { + LoggerFactory.getLogger(FrostSignatureFinalizer::class.java).warn("Failed to assign z for $peerId") + imposters.add(peerId) + } } - } - if (imposters.isNotEmpty()) { - return Round(value = null, imposters = imposters, restart = true) - } + if (imposters.isNotEmpty()) + return@finish Round(value = null, imposters = imposters, restart = true) - val signature = session.client.aggregator().computeSignature(session.opId) - return Round(signature) + Round(session.client.aggregator().computeSignature(session.opId)) + } } -} \ No newline at end of file +} diff --git a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20CommitmentHandler.kt b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20CommitmentHandler.kt index f54e4f95..550bb3b2 100644 --- a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20CommitmentHandler.kt +++ b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20CommitmentHandler.kt @@ -15,7 +15,9 @@ class GG20CommitmentHandler

>( private val clients: TKeeperClients ) { fun broadcast(sessionId: String): Round { - val session = sessionFactory.session(sessionId).client + val wrapper = sessionFactory.session(sessionId) + wrapper.protocol.requireSetup() + val session = wrapper.client val commitment = session.context().crypto().ephemeral().commitment val r = session.context().crypto().ephemeral().r_i @@ -37,25 +39,27 @@ class GG20CommitmentHandler

>( } fun complete(sessionId: String) { - sessionFactory.session(sessionId).client - .mta() - .init() + val session = sessionFactory.session(sessionId) + session.protocol.completeSetup { + session.client.mta().init() + } } fun store(sessionId: String, idx: Int, setup: GG20Setup) { val session = sessionFactory.session(sessionId) + session.protocol.storeSetup { + val zkSetup = setup.zk.unwrap() - val zkSetup = setup.zk.unwrap() + val point = setup.gamma.point.let { + session.client.context().crypto().curve() + .decodePoint(it) + } - val point = setup.gamma.point.let { - session.client.context().crypto().curve() - .decodePoint(it) - } + session.client.context().mta().initiator() + .storeZKSetup(idx, zkSetup) - session.client.context().mta().initiator() - .storeZKSetup(idx, zkSetup) - - session.client.context().integrity() - .storeGammaCommitment(idx, GammaCommitment(point, setup.gamma.r)) + session.client.context().integrity() + .storeGammaCommitment(idx, GammaCommitment(point, setup.gamma.r)) + } } -} \ No newline at end of file +} diff --git a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20MtAHandler.kt b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20MtAHandler.kt index b5c42550..176ec3d9 100644 --- a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20MtAHandler.kt +++ b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20MtAHandler.kt @@ -20,8 +20,9 @@ class GG20MtAHandler

>( private val eventScope: EventScope ) { fun exchange(sessionId: String): Round { - val session = sessionFactory.session(sessionId) - .client + val wrapper = sessionFactory.session(sessionId) + wrapper.protocol.requireMta() + val session = wrapper.client val curve = session.context().crypto().curve() val peers = clients.forBroadcast(session.context().crypto().participants()) @@ -56,25 +57,25 @@ class GG20MtAHandler

>( fun compute(idx: Int, request: GG20MtAComputeRequest): GG20MtAResult { try { - val respondentApi = sessionFactory.session(request.sessionId) - .client - .mta() - .asRespondent() + val session = sessionFactory.session(request.sessionId) + return session.protocol.mta { + val respondentApi = session.client.mta().asRespondent() - val initiatorPublicKey = request.initiatorPublicKey.unwrap() - val initiatorMessage = request.message.unwrap() + val initiatorPublicKey = request.initiatorPublicKey.unwrap() + val initiatorMessage = request.message.unwrap() - val gammaResult = respondentApi.compute( - GG20.ComputationType.GAMMA, idx, - initiatorPublicKey, initiatorMessage - ) + val gammaResult = respondentApi.compute( + GG20.ComputationType.GAMMA, idx, + initiatorPublicKey, initiatorMessage + ) - val lagrangeResult = respondentApi.compute( - GG20.ComputationType.LAGRANGE, idx, - initiatorPublicKey, initiatorMessage - ) + val lagrangeResult = respondentApi.compute( + GG20.ComputationType.LAGRANGE, idx, + initiatorPublicKey, initiatorMessage + ) - return GG20MtAResult(peerId, gammaResult.dto(), lagrangeResult.dto()) + GG20MtAResult(peerId, gammaResult.dto(), lagrangeResult.dto()) + } } catch (_: ArithmeticException) { throw IdentifiableAbortException(idx, "Invalid GG20 MtA input") } diff --git a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20OfflinePhaseHandler.kt b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20OfflinePhaseHandler.kt index ac497757..8f27ac78 100644 --- a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20OfflinePhaseHandler.kt +++ b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20OfflinePhaseHandler.kt @@ -23,12 +23,16 @@ class GG20OfflinePhaseHandler

>( private val sessionFactory: GG20SessionManager

) { fun ensureIntegrity(sessionId: String) { - val client = sessionFactory.session(sessionId).client - client.integrity().assertLambdaProductMatchesDelta() + val session = sessionFactory.session(sessionId) + session.protocol.verify { + session.client.integrity().assertLambdaProductMatchesDelta() + } } fun broadcast(sessionId: String): Round { - val session = sessionFactory.session(sessionId).client + val wrapper = sessionFactory.session(sessionId) + wrapper.protocol.enterOffline() + val session = wrapper.client val gamma = session.context().crypto().ephemeral().Gamma_i val delta = session.signature().computeDeltaShare() @@ -51,6 +55,7 @@ class GG20OfflinePhaseHandler

>( fun broadcastLambda(sessionId: String): Round { val session = sessionFactory.session(sessionId) + session.protocol.enterLambda() val client = session.client val gammaProduct = client.context().signature() .computeGammaProduct(client.context().crypto().ephemeral().Gamma_i()) @@ -68,10 +73,11 @@ class GG20OfflinePhaseHandler

>( fun storeLambda(sessionId: String, idx: Int, data: GG20ChaumPedersonCommitmentDto) { val session = sessionFactory.session(sessionId) - val commitment = data.unwrapValue(session.client.context().crypto().curve()) - try { - session.client.integrity().storeLambdaCommitment(idx, commitment) + session.protocol.storeLambda { + val commitment = data.unwrapValue(session.client.context().crypto().curve()) + session.client.integrity().storeLambdaCommitment(idx, commitment) + } } catch (_: Exception) { eventScope.call(SessionCleanEvent(sessionId, GG20SessionManager.NAME)) throw TKeeperException(ErrorType.IMPOSTER_FOUND, imposters = setOf(idx)) @@ -80,18 +86,19 @@ class GG20OfflinePhaseHandler

>( fun store(sessionId: String, idx: Int, data: GG20OfflinePhaseData) { val session = sessionFactory.session(sessionId) - val params = session.client.context().crypto().curve() - - val gammaPoint = data.gamma.let { - params.decodePoint(it) - } - - val sigma = data.sigma.unwrap(params) - try { - session.client.signature().storeGamma(idx, gammaPoint) - session.client.integrity().storeSigmaCommitment(idx, sigma) - session.client.context().signature().storeDeltaShare(idx, data.delta) + session.protocol.storeOffline { + val params = session.client.context().crypto().curve() + + val gammaPoint = data.gamma.let { + params.decodePoint(it) + } + + val sigma = data.sigma.unwrap(params) + session.client.signature().storeGamma(idx, gammaPoint) + session.client.integrity().storeSigmaCommitment(idx, sigma) + session.client.context().signature().storeDeltaShare(idx, data.delta) + } } catch (_: Exception) { eventScope.call(SessionCleanEvent(sessionId, GG20SessionManager.NAME)) throw TKeeperException(ErrorType.IMPOSTER_FOUND, imposters = setOf(idx)) @@ -107,4 +114,4 @@ class GG20OfflinePhaseHandler

>( unwrap(curve), value?.let { curve.decodePoint(it) } ?: throw TKeeperException(ErrorType.INVALID_REQUEST_BODY, "Chaum pederson commitment value is missing") ) -} \ No newline at end of file +} diff --git a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20SessionClient.kt b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20SessionClient.kt index b14c832a..9cdf27eb 100644 --- a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20SessionClient.kt +++ b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20SessionClient.kt @@ -2,15 +2,19 @@ package org.exploit.keeper.platform.ecc.signer.gg20 import org.exploit.ecc.spi.point.WeierstrassPointOps import org.exploit.keeper.model.internal.key.DeserializedKey +import org.exploit.keeper.platform.ecc.protocol.GG20ProtocolState import org.exploit.tss.gg20.GG20Client class GG20SessionClient

>( val client: GG20Client

, val message: ByteArray, val tweak: String?, - private val keyShare: DeserializedKey + private val keyShare: DeserializedKey, ) : AutoCloseable { + internal val protocol = GG20ProtocolState() + override fun close() { + protocol.terminate() try { client.close() } finally { diff --git a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20SignaturePartHandler.kt b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20SignaturePartHandler.kt index 058bce34..d8752668 100644 --- a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20SignaturePartHandler.kt +++ b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/signer/gg20/GG20SignaturePartHandler.kt @@ -9,12 +9,11 @@ class GG20SignaturePartHandler

>( private val factory: GG20SessionManager

) { fun computePartialS(sessionId: String, finalize: Boolean = false): Value { + val session = factory.session(sessionId) return try { - factory.session(sessionId) - .client - .signature() - .computePartialS() - .let { Value(peerId, it) } + session.protocol.partial(finalize) { + session.client.signature().computePartialS().let { Value(peerId, it) } + } } finally { if (finalize) factory.clear(sessionId) diff --git a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/store/CommitStore.kt b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/store/CommitStore.kt index 844feed4..69d4f40e 100644 --- a/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/store/CommitStore.kt +++ b/platform-ecc/src/main/kotlin/org/exploit/keeper/platform/ecc/store/CommitStore.kt @@ -6,6 +6,7 @@ import org.exploit.keeper.platform.ecc.util.CommitmentsSerializer.serialize import org.exploit.keeper.service.db.store.DataStore import org.exploit.keeper.service.db.store.KeyStoreOps import org.rocksdb.Transaction +import java.security.MessageDigest class CommitStore(val ops: KeyStoreOps): DataStore { fun savePending(tx: Transaction, keyId: String, gen: Int, commits: DkgCommitments) { @@ -43,6 +44,18 @@ class CommitStore(val ops: KeyStoreOps): DataStore { ops.delete(tx, DKG_COMMITMENTS.format(keyId, gen)) } + fun saveActiveIfMissing(tx: Transaction, keyId: String, gen: Int, commits: DkgCommitments) { + val expected = serialize(commits) + val existing = readActive(tx, keyId, gen) + if (existing != null) { + require(MessageDigest.isEqual(serialize(existing), expected)) { + "Active commitments differ for $keyId/$gen" + } + return + } + ops.saveSigned(tx, DKG_COMMITMENTS.format(keyId, gen), expected) + } + fun hasPending(keyId: String, gen: Int): Boolean = ops.runInTransaction { tx -> hasPending(tx, keyId, gen) } @@ -55,6 +68,17 @@ class CommitStore(val ops: KeyStoreOps): DataStore { fun hasActive(tx: Transaction, keyId: String, gen: Int): Boolean = ops.hasKey(tx, DKG_COMMITMENTS.format(keyId, gen)) + fun activeGenerations(tx: Transaction, keyId: String): Set = + scanGenerationsByPrefix(tx, DKG_COMMITMENTS_PREFIX.format(keyId)) + + fun pendingGenerations(tx: Transaction, keyId: String): Set = + scanGenerationsByPrefix(tx, DKG_COMMITMENTS_PENDING_PREFIX.format(keyId)) + + fun clearForRecovery(tx: Transaction, keyId: String) { + ops.deleteByPrefix(tx, DKG_COMMITMENTS_PREFIX.format(keyId)) + ops.deleteByPrefix(tx, DKG_COMMITMENTS_PENDING_PREFIX.format(keyId)) + } + fun promotePendingToActive(tx: Transaction, keyId: String, gen: Int): DkgCommitments { val active = readActive(tx, keyId, gen) if (active != null) { @@ -126,9 +150,26 @@ class CommitStore(val ops: KeyStoreOps): DataStore { return maxGen } + private fun scanGenerationsByPrefix(tx: Transaction, prefix: String): Set = buildSet { + ops.newIterator(tx).use { iterator -> + iterator.seek(prefix.toByteArray(Charsets.UTF_8)) + while (iterator.isValid) { + val recordId = iterator.key().toString(Charsets.UTF_8) + if (!recordId.startsWith(prefix)) break + val suffix = recordId.removePrefix(prefix) + val generation = suffix.toIntOrNull() + ?.takeIf { it > 0 && suffix == "%010d".format(it) } + ?: throw IllegalArgumentException("Invalid commitment generation record: $recordId") + add(generation) + iterator.next() + } + } + } + private companion object { const val DKG_COMMITMENTS = "__dkg_commitments__%s__version__%010d" const val DKG_COMMITMENTS_PENDING = "__pending_dkg_commitments__%s__version__%010d" const val DKG_COMMITMENTS_PREFIX = "__dkg_commitments__%s__version__" + const val DKG_COMMITMENTS_PENDING_PREFIX = "__pending_dkg_commitments__%s__version__" } -} \ No newline at end of file +} diff --git a/platform-ecc/src/test/kotlin/org/exploit/keeper/platform/ecc/fuzz/KeeperProtocolStateFuzzTest.kt b/platform-ecc/src/test/kotlin/org/exploit/keeper/platform/ecc/fuzz/KeeperProtocolStateFuzzTest.kt new file mode 100644 index 00000000..b3c3e8a2 --- /dev/null +++ b/platform-ecc/src/test/kotlin/org/exploit/keeper/platform/ecc/fuzz/KeeperProtocolStateFuzzTest.kt @@ -0,0 +1,248 @@ +package org.exploit.keeper.platform.ecc.fuzz + +import com.code_intelligence.jazzer.junit.FuzzTest +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.platform.ecc.protocol.ECDkgProtocolState +import org.exploit.keeper.platform.ecc.protocol.FrostProtocolState +import org.exploit.keeper.platform.ecc.protocol.GG20ProtocolState +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.provider.MethodSource +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.stream.Stream +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class KeeperProtocolStateFuzzTest { + @MethodSource("seeds") + @FuzzTest(maxDuration = "30s") + fun generatedActionsPreserveKeeperProtocolOrder(input: ByteArray) { + if (input.size > MAX_INPUT_SIZE) return + + val actions = if (input.isEmpty()) byteArrayOf(0) else input + fuzzEccDkg(actions) + fuzzFrost(actions) + fuzzGg20(actions) + } + + @Test + fun concurrentOneShotTransitionsHaveExactlyOneWinner() { + val dkg = ECDkgProtocolState() + assertOneWinner { dkg.compute {} } + + val frost = FrostProtocolState().also { + it.beginCollection() + it.finishCollection(true) + } + assertOneWinner { frost.finish {} } + + val gg20 = GG20ProtocolState().also { + it.completeSetup {} + it.enterOffline() + it.enterLambda() + } + assertOneWinner { gg20.partial(finalize = false) {} } + } + + private fun fuzzEccDkg(input: ByteArray) { + val state = ECDkgProtocolState() + var phase = EccDkgPhase.SHARING + + input.forEachIndexed { index, value -> + val fail = unsigned(input.at(index + 1)) and 1 == 1 + when (unsigned(value) % 4) { + 0 -> expect(phase == EccDkgPhase.SHARING, fail) { + state.sharing { probe(fail) } + } + 1 -> { + val allowed = phase == EccDkgPhase.SHARING + expect(allowed, fail) { state.compute { probe(fail) } } + if (allowed && !fail) phase = EccDkgPhase.PENDING + } + 2 -> { + val allowed = phase == EccDkgPhase.PENDING + expect(allowed, fail) { state.complete { probe(fail) } } + if (allowed && !fail) phase = EccDkgPhase.TERMINATED + } + else -> { + state.terminate() + phase = EccDkgPhase.TERMINATED + } + } + assertEquals(phase.name, state.phase().name) + } + } + + private fun fuzzFrost(input: ByteArray) { + val state = FrostProtocolState() + var phase = FrostPhase.COMMITMENTS + + input.forEachIndexed { index, value -> + val success = unsigned(input.at(index + 1)) and 1 == 0 + when (unsigned(value) % 5) { + 0 -> expect( + phase == FrostPhase.COMMITMENTS || phase == FrostPhase.COLLECTING || phase == FrostPhase.READY, + ) { state.requireCommitmentPhase() } + 1 -> { + val allowed = phase == FrostPhase.COMMITMENTS + expect(allowed) { state.beginCollection() } + if (allowed) phase = FrostPhase.COLLECTING + } + 2 -> { + val allowed = phase == FrostPhase.COLLECTING || phase == FrostPhase.TERMINATED + expect(allowed) { state.finishCollection(success) } + if (phase == FrostPhase.COLLECTING) + phase = if (success) FrostPhase.READY else FrostPhase.COMMITMENTS + } + 3 -> { + val allowed = phase == FrostPhase.READY + expect(allowed, !success) { + state.finish { + assertEquals(FrostProtocolState.Phase.SIGNING, state.phase()) + probe(!success) + } + } + if (allowed) phase = FrostPhase.TERMINATED + } + else -> { + state.terminate() + phase = FrostPhase.TERMINATED + } + } + assertEquals(phase.name, state.phase().name) + } + } + + private fun fuzzGg20(input: ByteArray) { + val state = GG20ProtocolState() + var phase = Gg20Phase.SETUP + var partial = false + + input.forEachIndexed { index, value -> + val fail = unsigned(input.at(index + 1)) and 1 == 1 + val finalize = unsigned(input.at(index + 2)) and 1 == 1 + when (unsigned(value) % 12) { + 0 -> expect(phase == Gg20Phase.SETUP) { state.requireSetup() } + 1 -> expect(phase == Gg20Phase.SETUP, fail) { state.storeSetup { probe(fail) } } + 2 -> { + val allowed = phase == Gg20Phase.SETUP + expect(allowed, fail) { state.completeSetup { probe(fail) } } + if (allowed && !fail) phase = Gg20Phase.MTA + } + 3 -> expect(phase == Gg20Phase.MTA) { state.requireMta() } + 4 -> expect(phase == Gg20Phase.MTA, fail) { state.mta { probe(fail) } } + 5 -> { + val allowed = phase == Gg20Phase.MTA || phase == Gg20Phase.OFFLINE + expect(allowed) { state.enterOffline() } + if (allowed) phase = Gg20Phase.OFFLINE + } + 6 -> { + val allowed = phase == Gg20Phase.MTA || phase == Gg20Phase.OFFLINE + expect(allowed, fail) { state.storeOffline { probe(fail) } } + if (allowed && !fail) phase = Gg20Phase.OFFLINE + } + 7 -> { + val allowed = phase == Gg20Phase.OFFLINE || phase == Gg20Phase.LAMBDA + expect(allowed) { state.enterLambda() } + if (allowed) phase = Gg20Phase.LAMBDA + } + 8 -> { + val allowed = phase == Gg20Phase.OFFLINE || phase == Gg20Phase.LAMBDA + expect(allowed, fail) { state.storeLambda { probe(fail) } } + if (allowed && !fail) phase = Gg20Phase.LAMBDA + } + 9 -> { + val allowed = phase == Gg20Phase.LAMBDA + expect(allowed, fail) { state.verify { probe(fail) } } + if (allowed && !fail) phase = Gg20Phase.SIGNING + } + 10 -> { + val allowed = (phase == Gg20Phase.LAMBDA || phase == Gg20Phase.SIGNING) && !partial + expect(allowed, fail) { state.partial(finalize) { probe(fail) } } + if (allowed) { + if (!fail) partial = true + phase = if (finalize) Gg20Phase.TERMINATED else if (!fail) Gg20Phase.SIGNING else phase + } + } + else -> { + state.terminate() + phase = Gg20Phase.TERMINATED + } + } + assertEquals(phase.name, state.phase().name) + } + } + + private fun expect(allowed: Boolean, fails: Boolean = false, action: () -> Unit) { + val result = runCatching(action) + when { + !allowed -> assertProtocolStateFailure(result.exceptionOrNull()) + fails -> assertSame(PROBE, result.exceptionOrNull()) + else -> assertTrue(result.isSuccess) + } + } + + private fun assertOneWinner(action: () -> Unit) { + val outcomes = runConcurrently(CONTENDERS) { runCatching(action) } + assertEquals(1, outcomes.count { it.isSuccess }) + assertEquals(CONTENDERS - 1, outcomes.count { + (it.exceptionOrNull() as? TKeeperException)?.errorType == ErrorType.INVALID_REQUEST_BODY + }) + } + + private fun assertProtocolStateFailure(error: Throwable?) { + assertEquals(ErrorType.INVALID_REQUEST_BODY, assertIs(error).errorType) + } + + private fun runConcurrently(count: Int, action: () -> T): List { + val ready = CountDownLatch(count) + val start = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(count) + return try { + val futures = List(count) { + executor.submit { + ready.countDown() + check(start.await(10, TimeUnit.SECONDS)) + action() + } + } + check(ready.await(10, TimeUnit.SECONDS)) + start.countDown() + futures.map { it.get(10, TimeUnit.SECONDS) } + } finally { + executor.shutdownNow() + } + } + + private fun probe(fail: Boolean) { + if (fail) throw PROBE + } + + private fun unsigned(value: Byte): Int = value.toInt() and 0xff + + private fun ByteArray.at(index: Int): Byte = this[index % size] + + private enum class EccDkgPhase { SHARING, PENDING, TERMINATED } + private enum class FrostPhase { COMMITMENTS, COLLECTING, READY, TERMINATED } + private enum class Gg20Phase { SETUP, MTA, OFFLINE, LAMBDA, SIGNING, TERMINATED } + + companion object { + private const val MAX_INPUT_SIZE = 4_096 + private const val CONTENDERS = 8 + private val PROBE = ProbeFailure() + + @JvmStatic + fun seeds(): Stream = Stream.of( + ByteArray(0), + byteArrayOf(1, 0, 2, 0), + byteArrayOf(1, 0, 2, 0, 3, 0), + ByteArray(128) { it.toByte() }, + ) + } + + private class ProbeFailure : RuntimeException() +} diff --git a/platform-ecc/src/test/kotlin/org/exploit/keeper/platform/ecc/property/ProtocolStateMachineProperties.kt b/platform-ecc/src/test/kotlin/org/exploit/keeper/platform/ecc/property/ProtocolStateMachineProperties.kt index 33fcc625..2572e08f 100644 --- a/platform-ecc/src/test/kotlin/org/exploit/keeper/platform/ecc/property/ProtocolStateMachineProperties.kt +++ b/platform-ecc/src/test/kotlin/org/exploit/keeper/platform/ecc/property/ProtocolStateMachineProperties.kt @@ -11,6 +11,7 @@ import org.exploit.bigint.BigInt import org.exploit.ecc.spi.point.PointOps import org.exploit.keeper.constant.common.ErrorType import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.model.error.ErrorCode import org.exploit.keeper.util.validation.ThresholdParticipants import org.exploit.tss.frost.context.memory.InMemoryCommitmentContext import org.exploit.tss.frost.context.memory.InMemorySignatureAggregatorContext @@ -329,7 +330,7 @@ class ProtocolStateMachineProperties { } } - private enum class ParticipantMutation(val errorType: ErrorType) { + private enum class ParticipantMutation(val errorType: ErrorCode) { OMIT_LOCAL(ErrorType.NOT_PARTICIPANT) { override fun apply(valid: List, local: Int, total: Int, threshold: Int) = valid.filterNot { it == local } diff --git a/platform-pqc/build.gradle b/platform-pqc/build.gradle index 79cace98..2d4c6cc0 100644 --- a/platform-pqc/build.gradle +++ b/platform-pqc/build.gradle @@ -6,7 +6,7 @@ plugins { } group = 'org.exploit' -version = '2.3.2' +version = '2.4.0' repositories { mavenCentral() @@ -26,6 +26,7 @@ dependencies { testImplementation 'com.code-intelligence:jazzer-junit:0.30.0' testImplementation(project(':')) implementation ('org.exploit.anvil:t-mldsa:0.2.1') + implementation 'org.bouncycastle:bcprov-jdk18on:1.84' compileOnly(project(':')) ksp "io.micronaut:micronaut-inject-kotlin:4.10.8" @@ -53,3 +54,18 @@ tasks.register('fuzzMLDSAStateMachine', Test) { environment 'JAZZER_FUZZ', '1' systemProperty 'jazzer.max_duration', providers.gradleProperty('keeper.fuzz.duration').getOrElse('30s') } + +tasks.register('fuzzKeeperMLDSAProtocols', Test) { + group = 'verification' + description = 'Runs coverage-guided action sequences against keeper-owned ML-DSA protocol phases.' + outputs.upToDateWhen { false } + + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform() + filter { + includeTestsMatching 'org.exploit.keeper.platform.pqc.fuzz.KeeperMLDSAProtocolStateFuzzTest' + } + environment 'JAZZER_FUZZ', '1' + systemProperty 'jazzer.max_duration', providers.gradleProperty('keeper.fuzz.duration').getOrElse('30s') +} diff --git a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/consistency/MLDSAPublicKeySyncHandler.kt b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/consistency/MLDSAPublicKeySyncHandler.kt index bb03d4d3..acf1ca37 100644 --- a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/consistency/MLDSAPublicKeySyncHandler.kt +++ b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/consistency/MLDSAPublicKeySyncHandler.kt @@ -7,6 +7,7 @@ import org.exploit.keeper.exception.TKeeperException import org.exploit.keeper.model.dealer.KeyShare import org.exploit.keeper.platform.pqc.algorithm.MLDSAKeyAlgorithmProvider import org.exploit.keeper.platform.pqc.model.dealer.MLDSAKeyShare +import org.exploit.keeper.platform.pqc.model.recovery.MLDSARecoveryCommitmentSet import org.exploit.keeper.platform.pqc.store.MLDSAPublicKeyStore import org.exploit.keeper.service.consistency.KeySyncHandler import org.exploit.keeper.service.db.RocksKeyDB @@ -81,14 +82,21 @@ class MLDSAPublicKeySyncHandler(private val db: RocksKeyDB) : KeySyncHandler { override fun onPendingMissing(tx: Transaction, keyId: String, generation: Int) { publicKeys.deletePending(tx, keyId, generation) + publicKeys.deletePendingRecoveryCommitments(tx, keyId, generation) } override fun onPendingDropped(tx: Transaction, keyId: String, generation: Int) { publicKeys.deletePending(tx, keyId, generation) + publicKeys.deletePendingRecoveryCommitments(tx, keyId, generation) } override fun onActiveRolledBack(tx: Transaction, keyId: String, generation: Int) { publicKeys.deleteActive(tx, keyId, generation) + publicKeys.deleteActiveRecoveryCommitments(tx, keyId, generation) + } + + override fun onRecoveryReset(tx: Transaction, keyId: String) { + publicKeys.clearForRecovery(tx, keyId) } private fun promote(tx: Transaction, keyId: String, generation: Int) { @@ -97,6 +105,7 @@ class MLDSAPublicKeySyncHandler(private val db: RocksKeyDB) : KeySyncHandler { } else { publicKeys.promotePendingToActiveIfPresent(tx, keyId, generation) } + publicKeys.promotePendingRecoveryCommitmentsIfPresent(tx, keyId, generation) } private fun savePendingShare( @@ -137,6 +146,23 @@ class MLDSAPublicKeySyncHandler(private val db: RocksKeyDB) : KeySyncHandler { if (partyKey.params() != mldsa.params || partyKey.partyIndex() != peerId - 1) throw TKeeperException(ErrorType.INVALID_SHARE, "ML-DSA party key does not belong to peer $peerId") + mldsaShare.recoveryCommitments?.let { encoded -> + val commitments = try { + MLDSARecoveryCommitmentSet.decode(encoded) + } catch (e: Exception) { + throw TKeeperException(ErrorType.INVALID_SHARE, "Invalid ML-DSA recovery commitments", e) + } + if (commitments.algorithmId != mldsa.id() || + commitments.threshold != threshold || commitments.total != total + ) throw TKeeperException(ErrorType.INVALID_SHARE, "ML-DSA recovery commitment parameters mismatch") + try { + commitments.requireValidPartyKey(publicKey, partyKey, peerId - 1) + } catch (e: Exception) { + throw TKeeperException(ErrorType.INVALID_SHARE, "ML-DSA share does not match recovery commitments", e) + } + publicKeys.savePendingRecoveryCommitments(tx, keyId, generation, commitments) + } + val encoded = publicKey.encode() try { publicKeys.savePending(tx, keyId, generation, encoded) diff --git a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/dealer/MLDSAKeySplitter.kt b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/dealer/MLDSAKeySplitter.kt index 2ab67e39..863cdaa1 100644 --- a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/dealer/MLDSAKeySplitter.kt +++ b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/dealer/MLDSAKeySplitter.kt @@ -7,6 +7,7 @@ import org.exploit.keeper.model.dealer.DealerSplitResult import org.exploit.keeper.platform.pqc.algorithm.MLDSAKeyAlgorithmProvider import org.exploit.keeper.platform.pqc.model.dealer.MLDSAKeyShare import org.exploit.keeper.platform.pqc.model.dealer.MLDSAKeySplitResult +import org.exploit.keeper.platform.pqc.model.recovery.MLDSARecoveryCommitmentSet import org.exploit.keeper.service.dealer.split.KeySplitter import org.exploit.keeper.service.key.KeyAlgorithmProvider import org.exploit.keeper.service.key.KeyAlgorithms @@ -25,6 +26,13 @@ class MLDSAKeySplitter(private val algorithms: KeyAlgorithms) : KeySplitter { val threshold = ThresholdMLDSAKeySplitter.split(privateKey, n, t) try { val publicKey = threshold.publicKey().encode() + val recoveryCommitments = MLDSARecoveryCommitmentSet.fromPartyKeys( + mldsa.id(), + t, + n, + threshold.publicKey(), + threshold.partyKeys(), + ).encode() try { val shares = LinkedHashMap(n) try { @@ -34,7 +42,8 @@ class MLDSAKeySplitter(private val algorithms: KeyAlgorithms) : KeySplitter { share = threshold.partyKey(peerId - 1).encode(), publicKey = publicKey.copyOf(), threshold = t, - total = n + total = n, + recoveryCommitments = recoveryCommitments.copyOf(), ) } } catch (e: Exception) { @@ -44,6 +53,7 @@ class MLDSAKeySplitter(private val algorithms: KeyAlgorithms) : KeySplitter { return MLDSAKeySplitResult(shares) } finally { publicKey.fill(0) + recoveryCommitments.fill(0) } } finally { threshold.destroy() diff --git a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/jackson/MLDSAKeyShareJacksonAdapter.kt b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/jackson/MLDSAKeyShareJacksonAdapter.kt index 8addeac6..648248fa 100644 --- a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/jackson/MLDSAKeyShareJacksonAdapter.kt +++ b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/jackson/MLDSAKeyShareJacksonAdapter.kt @@ -29,6 +29,7 @@ class MLDSAKeyShareJacksonAdapter(private val algorithms: KeyAlgorithms) : KeySh generator.writeBinaryField("publicKey", key.publicKey) generator.writeNumberField("threshold", key.threshold) generator.writeNumberField("total", key.total) + key.recoveryCommitments?.let { generator.writeBinaryField("recoveryCommitments", it) } generator.writeEndObject() } @@ -37,12 +38,19 @@ class MLDSAKeyShareJacksonAdapter(private val algorithms: KeyAlgorithms) : KeySh if (algorithms.findOrThrow(algorithm) !is MLDSAKeyAlgorithmProvider) throw JsonMappingException.from(ctx, "Not ML-DSA algorithm: $algorithm") + val recoveryCommitments = json.get("recoveryCommitments")?.takeUnless { it.isNull }?.let { + if (!it.isBinary) + throw JsonMappingException.from(ctx, "recoveryCommitments must have binary type") + it.binaryValue() + } + return MLDSAKeyShare( algorithm = algorithm, share = json.requireBinary(ctx, "share"), publicKey = json.requireBinary(ctx, "publicKey"), threshold = json.requireInt(ctx, "threshold"), - total = json.requireInt(ctx, "total") + total = json.requireInt(ctx, "total"), + recoveryCommitments = recoveryCommitments, ) } diff --git a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/keygen/threshold/ThresholdMLDSAKeyGenSessionManager.kt b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/keygen/threshold/ThresholdMLDSAKeyGenSessionManager.kt index 7223f2c5..59ae3893 100644 --- a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/keygen/threshold/ThresholdMLDSAKeyGenSessionManager.kt +++ b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/keygen/threshold/ThresholdMLDSAKeyGenSessionManager.kt @@ -9,6 +9,8 @@ import org.exploit.keeper.model.event.DKGPromotedEvent import org.exploit.keeper.model.internal.keygen.InitKeyGen import org.exploit.keeper.platform.pqc.algorithm.MLDSAKeyAlgorithmProvider import org.exploit.keeper.platform.pqc.model.keygen.* +import org.exploit.keeper.platform.pqc.model.recovery.MLDSARecoveryCommitmentSet +import org.exploit.keeper.platform.pqc.protocol.MLDSADkgProtocolState import org.exploit.keeper.platform.pqc.store.MLDSAPublicKeyStore import org.exploit.keeper.platform.pqc.util.toDto import org.exploit.keeper.platform.pqc.util.toLibrary @@ -81,7 +83,8 @@ class ThresholdMLDSAKeyGenSessionManager( } fun round1(keyId: String): ThresholdMLDSAKeyGenerator.Round1Output = lock.withLock { - session(keyId).generator().round1() + val session = session(keyId) + session.protocol.round1 { session.generator().round1() } } fun storeDirect(keyId: String, senderPeerId: Int, batch: MLDSADirectSeedBatch) = lock.withLock { @@ -90,27 +93,29 @@ class ThresholdMLDSAKeyGenSessionManager( require(batch.seeds.isNotEmpty()) { "Direct seed batch must not be empty" } val session = session(keyId) - val generator = session.generator() - require(generator.round() <= 1) { "Direct seed received after round 1" } - - val sender = senderPeerId - 1 - val recipient = initData.peerId - 1 - batch.seeds.forEach { seed -> - val message = seed.toLibrary(sender, recipient, initData.total) - var stored = false - try { - val subset = message.subset() - - require(subset.contains(recipient)) { "Direct seed subset does not contain recipient" } - require(generator.subsets().contains(subset)) { "Unknown direct seed subset" } - require(generator.leader(subset) == sender) { "Direct seed sender is not subset leader" } - require(sender != recipient) { "Direct seed sender must be remote" } - - val key = DirectMessageKey(sender, subset.indices().toList()) - require(session.directMessages.put(key, message) == null) { "Duplicate direct seed" } - stored = true - } finally { - if (!stored) message.destroy() + session.protocol.direct { + val generator = session.generator() + require(generator.round() <= 1) { "Direct seed received after round 1" } + + val sender = senderPeerId - 1 + val recipient = initData.peerId - 1 + batch.seeds.forEach { seed -> + val message = seed.toLibrary(sender, recipient, initData.total) + var stored = false + try { + val subset = message.subset() + + require(subset.contains(recipient)) { "Direct seed subset does not contain recipient" } + require(generator.subsets().contains(subset)) { "Unknown direct seed subset" } + require(generator.leader(subset) == sender) { "Direct seed sender is not subset leader" } + require(sender != recipient) { "Direct seed sender must be remote" } + + val key = DirectMessageKey(sender, subset.indices().toList()) + require(session.directMessages.put(key, message) == null) { "Duplicate direct seed" } + stored = true + } finally { + if (!stored) message.destroy() + } } } } finally { @@ -124,11 +129,13 @@ class ThresholdMLDSAKeyGenSessionManager( val directMessages = session.directMessages.values.toList() try { - val output = session.generator().round2(broadcasts, directMessages) - try { - return@withLock output.toDto() - } finally { - output.destroy() + return@withLock session.protocol.round2 { + val output = session.generator().round2(broadcasts, directMessages) + try { + output.toDto() + } finally { + output.destroy() + } } } finally { broadcasts.values.forEach { it.destroy() } @@ -140,11 +147,14 @@ class ThresholdMLDSAKeyGenSessionManager( fun round3(keyId: String, batch: MLDSARound2Batch): MLDSARound3Output = lock.withLock { val outputs = batch.toLibrary(initData.total) try { - val output = session(keyId).generator().round3(outputs) - try { - output.toDto() - } finally { - output.destroy() + val session = session(keyId) + session.protocol.round3 { + val output = session.generator().round3(outputs) + try { + output.toDto() + } finally { + output.destroy() + } } } finally { outputs.values.forEach { it.destroy() } @@ -152,13 +162,23 @@ class ThresholdMLDSAKeyGenSessionManager( } fun round4(keyId: String, batch: MLDSARound3Batch): MLDSARound4Output = lock.withLock { + val session = session(keyId) + val recoveryCommitments = MLDSARecoveryCommitmentSet.fromRound3( + session.algorithm.id(), + initData.threshold, + initData.total, + batch, + ) val outputs = batch.toLibrary(initData.total) try { - val output = session(keyId).generator().round4(outputs) - try { - output.toDto(byteArrayOf()) - } finally { - output.destroy() + return@withLock session.protocol.round4 { + val output = session.generator().round4(outputs) + try { + session.recoveryCommitments = recoveryCommitments + output.toDto(byteArrayOf()) + } finally { + output.destroy() + } } } finally { outputs.values.forEach { it.destroy() } @@ -170,23 +190,27 @@ class ThresholdMLDSAKeyGenSessionManager( require(output.partyIndex == peerId - 1) { "Round 4 sender does not match authenticated peer" } val session = session(keyId) - require(session.round4Outputs.putIfAbsent(output.partyIndex, output) == null) { - "Duplicate round 4 output from peer $peerId" + session.protocol.storeRound4 { + require(session.round4Outputs.putIfAbsent(output.partyIndex, output) == null) { + "Duplicate round 4 output from peer $peerId" + } } } fun aggregate(keyId: String) = lock.withLock { val session = session(keyId) - check(session.result == null) { "DKG result already computed" } + session.protocol.aggregate { + check(session.result == null) { "DKG result already computed" } - val generator = session.generator() - val publicShareSize = generator.params().k() * WPacking.polyQSize() - val batch = MLDSARound4Batch(session.round4Outputs.values.toList()) - val outputs = batch.toLibrary(initData.total, publicShareSize) - try { - session.result = generator.aggregate(outputs) - } finally { - outputs.values.forEach { it.destroy() } + val generator = session.generator() + val publicShareSize = generator.params().k() * WPacking.polyQSize() + val batch = MLDSARound4Batch(session.round4Outputs.values.toList()) + val outputs = batch.toLibrary(initData.total, publicShareSize) + try { + session.result = generator.aggregate(outputs) + } finally { + outputs.values.forEach { it.destroy() } + } } } @@ -194,10 +218,12 @@ class ThresholdMLDSAKeyGenSessionManager( val session = session(keyId) try { - if (session.init.mode == KeyGenMode.REFRESH) { - persistPendingRefresh(session) - } else { - persistPendingResult(session) + session.protocol.compute { + if (session.init.mode == KeyGenMode.REFRESH) { + persistPendingRefresh(session) + } else { + persistPendingResult(session) + } } } catch (e: Exception) { LOGGER.error("Exception while persisting ML-DSA DKG result for $keyId", e) @@ -211,11 +237,18 @@ class ThresholdMLDSAKeyGenSessionManager( val generation = session.init.targetGeneration try { - db.vault.ops.runInTransaction { tx -> - publicKeys.promotePendingToActive(tx, keyId, generation) - lifecycle.promotePendingToActive(tx, keyId, generation, session.init.policy) + session.protocol.complete { + db.vault.ops.runInTransaction { tx -> + publicKeys.promotePendingToActive(tx, keyId, generation) + if (session.recoveryCommitments == null) { + publicKeys.promotePendingRecoveryCommitmentsIfPresent(tx, keyId, generation) + } else { + publicKeys.promotePendingRecoveryCommitments(tx, keyId, generation) + } + lifecycle.promotePendingToActive(tx, keyId, generation, session.init.policy) + } + eventScope.call(DKGPromotedEvent(keyId, generation)) } - eventScope.call(DKGPromotedEvent(keyId, generation)) } finally { release(keyId) } @@ -248,11 +281,14 @@ class ThresholdMLDSAKeyGenSessionManager( val partyKey = result.partyKey().encode() val serialized = KeySerializer.serialize(session.algorithm.id(), partyKey) val publicKey = result.publicKey().encode() + val recoveryCommitments = session.recoveryCommitments + ?: throw IllegalStateException("Missing validated ML-DSA recovery commitments") try { db.vault.ops.runInTransaction { tx -> db.vault.savePending(tx, keyId, generation, serialized) publicKeys.savePending(tx, keyId, generation, publicKey) + publicKeys.savePendingRecoveryCommitments(tx, keyId, generation, recoveryCommitments) lifecycle.savePendingMeta( tx, KeySetActivationService.PendingMetaDraft( @@ -303,6 +339,10 @@ class ThresholdMLDSAKeyGenSessionManager( db.vault.savePending(tx, keyId, generation, raw) publicKeys.savePending(tx, keyId, generation, publicKey) + publicKeys.readActiveRecoveryCommitments(tx, keyId, sourceGeneration)?.let { + session.recoveryCommitments = it + publicKeys.savePendingRecoveryCommitments(tx, keyId, generation, it) + } lifecycle.savePendingMeta( tx, KeySetActivationService.PendingMetaDraft( @@ -399,14 +439,17 @@ class ThresholdMLDSAKeyGenSessionManager( val algorithm: MLDSAKeyAlgorithmProvider, private val generator: ThresholdMLDSAKeyGenerator? ) { + val protocol = MLDSADkgProtocolState(init.mode == KeyGenMode.REFRESH) val directMessages = LinkedHashMap() val round4Outputs = LinkedHashMap() + var recoveryCommitments: MLDSARecoveryCommitmentSet? = null var result: DkgResult? = null fun generator(): ThresholdMLDSAKeyGenerator = generator ?: throw TKeeperException(ErrorType.INVALID_KEY_GENERATION, "ML-DSA refresh has no DKG rounds") fun destroy() { + protocol.terminate() directMessages.values.forEach { it.destroy() } directMessages.clear() round4Outputs.clear() diff --git a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/model/dealer/MLDSAKeyShare.kt b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/model/dealer/MLDSAKeyShare.kt index 7020ccb2..f9476190 100644 --- a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/model/dealer/MLDSAKeyShare.kt +++ b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/model/dealer/MLDSAKeyShare.kt @@ -7,7 +7,8 @@ class MLDSAKeyShare( val share: ByteArray, val publicKey: ByteArray, val threshold: Int, - val total: Int + val total: Int, + val recoveryCommitments: ByteArray? = null, ) : KeyShare { override fun raw(): ByteArray = share @@ -16,5 +17,6 @@ class MLDSAKeyShare( override fun close() { share.fill(0) publicKey.fill(0) + recoveryCommitments?.fill(0) } } diff --git a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/model/recovery/MLDSARecoveryCommitmentSet.kt b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/model/recovery/MLDSARecoveryCommitmentSet.kt new file mode 100644 index 00000000..e0ac0a3c --- /dev/null +++ b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/model/recovery/MLDSARecoveryCommitmentSet.kt @@ -0,0 +1,292 @@ +package org.exploit.keeper.platform.pqc.model.recovery + +import org.bouncycastle.crypto.digests.SHAKEDigest +import org.exploit.keeper.platform.pqc.model.keygen.MLDSARound3Batch +import org.exploit.mldsa.constant.MLDSAConstant +import org.exploit.mldsa.encoder.MLDSAEncoding +import org.exploit.mldsa.model.Polynomial +import org.exploit.mldsa.model.key.threshold.ThresholdPartyKey +import org.exploit.mldsa.model.key.threshold.ThresholdPublicKey +import org.exploit.mldsa.ntt.NttArithmetic +import org.exploit.mldsa.ntt.NttTransform +import org.exploit.mldsa.rss.RssSecretSharing +import org.exploit.mldsa.sampler.MLDSASampling +import org.exploit.mldsa.util.Destroyer +import org.exploit.mldsa.util.WPacking +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +class MLDSARecoveryCommitmentSet private constructor( + val algorithmId: Byte, + val threshold: Int, + val total: Int, + entries: Map, ByteArray>, +) { + private val entries = validateAndCopy(threshold, total, entries) + + fun commitment(subset: List): ByteArray? = entries[subset]?.copyOf() + + fun requireValidPartyKey( + publicKey: ThresholdPublicKey, + partyKey: ThresholdPartyKey, + targetPartyIndex: Int, + sourcePartyIndex: Int? = null, + ) { + require(publicKey.params() == partyKey.params()) { "ML-DSA party/public parameter mismatch" } + require(partyKey.partyIndex() == targetPartyIndex) { "ML-DSA party key belongs to another target" } + require(targetPartyIndex in 0 until total && sourcePartyIndex?.let { it in 0 until total } != false) + + val expected = RssSecretSharing.allSubsets(total, total - threshold + 1) + .map { it.indices().toList() } + .filter { targetPartyIndex in it && (sourcePartyIndex == null || sourcePartyIndex in it) } + .toSet() + val shares = partyKey.ski() + try { + val actual = shares.keys.map { it.indices().toList() }.toSet() + require(actual == expected) { "Incomplete ML-DSA recovery party key" } + shares.forEach { (subset, secret) -> + val indices = subset.indices().toList() + val expectedCommitment = entries[indices] + ?: throw IllegalArgumentException("Missing ML-DSA recovery commitment") + val actualCommitment = commitSecretShare(publicKey, indices, secret) + require(MessageDigest.isEqual(expectedCommitment, actualCommitment)) { + "ML-DSA recovery commitment mismatch" + } + } + } finally { + Destroyer.destroyPolyMap(shares) + } + } + + fun encode(): ByteArray = ByteArrayOutputStream().use { bytes -> + DataOutputStream(bytes).use { output -> + output.writeInt(MAGIC) + output.writeByte(VERSION) + output.writeByte(algorithmId.toInt()) + output.writeByte(threshold) + output.writeByte(total) + output.writeByte(entries.size) + entries.forEach { (subset, commitment) -> + output.writeByte(subset.size) + subset.forEach(output::writeByte) + output.write(commitment) + } + } + bytes.toByteArray() + } + + companion object { + private const val MAGIC = 0x544D5243 + private const val VERSION = 1 + private const val COMMITMENT_SIZE = 64 + private const val DOMAIN = "T-MLDSA-DKG-PUBLIC-COMMIT" + + fun fromRound3( + algorithmId: Byte, + threshold: Int, + total: Int, + batch: MLDSARound3Batch, + ): MLDSARecoveryCommitmentSet { + require(batch.outputs.map { it.partyIndex }.sorted() == (0 until total).toList()) { + "Expected one Round 3 output from every party" + } + + val entries = LinkedHashMap, ByteArray>() + batch.outputs.sortedBy { it.partyIndex }.forEach { output -> + output.commitments.forEach { item -> + require(item.subset.firstOrNull() == output.partyIndex) { + "Round 3 commitment is not owned by its subset leader" + } + require(entries.put(item.subset, item.value) == null) { "Duplicate recovery commitment subset" } + } + } + return MLDSARecoveryCommitmentSet(algorithmId, threshold, total, entries) + } + + fun fromPartyKeys( + algorithmId: Byte, + threshold: Int, + total: Int, + publicKey: ThresholdPublicKey, + partyKeys: List, + ): MLDSARecoveryCommitmentSet { + require(partyKeys.map { it.partyIndex() }.sorted() == (0 until total).toList()) { + "Expected one ML-DSA party key for every party" + } + require(partyKeys.all { it.params() == publicKey.params() }) { "ML-DSA party/public parameter mismatch" } + require(partyKeys.map { it.coefficientBound() }.distinct().size == 1) { + "ML-DSA party coefficient bounds disagree" + } + + val unique = LinkedHashMap, Array>() + try { + partyKeys.sortedBy { it.partyIndex() }.forEach { partyKey -> + val shares = partyKey.ski() + try { + val expected = RssSecretSharing.allSubsets(total, total - threshold + 1) + .map { it.indices().toList() } + .filter { partyKey.partyIndex() in it } + .toSet() + require(shares.keys.map { it.indices().toList() }.toSet() == expected) { + "Incomplete ML-DSA party key" + } + shares.forEach { (subset, value) -> + val indices = subset.indices().toList() + subset.destroy() + val existing = unique[indices] + if (existing == null) { + unique[indices] = value + } else { + try { + require(samePolynomials(existing, value)) { + "Conflicting replicated ML-DSA share" + } + } finally { + value.forEach(Polynomial::destroy) + } + } + } + } catch (t: Throwable) { + Destroyer.destroyPolyMap(shares) + throw t + } + } + + val entries = unique.mapValuesTo(LinkedHashMap()) { (subset, secret) -> + commitSecretShare(publicKey, subset, secret) + } + return MLDSARecoveryCommitmentSet(algorithmId, threshold, total, entries) + } finally { + unique.values.forEach { polynomials -> polynomials.forEach(Polynomial::destroy) } + } + } + + fun decode(encoded: ByteArray): MLDSARecoveryCommitmentSet { + val result = try { + DataInputStream(ByteArrayInputStream(encoded)).use { input -> + require(input.readInt() == MAGIC) { "Invalid ML-DSA recovery commitment magic" } + require(input.readUnsignedByte() == VERSION) { "Unsupported ML-DSA recovery commitment version" } + + val algorithmId = input.readByte() + val threshold = input.readUnsignedByte() + val total = input.readUnsignedByte() + val count = input.readUnsignedByte() + + val entries = LinkedHashMap, ByteArray>(count) + repeat(count) { + val subset = List(input.readUnsignedByte()) { input.readUnsignedByte() } + val commitment = ByteArray(COMMITMENT_SIZE) + input.readFully(commitment) + require(entries.put(subset, commitment) == null) { "Duplicate recovery commitment subset" } + } + require(input.read() == -1) { "Trailing ML-DSA recovery commitment data" } + MLDSARecoveryCommitmentSet(algorithmId, threshold, total, entries) + } + } catch (e: IOException) { + throw IllegalArgumentException("Malformed ML-DSA recovery commitments", e) + } + require(MessageDigest.isEqual(encoded, result.encode())) { "Non-canonical ML-DSA recovery commitments" } + return result + } + + fun commitPackedPublicShare(subset: List, packedPublicShare: ByteArray): ByteArray { + require(subset.isNotEmpty() && subset.zipWithNext().all { (left, right) -> left < right }) { + "Subset must be canonical" + } + require(subset.all { it in 0 until 6 }) { "Subset party index must be in [0,6)" } + require(packedPublicShare.isNotEmpty()) { "Packed public share must not be empty" } + + val digest = SHAKEDigest(256) + val domain = DOMAIN.toByteArray(StandardCharsets.US_ASCII) + digest.update(domain, 0, domain.size) + digest.update(subset.size.toByte()) + subset.forEach { digest.update(it.toByte()) } + val length = MLDSAEncoding.integerToBytes(packedPublicShare.size, Int.SIZE_BYTES) + digest.update(length, 0, length.size) + digest.update(packedPublicShare, 0, packedPublicShare.size) + + return ByteArray(COMMITMENT_SIZE).also { digest.doOutput(it, 0, it.size) } + } + + private fun commitSecretShare( + publicKey: ThresholdPublicKey, + subset: List, + secret: Array, + ): ByteArray { + val params = publicKey.params() + require(secret.size == params.l() + params.k()) { "Invalid ML-DSA recovery secret dimension" } + val rho = publicKey.rho() + val matrix = MLDSASampling.expandA(rho, params.k(), params.l()) + val s1Hat = Array(params.l()) { index -> NttTransform.forward(secret[index]) } + val publicShare = Array(params.k()) { row -> + var sum = Polynomial.zero() + try { + for (column in 0 until params.l()) { + val product = NttArithmetic.multiply(matrix[row][column], s1Hat[column]) + val next = try { + NttArithmetic.add(sum, product) + } finally { + product.destroy() + } + sum.destroy() + sum = next + } + val normal = NttTransform.inverse(sum) + try { + Polynomial.addPoly(normal, secret[params.l() + row]) + } finally { + normal.destroy() + } + } finally { + sum.destroy() + } + } + return try { + val packed = WPacking.packVecK(arrayOf(publicShare), params.k()) + try { + commitPackedPublicShare(subset, packed) + } finally { + packed.fill(0) + } + } finally { + rho.fill(0) + matrix.flatten().forEach(Polynomial::destroy) + s1Hat.forEach(Polynomial::destroy) + publicShare.forEach(Polynomial::destroy) + } + } + + private fun samePolynomials(left: Array, right: Array): Boolean = + left.size == right.size && left.indices.all { polynomial -> + (0 until MLDSAConstant.N).all { coefficient -> + left[polynomial].get(coefficient) == right[polynomial].get(coefficient) + } + } + + private fun validateAndCopy( + threshold: Int, + total: Int, + entries: Map, ByteArray>, + ): LinkedHashMap, ByteArray> { + require(total in 2..6 && threshold in 2..total) { "Invalid ML-DSA quorum parameters" } + val expected = RssSecretSharing.allSubsets(total, total - threshold + 1) + .map { it.indices().toList() } + require(entries.keys == expected.toSet()) { "Incomplete ML-DSA recovery commitment set" } + + return LinkedHashMap, ByteArray>(expected.size).apply { + expected.forEach { subset -> + val commitment = entries.getValue(subset) + require(commitment.size == COMMITMENT_SIZE) { + "ML-DSA recovery commitment must be $COMMITMENT_SIZE bytes" + } + put(subset, commitment.copyOf()) + } + } + } + } +} diff --git a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/protocol/PQCKeeperProtocolState.kt b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/protocol/PQCKeeperProtocolState.kt new file mode 100644 index 00000000..c0179049 --- /dev/null +++ b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/protocol/PQCKeeperProtocolState.kt @@ -0,0 +1,77 @@ +package org.exploit.keeper.platform.pqc.protocol + +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.util.extension.ensure +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +internal class MLDSADkgProtocolState(private val refresh: Boolean) { + enum class Phase { INITIALIZED, ROUND1, ROUND2, ROUND3, ROUND4, AGGREGATED, PENDING, TERMINATED } + + private val lock = ReentrantLock() + private var phase = Phase.INITIALIZED + + fun direct(action: () -> T): T = inPhase(setOf(Phase.INITIALIZED, Phase.ROUND1), action) + + fun round1(action: () -> T): T = transition(Phase.INITIALIZED, Phase.ROUND1, action) + + fun round2(action: () -> T): T = transition(Phase.ROUND1, Phase.ROUND2, action) + + fun round3(action: () -> T): T = transition(Phase.ROUND2, Phase.ROUND3, action) + + fun round4(action: () -> T): T = transition(Phase.ROUND3, Phase.ROUND4, action) + + fun storeRound4(action: () -> T): T = inPhase( + setOf(Phase.INITIALIZED, Phase.ROUND1, Phase.ROUND2, Phase.ROUND3, Phase.ROUND4), + action, + ) + + fun aggregate(action: () -> T): T = transition(Phase.ROUND4, Phase.AGGREGATED, action) + + fun compute(action: () -> T): T = transition( + if (refresh) Phase.INITIALIZED else Phase.AGGREGATED, + Phase.PENDING, + action, + ) + + fun complete(action: () -> T): T = transition(Phase.PENDING, Phase.TERMINATED, action) + + fun terminate() = lock.withLock { phase = Phase.TERMINATED } + + fun phase(): Phase = lock.withLock { phase } + + private fun inPhase(expected: Set, action: () -> T): T = lock.withLock { + requireProtocolState(phase in expected) { "Expected ML-DSA DKG phase in $expected, got $phase" } + action() + } + + private fun transition(expected: Phase, next: Phase, action: () -> T): T = lock.withLock { + requireProtocolState(phase == expected) { "Expected ML-DSA DKG phase $expected, got $phase" } + action().also { phase = next } + } +} + +internal class MLDSASigningProtocolState { + enum class Phase { INITIALIZED, ROUND1, ROUND2, ROUND3, TERMINATED } + + private val lock = ReentrantLock() + private var phase = Phase.INITIALIZED + + fun round1(action: () -> T): T = transition(Phase.INITIALIZED, Phase.ROUND1, action) + + fun round2(action: () -> T): T = transition(Phase.ROUND1, Phase.ROUND2, action) + + fun round3(action: () -> T): T = transition(Phase.ROUND2, Phase.ROUND3, action) + + fun terminate() = lock.withLock { phase = Phase.TERMINATED } + + fun phase(): Phase = lock.withLock { phase } + + private fun transition(expected: Phase, next: Phase, action: () -> T): T = lock.withLock { + requireProtocolState(phase == expected) { "Expected ML-DSA signing phase $expected, got $phase" } + action().also { phase = next } + } +} + +private inline fun requireProtocolState(condition: Boolean, message: () -> String) = + ensure(condition, ErrorType.INVALID_REQUEST_BODY, message) diff --git a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/signer/threshold/ThresholdMLDSASessionManager.kt b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/signer/threshold/ThresholdMLDSASessionManager.kt index ebe81721..e9e5c490 100644 --- a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/signer/threshold/ThresholdMLDSASessionManager.kt +++ b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/signer/threshold/ThresholdMLDSASessionManager.kt @@ -17,6 +17,7 @@ import org.exploit.keeper.model.material.SigningMaterial import org.exploit.keeper.platform.pqc.algorithm.MLDSAKeyAlgorithmProvider import org.exploit.keeper.platform.pqc.api.MLDSASigningApi import org.exploit.keeper.platform.pqc.model.signature.* +import org.exploit.keeper.platform.pqc.protocol.MLDSASigningProtocolState import org.exploit.keeper.platform.pqc.store.MLDSAPublicKeyStore import org.exploit.keeper.platform.pqc.util.toDto import org.exploit.keeper.platform.pqc.util.toLibrary @@ -94,23 +95,29 @@ abstract class ThresholdMLDSASessionManager( fun round1(sessionId: String): MLDSASignRound1 { val session = session(sessionId) - return MLDSASignRound1(initData.peerId - 1, session.client.round1().cmt()) + return session.protocol.round1 { + MLDSASignRound1(initData.peerId - 1, session.client.round1().cmt()) + } } fun round2(sessionId: String, batch: MLDSASignRound1Batch): MLDSASignRound2 { val session = session(sessionId) val commitments = batch.toLibrary(session.params) - return MLDSASignRound2(initData.peerId - 1, session.client.round2(commitments).wPacked()) + return session.protocol.round2 { + MLDSASignRound2(initData.peerId - 1, session.client.round2(commitments).wPacked()) + } } fun round3(sessionId: String, batch: MLDSASignRound2Batch): MLDSASignRound3 { val session = session(sessionId) val reveals = batch.toLibrary(session.params) - val result = session.client.round3(reveals) - try { - return result.toDto(initData.peerId - 1, session.params) - } finally { - result.destroy() + return session.protocol.round3 { + val result = session.client.round3(reveals) + try { + result.toDto(initData.peerId - 1, session.params) + } finally { + result.destroy() + } } } @@ -379,7 +386,10 @@ abstract class ThresholdMLDSASessionManager( val publicKey: ThresholdPublicKey, val params: ThresholdMLDSAParameters ) { + val protocol = MLDSASigningProtocolState() + fun destroy() { + protocol.terminate() client.destroy() coordinator.destroy() partyKey.destroy() diff --git a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/store/MLDSAPublicKeyStore.kt b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/store/MLDSAPublicKeyStore.kt index f541a28d..4e090391 100644 --- a/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/store/MLDSAPublicKeyStore.kt +++ b/platform-pqc/src/main/kotlin/org/exploit/keeper/platform/pqc/store/MLDSAPublicKeyStore.kt @@ -1,8 +1,13 @@ package org.exploit.keeper.platform.pqc.store +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.platform.pqc.model.recovery.MLDSARecoveryCommitmentSet import org.exploit.keeper.service.db.store.DataStore import org.exploit.keeper.service.db.store.KeyStoreOps import org.rocksdb.Transaction +import java.nio.charset.StandardCharsets +import java.security.MessageDigest class MLDSAPublicKeyStore(val ops: KeyStoreOps) : DataStore { fun savePending(tx: Transaction, keyId: String, generation: Int, publicKey: ByteArray) { @@ -15,6 +20,21 @@ class MLDSAPublicKeyStore(val ops: KeyStoreOps) : DataStore { fun readActive(tx: Transaction, keyId: String, generation: Int): ByteArray? = ops.getSigned(tx, activeId(keyId, generation)) + fun saveActiveIfMissing(tx: Transaction, keyId: String, generation: Int, publicKey: ByteArray) { + val existing = readActive(tx, keyId, generation) + if (existing != null) { + try { + require(MessageDigest.isEqual(existing, publicKey)) { + "Active ML-DSA public key differs for $keyId/$generation" + } + } finally { + existing.fill(0) + } + return + } + ops.saveSigned(tx, activeId(keyId, generation), publicKey) + } + fun deletePending(tx: Transaction, keyId: String, generation: Int) { ops.delete(tx, pendingId(keyId, generation)) } @@ -49,14 +69,135 @@ class MLDSAPublicKeyStore(val ops: KeyStoreOps) : DataStore { return pending } + fun savePendingRecoveryCommitments( + tx: Transaction, + keyId: String, + generation: Int, + commitments: MLDSARecoveryCommitmentSet, + ) { + ops.saveSigned(tx, pendingRecoveryId(keyId, generation), commitments.encode()) + } + + fun readActiveRecoveryCommitments( + tx: Transaction, + keyId: String, + generation: Int, + ): MLDSARecoveryCommitmentSet? = + ops.getSigned(tx, activeRecoveryId(keyId, generation))?.let(MLDSARecoveryCommitmentSet::decode) + + fun saveActiveRecoveryCommitmentsIfMissing( + tx: Transaction, + keyId: String, + generation: Int, + commitments: MLDSARecoveryCommitmentSet, + ) { + val expected = commitments.encode() + try { + val existing = readActiveRecoveryCommitments(tx, keyId, generation) + if (existing != null) { + require(MessageDigest.isEqual(existing.encode(), expected)) { + "Active ML-DSA recovery commitments differ for $keyId/$generation" + } + return + } + ops.saveSigned(tx, activeRecoveryId(keyId, generation), expected) + } finally { + expected.fill(0) + } + } + + fun activePublicGenerations(tx: Transaction, keyId: String): Set = + scanGenerations(tx, PUBLIC_KEY_PREFIX.format(keyId)) + + fun pendingPublicGenerations(tx: Transaction, keyId: String): Set = + scanGenerations(tx, PENDING_PUBLIC_KEY_PREFIX.format(keyId)) + + fun activeRecoveryGenerations(tx: Transaction, keyId: String): Set = + scanGenerations(tx, RECOVERY_COMMITMENTS_PREFIX.format(keyId)) + + fun pendingRecoveryGenerations(tx: Transaction, keyId: String): Set = + scanGenerations(tx, PENDING_RECOVERY_COMMITMENTS_PREFIX.format(keyId)) + + fun clearForRecovery(tx: Transaction, keyId: String) { + ops.deleteByPrefix(tx, PUBLIC_KEY_PREFIX.format(keyId)) + ops.deleteByPrefix(tx, PENDING_PUBLIC_KEY_PREFIX.format(keyId)) + ops.deleteByPrefix(tx, RECOVERY_COMMITMENTS_PREFIX.format(keyId)) + ops.deleteByPrefix(tx, PENDING_RECOVERY_COMMITMENTS_PREFIX.format(keyId)) + } + + fun deletePendingRecoveryCommitments(tx: Transaction, keyId: String, generation: Int) { + ops.delete(tx, pendingRecoveryId(keyId, generation)) + } + + fun deleteActiveRecoveryCommitments(tx: Transaction, keyId: String, generation: Int) { + ops.delete(tx, activeRecoveryId(keyId, generation)) + } + + fun promotePendingRecoveryCommitmentsIfPresent( + tx: Transaction, + keyId: String, + generation: Int, + ): MLDSARecoveryCommitmentSet? { + val active = readActiveRecoveryCommitments(tx, keyId, generation) + if (active != null) { + deletePendingRecoveryCommitments(tx, keyId, generation) + return active + } + + val pendingId = pendingRecoveryId(keyId, generation) + val raw = ops.getSigned(tx, pendingId) ?: return null + ops.saveSigned(tx, activeRecoveryId(keyId, generation), raw) + ops.delete(tx, pendingId) + return MLDSARecoveryCommitmentSet.decode(raw) + } + + fun promotePendingRecoveryCommitments( + tx: Transaction, + keyId: String, + generation: Int, + ): MLDSARecoveryCommitmentSet = + promotePendingRecoveryCommitmentsIfPresent(tx, keyId, generation) + ?: throw IllegalStateException("Pending ML-DSA recovery commitments not found for $keyId/$generation") + private fun pendingId(keyId: String, generation: Int) = PENDING_PUBLIC_KEY.format(keyId, generation) private fun activeId(keyId: String, generation: Int) = PUBLIC_KEY.format(keyId, generation) + private fun pendingRecoveryId(keyId: String, generation: Int) = + PENDING_RECOVERY_COMMITMENTS.format(keyId, generation) + + private fun activeRecoveryId(keyId: String, generation: Int) = + RECOVERY_COMMITMENTS.format(keyId, generation) + + private fun scanGenerations(tx: Transaction, prefix: String): Set = buildSet { + ops.newIterator(tx).use { iterator -> + iterator.seek(prefix.toByteArray(StandardCharsets.UTF_8)) + while (iterator.isValid) { + val recordId = iterator.key().toString(StandardCharsets.UTF_8) + if (!recordId.startsWith(prefix)) break + val suffix = recordId.removePrefix(prefix) + val generation = suffix.toIntOrNull() + ?.takeIf { it > 0 && suffix == "%010d".format(it) } + ?: throw TKeeperException( + ErrorType.TAMPERED_KEEPER, + "Invalid ML-DSA generation record: $recordId", + ) + add(generation) + iterator.next() + } + } + } + private companion object { const val PUBLIC_KEY = "__mldsa_public_key__%s__version__%010d" const val PENDING_PUBLIC_KEY = "__pending_mldsa_public_key__%s__version__%010d" + const val RECOVERY_COMMITMENTS = "__mldsa_recovery_commitments__%s__version__%010d" + const val PENDING_RECOVERY_COMMITMENTS = "__pending_mldsa_recovery_commitments__%s__version__%010d" + const val PUBLIC_KEY_PREFIX = "__mldsa_public_key__%s__version__" + const val PENDING_PUBLIC_KEY_PREFIX = "__pending_mldsa_public_key__%s__version__" + const val RECOVERY_COMMITMENTS_PREFIX = "__mldsa_recovery_commitments__%s__version__" + const val PENDING_RECOVERY_COMMITMENTS_PREFIX = "__pending_mldsa_recovery_commitments__%s__version__" } } diff --git a/platform-pqc/src/test/kotlin/org/exploit/keeper/platform/pqc/dealer/MLDSADealerRecoveryCommitmentsTest.kt b/platform-pqc/src/test/kotlin/org/exploit/keeper/platform/pqc/dealer/MLDSADealerRecoveryCommitmentsTest.kt new file mode 100644 index 00000000..7743c12b --- /dev/null +++ b/platform-pqc/src/test/kotlin/org/exploit/keeper/platform/pqc/dealer/MLDSADealerRecoveryCommitmentsTest.kt @@ -0,0 +1,100 @@ +package org.exploit.keeper.platform.pqc.dealer + +import org.exploit.keeper.platform.pqc.algorithm.MLDSA44KeyAlgorithmProvider +import org.exploit.keeper.factory.JacksonFactory +import org.exploit.keeper.model.dealer.KeyShare +import org.exploit.keeper.platform.pqc.jackson.MLDSAKeyShareJacksonAdapter +import org.exploit.keeper.platform.pqc.model.dealer.MLDSAKeyShare +import org.exploit.keeper.platform.pqc.model.recovery.MLDSARecoveryCommitmentSet +import org.exploit.keeper.service.dealer.serializer.KeyShareDeserializer +import org.exploit.keeper.service.dealer.serializer.KeyShareSerializer +import org.exploit.keeper.service.key.KeyAlgorithms +import org.exploit.mldsa.model.key.threshold.ThresholdPartyKey +import org.exploit.mldsa.model.key.threshold.ThresholdPublicKey +import java.security.MessageDigest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.test.assertNull + +class MLDSADealerRecoveryCommitmentsTest { + @Test + fun `dealer share JSON preserves commitments and accepts legacy payload`() { + val algorithm = MLDSA44KeyAlgorithmProvider() + val adapter = MLDSAKeyShareJacksonAdapter(KeyAlgorithms(listOf(algorithm))) + val mapper = JacksonFactory( + emptyList(), + KeyShareSerializer(listOf(adapter)), + KeyShareDeserializer(listOf(adapter)), + ).internalObjectMapper() + val share = MLDSAKeyShare( + algorithm.name(), byteArrayOf(1, 2), byteArrayOf(3, 4), 2, 3, byteArrayOf(5, 6) + ) + val encoded = mapper.writerFor(KeyShare::class.java).writeValueAsBytes(share) + try { + val restored = mapper.readValue(encoded, KeyShare::class.java) as MLDSAKeyShare + try { + assertTrue(MessageDigest.isEqual(share.recoveryCommitments!!, restored.recoveryCommitments!!)) + } finally { + restored.close() + } + + val legacy = mapper.readTree(encoded) as com.fasterxml.jackson.databind.node.ObjectNode + legacy.remove("recoveryCommitments") + val restoredLegacy = mapper.treeToValue(legacy, KeyShare::class.java) as MLDSAKeyShare + try { + assertNull(restoredLegacy.recoveryCommitments) + } finally { + restoredLegacy.close() + } + } finally { + encoded.fill(0) + share.close() + } + } + + @Test + fun `dealer split emits one commitment set and binds every party key`() { + val algorithm = MLDSA44KeyAlgorithmProvider() + val keyPair = algorithm.factory().generate() + val encodedPrivate = keyPair.privateKey().encode() + val split = try { + MLDSAKeySplitter(KeyAlgorithms(listOf(algorithm))).split(algorithm, encodedPrivate, 2, 3) + } finally { + encodedPrivate.fill(0) + keyPair.destroy() + } + + val shares = split.shares.values.map { it as MLDSAKeyShare } + try { + val expected = shares.first().recoveryCommitments!! + assertTrue(shares.all { MessageDigest.isEqual(expected, it.recoveryCommitments) }) + shares.forEachIndexed { index, share -> + val commitments = MLDSARecoveryCommitmentSet.decode(share.recoveryCommitments!!) + val publicKey = ThresholdPublicKey.decode(share.publicKey, algorithm.params) + val partyKey = ThresholdPartyKey.decode(share.share) + try { + commitments.requireValidPartyKey(publicKey, partyKey, index) + } finally { + partyKey.destroy() + publicKey.destroy() + } + } + + val swapped = ThresholdPartyKey.decode(shares[1].share) + val publicKey = ThresholdPublicKey.decode(shares[0].publicKey, algorithm.params) + try { + assertFailsWith { + MLDSARecoveryCommitmentSet.decode(expected).requireValidPartyKey(publicKey, swapped, 0) + } + } finally { + swapped.destroy() + publicKey.destroy() + } + assertEquals(3, shares.size) + } finally { + shares.forEach(MLDSAKeyShare::close) + } + } +} diff --git a/platform-pqc/src/test/kotlin/org/exploit/keeper/platform/pqc/fuzz/KeeperMLDSAProtocolStateFuzzTest.kt b/platform-pqc/src/test/kotlin/org/exploit/keeper/platform/pqc/fuzz/KeeperMLDSAProtocolStateFuzzTest.kt new file mode 100644 index 00000000..3d746db2 --- /dev/null +++ b/platform-pqc/src/test/kotlin/org/exploit/keeper/platform/pqc/fuzz/KeeperMLDSAProtocolStateFuzzTest.kt @@ -0,0 +1,187 @@ +package org.exploit.keeper.platform.pqc.fuzz + +import com.code_intelligence.jazzer.junit.FuzzTest +import org.exploit.keeper.constant.common.ErrorType +import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.platform.pqc.protocol.MLDSADkgProtocolState +import org.exploit.keeper.platform.pqc.protocol.MLDSASigningProtocolState +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.provider.MethodSource +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.stream.Stream +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class KeeperMLDSAProtocolStateFuzzTest { + @MethodSource("seeds") + @FuzzTest(maxDuration = "30s") + fun generatedActionsPreserveKeeperMLDSAProtocolOrder(input: ByteArray) { + if (input.size > MAX_INPUT_SIZE) return + + val actions = if (input.isEmpty()) byteArrayOf(0) else input + fuzzDkg(actions, refresh = false) + fuzzDkg(actions, refresh = true) + fuzzSigning(actions) + } + + @Test + fun concurrentRoundTransitionsHaveExactlyOneWinner() { + val dkg = MLDSADkgProtocolState(refresh = false) + assertOneSuccess(runConcurrently(CONTENDERS) { runCatching { dkg.round1 {} } }) + + val signing = MLDSASigningProtocolState() + assertOneSuccess(runConcurrently(CONTENDERS) { runCatching { signing.round1 {} } }) + } + + private fun fuzzDkg(input: ByteArray, refresh: Boolean) { + val state = MLDSADkgProtocolState(refresh) + var phase = DkgPhase.INITIALIZED + + input.forEachIndexed { index, value -> + val fail = unsigned(input.at(index + 1)) and 1 == 1 + when (unsigned(value) % 10) { + 0 -> expect(phase == DkgPhase.INITIALIZED || phase == DkgPhase.ROUND1, fail) { + state.direct { probe(fail) } + } + 1 -> transition(phase == DkgPhase.INITIALIZED, fail, { state.round1 { probe(fail) } }) { + phase = DkgPhase.ROUND1 + } + 2 -> transition(phase == DkgPhase.ROUND1, fail, { state.round2 { probe(fail) } }) { + phase = DkgPhase.ROUND2 + } + 3 -> transition(phase == DkgPhase.ROUND2, fail, { state.round3 { probe(fail) } }) { + phase = DkgPhase.ROUND3 + } + 4 -> transition(phase == DkgPhase.ROUND3, fail, { state.round4 { probe(fail) } }) { + phase = DkgPhase.ROUND4 + } + 5 -> expect(phase in DKG_ROUND4_STORE_PHASES, fail) { + state.storeRound4 { probe(fail) } + } + 6 -> transition(phase == DkgPhase.ROUND4, fail, { state.aggregate { probe(fail) } }) { + phase = DkgPhase.AGGREGATED + } + 7 -> { + val allowed = if (refresh) phase == DkgPhase.INITIALIZED else phase == DkgPhase.AGGREGATED + transition(allowed, fail, { state.compute { probe(fail) } }) { phase = DkgPhase.PENDING } + } + 8 -> transition(phase == DkgPhase.PENDING, fail, { state.complete { probe(fail) } }) { + phase = DkgPhase.TERMINATED + } + else -> { + state.terminate() + phase = DkgPhase.TERMINATED + } + } + assertEquals(phase.name, state.phase().name) + } + } + + private fun fuzzSigning(input: ByteArray) { + val state = MLDSASigningProtocolState() + var phase = SigningPhase.INITIALIZED + + input.forEachIndexed { index, value -> + val fail = unsigned(input.at(index + 1)) and 1 == 1 + when (unsigned(value) % 4) { + 0 -> transition(phase == SigningPhase.INITIALIZED, fail, { state.round1 { probe(fail) } }) { + phase = SigningPhase.ROUND1 + } + 1 -> transition(phase == SigningPhase.ROUND1, fail, { state.round2 { probe(fail) } }) { + phase = SigningPhase.ROUND2 + } + 2 -> transition(phase == SigningPhase.ROUND2, fail, { state.round3 { probe(fail) } }) { + phase = SigningPhase.ROUND3 + } + else -> { + state.terminate() + phase = SigningPhase.TERMINATED + } + } + assertEquals(phase.name, state.phase().name) + } + } + + private fun transition(allowed: Boolean, fails: Boolean, action: () -> Unit, success: () -> Unit) { + expect(allowed, fails, action) + if (allowed && !fails) success() + } + + private fun expect(allowed: Boolean, fails: Boolean = false, action: () -> Unit) { + val result = runCatching(action) + when { + !allowed -> assertProtocolStateFailure(result.exceptionOrNull()) + fails -> assertSame(PROBE, result.exceptionOrNull()) + else -> assertTrue(result.isSuccess) + } + } + + private fun assertOneSuccess(outcomes: List>) { + assertEquals(1, outcomes.count { it.isSuccess }) + assertEquals(CONTENDERS - 1, outcomes.count { + (it.exceptionOrNull() as? TKeeperException)?.errorType == ErrorType.INVALID_REQUEST_BODY + }) + } + + private fun assertProtocolStateFailure(error: Throwable?) { + assertEquals(ErrorType.INVALID_REQUEST_BODY, assertIs(error).errorType) + } + + private fun runConcurrently(count: Int, action: () -> T): List { + val ready = CountDownLatch(count) + val start = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(count) + return try { + val futures = List(count) { + executor.submit { + ready.countDown() + check(start.await(10, TimeUnit.SECONDS)) + action() + } + } + check(ready.await(10, TimeUnit.SECONDS)) + start.countDown() + futures.map { it.get(10, TimeUnit.SECONDS) } + } finally { + executor.shutdownNow() + } + } + + private fun probe(fail: Boolean) { + if (fail) throw PROBE + } + + private fun unsigned(value: Byte): Int = value.toInt() and 0xff + + private fun ByteArray.at(index: Int): Byte = this[index % size] + + private enum class DkgPhase { INITIALIZED, ROUND1, ROUND2, ROUND3, ROUND4, AGGREGATED, PENDING, TERMINATED } + private enum class SigningPhase { INITIALIZED, ROUND1, ROUND2, ROUND3, TERMINATED } + + companion object { + private const val MAX_INPUT_SIZE = 4_096 + private const val CONTENDERS = 8 + private val PROBE = ProbeFailure() + private val DKG_ROUND4_STORE_PHASES = setOf( + DkgPhase.INITIALIZED, + DkgPhase.ROUND1, + DkgPhase.ROUND2, + DkgPhase.ROUND3, + DkgPhase.ROUND4, + ) + + @JvmStatic + fun seeds(): Stream = Stream.of( + ByteArray(0), + byteArrayOf(1, 0, 2, 0, 3, 0, 4, 0, 6, 0, 7, 0, 8, 0), + byteArrayOf(0, 0, 1, 0, 2, 0), + ByteArray(128) { it.toByte() }, + ) + } + + private class ProbeFailure : RuntimeException() +} diff --git a/platform-pqc/src/test/kotlin/org/exploit/keeper/platform/pqc/keygen/MLDSAKeyGenRoundsTest.kt b/platform-pqc/src/test/kotlin/org/exploit/keeper/platform/pqc/keygen/MLDSAKeyGenRoundsTest.kt index 0203c4bc..bac40452 100644 --- a/platform-pqc/src/test/kotlin/org/exploit/keeper/platform/pqc/keygen/MLDSAKeyGenRoundsTest.kt +++ b/platform-pqc/src/test/kotlin/org/exploit/keeper/platform/pqc/keygen/MLDSAKeyGenRoundsTest.kt @@ -2,18 +2,55 @@ package org.exploit.keeper.platform.pqc.keygen import org.exploit.keeper.platform.pqc.algorithm.MLDSA44KeyAlgorithmProvider import org.exploit.keeper.platform.pqc.model.keygen.* +import org.exploit.keeper.platform.pqc.model.recovery.MLDSARecoveryCommitmentSet import org.exploit.keeper.platform.pqc.util.toDto import org.exploit.keeper.platform.pqc.util.toLibrary import org.exploit.keeper.util.KeySerializer import org.exploit.mldsa.constant.MLDSAParameters import org.exploit.mldsa.dkg.ThresholdMLDSAKeyGenerator import org.exploit.mldsa.model.key.threshold.ThresholdPartyKey +import org.exploit.mldsa.rss.RssSecretSharing import org.exploit.mldsa.util.WPacking import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith class MLDSAKeyGenRoundsTest { + @Test + fun `recovery commitments cover every RSS subset for every supported quorum`() { + for (total in 2..6) { + for (threshold in 2..total) { + val subsets = RssSecretSharing.allSubsets(total, total - threshold + 1) + .map { it.indices().toList() } + val outputs = (0 until total).map { partyIndex -> + MLDSARound3Output( + partyIndex, + subsets.filter { it.first() == partyIndex } + .map { MLDSASubsetBytes(it, ByteArray(64) { index -> (index + partyIndex).toByte() }) } + ) + } + val batch = MLDSARound3Batch(outputs) + val commitments = MLDSARecoveryCommitmentSet.fromRound3(7, threshold, total, batch) + + subsets.forEach { subset -> + assertEquals(64, commitments.commitment(subset)?.size) + } + assertFailsWith { + MLDSARecoveryCommitmentSet.fromRound3( + 7, + threshold, + total, + MLDSARound3Batch(outputs.mapIndexed { index, output -> + if (index == 0) output.copy(commitments = output.commitments.drop(1)) else output + }) + ) + } + } + } + } + @Test fun `keeper round DTOs preserve distributed key generation`() { val total = 3 @@ -51,6 +88,25 @@ class MLDSAKeyGenRoundsTest { val expectedPublicKey = results.first().publicKey().encode() val algorithm = MLDSA44KeyAlgorithmProvider() + val recoveryCommitments = MLDSARecoveryCommitmentSet.fromRound3( + algorithm.id(), + threshold = 2, + total = total, + batch = round3Batch, + ).let { MLDSARecoveryCommitmentSet.decode(it.encode()) } + + round4Batch.outputs.flatMap { it.publicShares }.forEach { publicShare -> + val expected = recoveryCommitments.commitment(publicShare.subset) + val actual = MLDSARecoveryCommitmentSet.commitPackedPublicShare(publicShare.subset, publicShare.value) + assertContentEquals(expected, actual) + + val tampered = publicShare.value.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() } + assertFalse( + actual.contentEquals( + MLDSARecoveryCommitmentSet.commitPackedPublicShare(publicShare.subset, tampered) + ) + ) + } assertContentEquals( expectedPublicKey, diff --git a/sdk/build.gradle b/sdk/build.gradle index 1f689d62..4199b571 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -7,7 +7,7 @@ plugins { } group = 'org.exploit' -version = findProperty('releaseVersion') ?: '2.3.2' +version = findProperty('releaseVersion') ?: '2.4.0' 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 1c1c19cf..791940c7 100644 --- a/sdk/src/main/java/org/exploit/tkeeper/sdk/TKeeperClient.java +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/TKeeperClient.java @@ -27,6 +27,7 @@ public final class TKeeperClient implements Closeable { private final DKGModule dkg; private final ExpireModule expire; private final QuorumModule quorum; + private final RecoveryModule recovery; public TKeeperClient(Jettyx jettyx, String baseUrl, Authorization authorization) { this.jettyx = jettyx; @@ -46,6 +47,7 @@ public TKeeperClient(Jettyx jettyx, String baseUrl, Authorization authorization) this.dkg = new DKGModule(jettyx, baseUrl, auth); this.expire = new ExpireModule(jettyx, baseUrl, auth); this.quorum = new QuorumModule(jettyx, baseUrl, auth); + this.recovery = new RecoveryModule(jettyx, baseUrl, auth); } public TKeeperClient(String baseUrl, Authorization authorization) { @@ -113,6 +115,10 @@ public QuorumModule quorum() { return quorum; } + public RecoveryModule recovery() { + return recovery; + } + public Jettyx getJettyx() { return jettyx; } diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/exception/TKeeperException.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/exception/TKeeperException.java index fcdbf961..50f46c67 100644 --- a/sdk/src/main/java/org/exploit/tkeeper/sdk/exception/TKeeperException.java +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/exception/TKeeperException.java @@ -1,20 +1,19 @@ package org.exploit.tkeeper.sdk.exception; -import org.exploit.tkeeper.sdk.model.ErrorType; import org.exploit.tkeeper.sdk.model.RequiredApprovalDetails; import java.util.Collections; import java.util.List; public final class TKeeperException extends RuntimeException { - private final ErrorType errorType; + private final String errorType; private final String details; private final List imposters; private final List dead; private final List approvals; public TKeeperException( - ErrorType errorType, + String errorType, String details, List imposters, List dead, @@ -27,15 +26,15 @@ public TKeeperException( this.approvals = approvals; } - public TKeeperException(ErrorType errorType, String details, List imposters, List dead) { + public TKeeperException(String errorType, String details, List imposters, List dead) { this(errorType, details, imposters, dead, Collections.emptyList()); } - public TKeeperException(ErrorType errorType, String details) { + public TKeeperException(String errorType, String details) { this(errorType, details, Collections.emptyList(), Collections.emptyList()); } - public ErrorType getErrorType() { + public String getErrorType() { return errorType; } diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/ConsistencyVerdict.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/ConsistencyVerdict.java index ccf19167..8fdcb22d 100644 --- a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/ConsistencyVerdict.java +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/ConsistencyVerdict.java @@ -4,5 +4,6 @@ public enum ConsistencyVerdict { OK, SYNC_NEEDED, ROTATE_NEEDED, + RECOVER_NEEDED, MISSING; } diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/ErrorMessage.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/ErrorMessage.java index ad9d2e4a..92bb3c12 100644 --- a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/ErrorMessage.java +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/ErrorMessage.java @@ -9,7 +9,7 @@ @JsonInclude(Include.NON_NULL) public record ErrorMessage( @JsonProperty("code") int code, - @JsonProperty("error") ErrorType error, + @JsonProperty("error") String error, @JsonProperty("details") String details, @JsonProperty("imposters") List imposters, @JsonProperty("dead") List dead, @@ -17,7 +17,7 @@ public record ErrorMessage( ) { public ErrorMessage( int code, - ErrorType error, + String error, String details, List imposters, List dead diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/ErrorType.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/ErrorType.java index 9c8015a2..f8def3d1 100644 --- a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/ErrorType.java +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/ErrorType.java @@ -1,96 +1,104 @@ package org.exploit.tkeeper.sdk.model; -public enum ErrorType { - KEEPER_ALREADY_INITIALIZED, - KEEPER_ALREADY_UNSEALED, - NOT_SUPPORTED_FOR_AUTO_PROVIDER, - NOT_SUPPORTED_FOR_MANUAL_PROVIDER, - - KEEPER_NOT_INITIALIZED, - KEEPER_SEALED, - RESTART_REQUIRED, - - KEY_NOT_FOUND, - SESSION_MANAGER_NOT_FOUND, - SESSION_NOT_FOUND, - UNSUPPORTED_KEY_ALGORITHM, - SIGNATURE_VERIFIER_NOT_FOUND, - SESSION_ALREADY_EXISTS, - - NOT_ENOUGH_HEALTHY_CLIENTS, - NOT_ENOUGH_CONFIGURED_CLIENTS, - NOT_ENOUGH_HONEST_CLIENTS, - - INVALID_CONFIG, - INVALID_SIGNATURE, - KEY_ALGORITHM_MISMATCH, - INVALID_BASE64, - INVALID_THRESHOLD, - INVALID_TOTAL, - INVALID_PEER_ID, - INVALID_CIPHERTEXT, - INVALID_SESSION_ID, - INVALID_REQUEST_BODY, - INVALID_QUERY_PARAMETER, - INVALID_SHARE, - INVALID_KEY_SET_POLICY, - INVALID_KEY_GENERATION, - INVALID_CURSOR, - INVALID_EXPIRATION_TIME, - INVALID_HASH_METHOD, - INVALID_PAYLOAD, - INVALID_SIG_SCHEME, - INVALID_KEY_ID, - INVALID_ASSET_OWNER, - INVALID_EXPIRE_TYPE, - INVALID_TWEAK, - INVALID_AUTHORITY_ARTIFACT, - INVALID_AUTHORITY_TYPE, - INVALID_AUTHORITY, - INVALID_INTENT, - - NOT_PARTICIPANT, - SHARE_NOT_FOUND, - IMPOSTER_FOUND, - CORRUPTED_KEY_STATE, - - KEY_ALREADY_EXISTS, - KEY_APPLY_OPS_FORBIDDEN, - KEY_PROCESS_OPS_FORBIDDEN, - - AUDIT_FAILED, - AUDIT_NOT_AVAILABLE, - - INTEGRITY_KEY_NOT_FOUND, - HMAC_KEY_NOT_FOUND, - - MISSING_KEY_ID, - MISSING_SESSION_ID, - MISSING_KEY_VERSION, - MISSING_WINDOW, - MISSING_EXPIRE_TYPE, - - IDENTIFIABLE_ABORT, - - DEV_MODE_VIOLATION, - - APPROVAL_REQUIRED, - ACCESS_DENIED, - UNAUTHENTICATED, - DESTROY_FORBIDDEN, - POLICY_VIOLATION, - AUTHORITY_VIOLATION, - NOT_THRESHOLD, - NOT_MONO, - - WARNING, - - CONSISTENCY_CHECK_FAILED, - ROTATE_NEEDED, - INCONSISTENT_KEEPER, - TAMPERED_KEEPER, - SESSION_MAX_ROUNDS_EXCEEDED, - NOT_COORDINATOR, - HSM_ERROR, - INTERNAL_ERROR +public final class ErrorType { + private ErrorType() { + } + + public static final String KEEPER_ALREADY_INITIALIZED = "KEEPER_ALREADY_INITIALIZED"; + public static final String KEEPER_ALREADY_UNSEALED = "KEEPER_ALREADY_UNSEALED"; + public static final String NOT_SUPPORTED_FOR_AUTO_PROVIDER = "NOT_SUPPORTED_FOR_AUTO_PROVIDER"; + public static final String NOT_SUPPORTED_FOR_MANUAL_PROVIDER = "NOT_SUPPORTED_FOR_MANUAL_PROVIDER"; + + public static final String KEEPER_NOT_INITIALIZED = "KEEPER_NOT_INITIALIZED"; + public static final String KEEPER_SEALED = "KEEPER_SEALED"; + public static final String RESTART_REQUIRED = "RESTART_REQUIRED"; + public static final String RECOVERY_MODE_ACTIVE = "RECOVERY_MODE_ACTIVE"; + public static final String RECOVERY_NOT_ENABLED = "RECOVERY_NOT_ENABLED"; + public static final String RECOVERY_NOT_POSSIBLE = "RECOVERY_NOT_POSSIBLE"; + public static final String RECOVERY_FAILED = "RECOVERY_FAILED"; + public static final String RECOVER_NEEDED = "RECOVER_NEEDED"; + + public static final String KEY_NOT_FOUND = "KEY_NOT_FOUND"; + public static final String SESSION_MANAGER_NOT_FOUND = "SESSION_MANAGER_NOT_FOUND"; + public static final String SESSION_NOT_FOUND = "SESSION_NOT_FOUND"; + public static final String UNSUPPORTED_KEY_ALGORITHM = "UNSUPPORTED_KEY_ALGORITHM"; + public static final String SIGNATURE_VERIFIER_NOT_FOUND = "SIGNATURE_VERIFIER_NOT_FOUND"; + public static final String SESSION_ALREADY_EXISTS = "SESSION_ALREADY_EXISTS"; + + public static final String NOT_ENOUGH_HEALTHY_CLIENTS = "NOT_ENOUGH_HEALTHY_CLIENTS"; + public static final String NOT_ENOUGH_CONFIGURED_CLIENTS = "NOT_ENOUGH_CONFIGURED_CLIENTS"; + public static final String NOT_ENOUGH_HONEST_CLIENTS = "NOT_ENOUGH_HONEST_CLIENTS"; + + public static final String INVALID_CONFIG = "INVALID_CONFIG"; + public static final String INVALID_SIGNATURE = "INVALID_SIGNATURE"; + public static final String KEY_ALGORITHM_MISMATCH = "KEY_ALGORITHM_MISMATCH"; + public static final String INVALID_BASE64 = "INVALID_BASE64"; + public static final String INVALID_THRESHOLD = "INVALID_THRESHOLD"; + public static final String INVALID_TOTAL = "INVALID_TOTAL"; + public static final String INVALID_PEER_ID = "INVALID_PEER_ID"; + public static final String INVALID_CIPHERTEXT = "INVALID_CIPHERTEXT"; + public static final String INVALID_SESSION_ID = "INVALID_SESSION_ID"; + public static final String INVALID_REQUEST_BODY = "INVALID_REQUEST_BODY"; + public static final String INVALID_QUERY_PARAMETER = "INVALID_QUERY_PARAMETER"; + public static final String INVALID_SHARE = "INVALID_SHARE"; + public static final String INVALID_KEY_SET_POLICY = "INVALID_KEY_SET_POLICY"; + public static final String INVALID_KEY_GENERATION = "INVALID_KEY_GENERATION"; + public static final String INVALID_CURSOR = "INVALID_CURSOR"; + public static final String INVALID_EXPIRATION_TIME = "INVALID_EXPIRATION_TIME"; + public static final String INVALID_HASH_METHOD = "INVALID_HASH_METHOD"; + public static final String INVALID_PAYLOAD = "INVALID_PAYLOAD"; + public static final String INVALID_SIG_SCHEME = "INVALID_SIG_SCHEME"; + public static final String INVALID_KEY_ID = "INVALID_KEY_ID"; + public static final String INVALID_ASSET_OWNER = "INVALID_ASSET_OWNER"; + public static final String INVALID_EXPIRE_TYPE = "INVALID_EXPIRE_TYPE"; + public static final String INVALID_TWEAK = "INVALID_TWEAK"; + public static final String INVALID_AUTHORITY_ARTIFACT = "INVALID_AUTHORITY_ARTIFACT"; + public static final String INVALID_AUTHORITY_TYPE = "INVALID_AUTHORITY_TYPE"; + public static final String INVALID_AUTHORITY = "INVALID_AUTHORITY"; + public static final String INVALID_INTENT = "INVALID_INTENT"; + + public static final String NOT_PARTICIPANT = "NOT_PARTICIPANT"; + public static final String SHARE_NOT_FOUND = "SHARE_NOT_FOUND"; + public static final String IMPOSTER_FOUND = "IMPOSTER_FOUND"; + public static final String CORRUPTED_KEY_STATE = "CORRUPTED_KEY_STATE"; + + public static final String KEY_ALREADY_EXISTS = "KEY_ALREADY_EXISTS"; + public static final String KEY_APPLY_OPS_FORBIDDEN = "KEY_APPLY_OPS_FORBIDDEN"; + public static final String KEY_PROCESS_OPS_FORBIDDEN = "KEY_PROCESS_OPS_FORBIDDEN"; + + public static final String AUDIT_FAILED = "AUDIT_FAILED"; + public static final String AUDIT_NOT_AVAILABLE = "AUDIT_NOT_AVAILABLE"; + + public static final String INTEGRITY_KEY_NOT_FOUND = "INTEGRITY_KEY_NOT_FOUND"; + public static final String HMAC_KEY_NOT_FOUND = "HMAC_KEY_NOT_FOUND"; + + public static final String MISSING_KEY_ID = "MISSING_KEY_ID"; + public static final String MISSING_SESSION_ID = "MISSING_SESSION_ID"; + public static final String MISSING_KEY_VERSION = "MISSING_KEY_VERSION"; + public static final String MISSING_WINDOW = "MISSING_WINDOW"; + public static final String MISSING_EXPIRE_TYPE = "MISSING_EXPIRE_TYPE"; + + public static final String IDENTIFIABLE_ABORT = "IDENTIFIABLE_ABORT"; + + public static final String DEV_MODE_VIOLATION = "DEV_MODE_VIOLATION"; + + public static final String APPROVAL_REQUIRED = "APPROVAL_REQUIRED"; + public static final String ACCESS_DENIED = "ACCESS_DENIED"; + public static final String UNAUTHENTICATED = "UNAUTHENTICATED"; + public static final String DESTROY_FORBIDDEN = "DESTROY_FORBIDDEN"; + public static final String POLICY_VIOLATION = "POLICY_VIOLATION"; + public static final String AUTHORITY_VIOLATION = "AUTHORITY_VIOLATION"; + public static final String NOT_THRESHOLD = "NOT_THRESHOLD"; + public static final String NOT_MONO = "NOT_MONO"; + + public static final String WARNING = "WARNING"; + + public static final String CONSISTENCY_CHECK_FAILED = "CONSISTENCY_CHECK_FAILED"; + public static final String ROTATE_NEEDED = "ROTATE_NEEDED"; + public static final String INCONSISTENT_KEEPER = "INCONSISTENT_KEEPER"; + public static final String TAMPERED_KEEPER = "TAMPERED_KEEPER"; + public static final String SESSION_MAX_ROUNDS_EXCEEDED = "SESSION_MAX_ROUNDS_EXCEEDED"; + public static final String NOT_COORDINATOR = "NOT_COORDINATOR"; + public static final String HSM_ERROR = "HSM_ERROR"; + public static final String INTERNAL_ERROR = "INTERNAL_ERROR"; } diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/Outcome.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/Outcome.java index b9f42b32..b50422b6 100644 --- a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/Outcome.java +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/Outcome.java @@ -9,11 +9,11 @@ @JsonInclude(Include.NON_NULL) public record Outcome( @JsonProperty("statusCode") Integer statusCode, - @JsonProperty("error") ErrorType error, + @JsonProperty("error") String error, @JsonProperty("details") String details, @JsonProperty("approvals") List approvals ) { - public Outcome(Integer statusCode, ErrorType error, String details) { + public Outcome(Integer statusCode, String error, String details) { this(statusCode, error, details, null); } } diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/RecoverRequest.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/RecoverRequest.java new file mode 100644 index 00000000..49d302b4 --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/RecoverRequest.java @@ -0,0 +1,14 @@ +package org.exploit.tkeeper.sdk.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record RecoverRequest( + @JsonProperty("keyId") String keyId, + @JsonProperty("helperIds") List helperIds +) { + public RecoverRequest { + helperIds = List.copyOf(helperIds); + } +} diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/model/RecoverResult.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/RecoverResult.java new file mode 100644 index 00000000..e5d75408 --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/model/RecoverResult.java @@ -0,0 +1,15 @@ +package org.exploit.tkeeper.sdk.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record RecoverResult( + @JsonProperty("keyId") String keyId, + @JsonProperty("algorithm") String algorithm, + @JsonProperty("targetPeerId") int targetPeerId, + @JsonProperty("currentGeneration") int currentGeneration, + @JsonProperty("recoveredGenerations") List recoveredGenerations, + @JsonProperty("helpers") List helpers +) { +} diff --git a/sdk/src/main/java/org/exploit/tkeeper/sdk/modules/RecoveryModule.java b/sdk/src/main/java/org/exploit/tkeeper/sdk/modules/RecoveryModule.java new file mode 100644 index 00000000..1cd059f7 --- /dev/null +++ b/sdk/src/main/java/org/exploit/tkeeper/sdk/modules/RecoveryModule.java @@ -0,0 +1,35 @@ +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.RecoverRequest; +import org.exploit.tkeeper.sdk.model.RecoverResult; + +import static org.exploit.tkeeper.sdk.util.KeeperUtils.handleError; + +public final class RecoveryModule { + private final RecoveryApi api; + + private interface RecoveryApi { + @Description("Recover missing key shares for all non-destroyed generations") + @HttpRequest(method = HttpMethod.POST, path = "/v1/keeper/recovery/recover") + @ContentType("application/json") + HttpResponse recover(@Body RecoverRequest request); + } + + public RecoveryModule(Jettyx jettyx, String baseUrl, Authorization auth) { + this.api = jettyx.newApiClient(baseUrl, auth).create(RecoveryApi.class); + } + + public RecoverResult recover(RecoverRequest request) { + var response = api.recover(request); + handleError(response); + 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 6e114180..2e482dd0 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 @@ -126,6 +126,15 @@ void ordinaryErrorKeepsLegacyShape() throws Exception { assertFalse(json.has("approvals")); } + @Test + void acceptsUnknownErrorType() throws Exception { + var error = mapper.readValue(""" + {"code":400,"error":"PLUGIN_ERROR","details":"missing"} + """, ErrorMessage.class); + + assertEquals("PLUGIN_ERROR", error.error()); + } + @Test void readsControlPlaneAuthVariants() throws Exception { var token = mapper.readValue(""" diff --git a/settings.gradle b/settings.gradle index db1a3230..355768c5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -21,3 +21,7 @@ include 'platform-ecc' include 'platform-pqc' include 'features:auth-dev' + +include 'features:recovery' +include 'features:recovery:ecc' +include 'features:recovery:pqc' diff --git a/src/main/kotlin/org/exploit/keeper/api/client/TKeeperClient.kt b/src/main/kotlin/org/exploit/keeper/api/client/TKeeperClient.kt index a4298208..3bbf4738 100644 --- a/src/main/kotlin/org/exploit/keeper/api/client/TKeeperClient.kt +++ b/src/main/kotlin/org/exploit/keeper/api/client/TKeeperClient.kt @@ -9,6 +9,7 @@ import org.exploit.keeper.api.ConsistencyApi import org.exploit.keeper.api.DestroyApi import org.exploit.keeper.api.HealthApi import org.exploit.keeper.api.TrustedDealerApi +import org.exploit.keeper.model.api.SensitiveResponse import org.exploit.keeper.service.auth.internal.InternalPathPolicy import org.exploit.keeper.service.auth.jettyx.InternalResponseVerifier import org.exploit.keeper.service.auth.jettyx.KeeperJettyxAuthenticator @@ -53,15 +54,21 @@ class TKeeperClient( if (!requiresVerification) return@newProxyInstance result - if (request == null) + if (request == null) { + @Suppress("UNCHECKED_CAST") + (result as? CompletableFuture>)?.thenAccept { response -> + (response.body as? SensitiveResponse)?.destroy() + } return@newProxyInstance CompletableFuture.failedFuture( IllegalStateException("Missing signed request context for $path") ) + } @Suppress("UNCHECKED_CAST") (result as CompletableFuture>).thenApply { response -> - verifier.get().verify(request, response, publicKey) - response + verifySensitiveResponse(response) { + verifier.get().verify(request, response, publicKey) + } } } catch (e: InvocationTargetException) { throw e.targetException @@ -75,4 +82,17 @@ class TKeeperClient( val dealer: TrustedDealerApi by lazy { internalClient(TrustedDealerApi::class.java) } val destroy: DestroyApi by lazy { internalClient(DestroyApi::class.java) } val health: HealthApi by lazy { internalClient(HealthApi::class.java) } -} + + companion object { + fun verifySensitiveResponse( + response: HttpResponse, + verify: () -> Unit, + ): HttpResponse = try { + verify() + response + } catch (t: Throwable) { + (response.body as? SensitiveResponse)?.destroy() + throw t + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/exploit/keeper/config/keeper/KeeperConfig.kt b/src/main/kotlin/org/exploit/keeper/config/keeper/KeeperConfig.kt index b5f366a0..8ac48bf4 100644 --- a/src/main/kotlin/org/exploit/keeper/config/keeper/KeeperConfig.kt +++ b/src/main/kotlin/org/exploit/keeper/config/keeper/KeeperConfig.kt @@ -13,10 +13,10 @@ data class KeeperConfig( val session: SessionConfig, val client: KeeperClientConfig, val servers: Servers, - val approval: ApprovalConfig + val approval: ApprovalConfig, ) { data class Servers( val public: KeeperServerConfig, val internal: KeeperServerConfig ) -} \ No newline at end of file +} diff --git a/src/main/kotlin/org/exploit/keeper/constant/common/ErrorType.kt b/src/main/kotlin/org/exploit/keeper/constant/common/ErrorType.kt index f7ff6a2c..1062c56a 100644 --- a/src/main/kotlin/org/exploit/keeper/constant/common/ErrorType.kt +++ b/src/main/kotlin/org/exploit/keeper/constant/common/ErrorType.kt @@ -1,96 +1,97 @@ package org.exploit.keeper.constant.common -enum class ErrorType(val statusCode: Int = 400) { - KEEPER_ALREADY_INITIALIZED, - KEEPER_ALREADY_UNSEALED, - NOT_SUPPORTED_FOR_AUTO_PROVIDER, - NOT_SUPPORTED_FOR_MANUAL_PROVIDER, +import org.exploit.keeper.model.error.ErrorCode - KEEPER_NOT_INITIALIZED(503), - KEEPER_SEALED(503), - RESTART_REQUIRED(503), +object ErrorType { + val KEEPER_ALREADY_INITIALIZED = ErrorCode("KEEPER_ALREADY_INITIALIZED") + val KEEPER_ALREADY_UNSEALED = ErrorCode("KEEPER_ALREADY_UNSEALED") + val NOT_SUPPORTED_FOR_AUTO_PROVIDER = ErrorCode("NOT_SUPPORTED_FOR_AUTO_PROVIDER") + val NOT_SUPPORTED_FOR_MANUAL_PROVIDER = ErrorCode("NOT_SUPPORTED_FOR_MANUAL_PROVIDER") - KEY_NOT_FOUND, - SESSION_MANAGER_NOT_FOUND, - SESSION_NOT_FOUND, - UNSUPPORTED_KEY_ALGORITHM, - SIGNATURE_VERIFIER_NOT_FOUND, - SESSION_ALREADY_EXISTS, + val KEEPER_NOT_INITIALIZED = ErrorCode("KEEPER_NOT_INITIALIZED", 503) + val KEEPER_SEALED = ErrorCode("KEEPER_SEALED", 503) + val RESTART_REQUIRED = ErrorCode("RESTART_REQUIRED", 503) - NOT_ENOUGH_HEALTHY_CLIENTS(503), - NOT_ENOUGH_CONFIGURED_CLIENTS(503), - NOT_ENOUGH_HONEST_CLIENTS(503), + val KEY_NOT_FOUND = ErrorCode("KEY_NOT_FOUND") + val SESSION_MANAGER_NOT_FOUND = ErrorCode("SESSION_MANAGER_NOT_FOUND") + val SESSION_NOT_FOUND = ErrorCode("SESSION_NOT_FOUND") + val UNSUPPORTED_KEY_ALGORITHM = ErrorCode("UNSUPPORTED_KEY_ALGORITHM") + val SIGNATURE_VERIFIER_NOT_FOUND = ErrorCode("SIGNATURE_VERIFIER_NOT_FOUND") + val SESSION_ALREADY_EXISTS = ErrorCode("SESSION_ALREADY_EXISTS") - INVALID_CONFIG, - INVALID_SIGNATURE, - KEY_ALGORITHM_MISMATCH, - INVALID_BASE64, - INVALID_THRESHOLD, - INVALID_TOTAL, - INVALID_PEER_ID, - INVALID_CIPHERTEXT, - INVALID_SESSION_ID, - INVALID_REQUEST_BODY, - INVALID_QUERY_PARAMETER, - INVALID_SHARE, - INVALID_KEY_SET_POLICY, - INVALID_KEY_GENERATION, - INVALID_CURSOR, - INVALID_EXPIRATION_TIME, - INVALID_HASH_METHOD, - INVALID_PAYLOAD, - INVALID_SIG_SCHEME, - INVALID_KEY_ID, - INVALID_ASSET_OWNER, - INVALID_EXPIRE_TYPE, - INVALID_TWEAK, - INVALID_AUTHORITY_ARTIFACT, - INVALID_AUTHORITY_TYPE, - INVALID_AUTHORITY, - INVALID_INTENT, + val NOT_ENOUGH_HEALTHY_CLIENTS = ErrorCode("NOT_ENOUGH_HEALTHY_CLIENTS", 503) + val NOT_ENOUGH_CONFIGURED_CLIENTS = ErrorCode("NOT_ENOUGH_CONFIGURED_CLIENTS", 503) + val NOT_ENOUGH_HONEST_CLIENTS = ErrorCode("NOT_ENOUGH_HONEST_CLIENTS", 503) - NOT_PARTICIPANT(500), - SHARE_NOT_FOUND(500), - IMPOSTER_FOUND(500), - CORRUPTED_KEY_STATE(500), + val INVALID_CONFIG = ErrorCode("INVALID_CONFIG") + val INVALID_SIGNATURE = ErrorCode("INVALID_SIGNATURE") + val KEY_ALGORITHM_MISMATCH = ErrorCode("KEY_ALGORITHM_MISMATCH") + val INVALID_BASE64 = ErrorCode("INVALID_BASE64") + val INVALID_THRESHOLD = ErrorCode("INVALID_THRESHOLD") + val INVALID_TOTAL = ErrorCode("INVALID_TOTAL") + val INVALID_PEER_ID = ErrorCode("INVALID_PEER_ID") + val INVALID_CIPHERTEXT = ErrorCode("INVALID_CIPHERTEXT") + val INVALID_SESSION_ID = ErrorCode("INVALID_SESSION_ID") + val INVALID_REQUEST_BODY = ErrorCode("INVALID_REQUEST_BODY") + val INVALID_QUERY_PARAMETER = ErrorCode("INVALID_QUERY_PARAMETER") + val INVALID_SHARE = ErrorCode("INVALID_SHARE") + val INVALID_KEY_SET_POLICY = ErrorCode("INVALID_KEY_SET_POLICY") + val INVALID_KEY_GENERATION = ErrorCode("INVALID_KEY_GENERATION") + val INVALID_CURSOR = ErrorCode("INVALID_CURSOR") + val INVALID_EXPIRATION_TIME = ErrorCode("INVALID_EXPIRATION_TIME") + val INVALID_HASH_METHOD = ErrorCode("INVALID_HASH_METHOD") + val INVALID_PAYLOAD = ErrorCode("INVALID_PAYLOAD") + val INVALID_SIG_SCHEME = ErrorCode("INVALID_SIG_SCHEME") + val INVALID_KEY_ID = ErrorCode("INVALID_KEY_ID") + val INVALID_ASSET_OWNER = ErrorCode("INVALID_ASSET_OWNER") + val INVALID_EXPIRE_TYPE = ErrorCode("INVALID_EXPIRE_TYPE") + val INVALID_TWEAK = ErrorCode("INVALID_TWEAK") + val INVALID_AUTHORITY_ARTIFACT = ErrorCode("INVALID_AUTHORITY_ARTIFACT") + val INVALID_AUTHORITY_TYPE = ErrorCode("INVALID_AUTHORITY_TYPE") + val INVALID_AUTHORITY = ErrorCode("INVALID_AUTHORITY") + val INVALID_INTENT = ErrorCode("INVALID_INTENT") - KEY_ALREADY_EXISTS, - KEY_APPLY_OPS_FORBIDDEN(403), - KEY_PROCESS_OPS_FORBIDDEN(403), + val NOT_PARTICIPANT = ErrorCode("NOT_PARTICIPANT", 500) + val SHARE_NOT_FOUND = ErrorCode("SHARE_NOT_FOUND", 500) + val IMPOSTER_FOUND = ErrorCode("IMPOSTER_FOUND", 500) + val CORRUPTED_KEY_STATE = ErrorCode("CORRUPTED_KEY_STATE", 500) - AUDIT_FAILED(500), - AUDIT_NOT_AVAILABLE(503), + val KEY_ALREADY_EXISTS = ErrorCode("KEY_ALREADY_EXISTS") + val KEY_APPLY_OPS_FORBIDDEN = ErrorCode("KEY_APPLY_OPS_FORBIDDEN", 403) + val KEY_PROCESS_OPS_FORBIDDEN = ErrorCode("KEY_PROCESS_OPS_FORBIDDEN", 403) - INTEGRITY_KEY_NOT_FOUND(500), - HMAC_KEY_NOT_FOUND(500), + val AUDIT_FAILED = ErrorCode("AUDIT_FAILED", 500) + val AUDIT_NOT_AVAILABLE = ErrorCode("AUDIT_NOT_AVAILABLE", 503) - MISSING_KEY_ID, - MISSING_SESSION_ID, - MISSING_KEY_VERSION, - MISSING_WINDOW, - MISSING_EXPIRE_TYPE, + val INTEGRITY_KEY_NOT_FOUND = ErrorCode("INTEGRITY_KEY_NOT_FOUND", 500) + val HMAC_KEY_NOT_FOUND = ErrorCode("HMAC_KEY_NOT_FOUND", 500) - IDENTIFIABLE_ABORT(500), + val MISSING_KEY_ID = ErrorCode("MISSING_KEY_ID") + val MISSING_SESSION_ID = ErrorCode("MISSING_SESSION_ID") + val MISSING_KEY_VERSION = ErrorCode("MISSING_KEY_VERSION") + val MISSING_WINDOW = ErrorCode("MISSING_WINDOW") + val MISSING_EXPIRE_TYPE = ErrorCode("MISSING_EXPIRE_TYPE") - DEV_MODE_VIOLATION(403), + val IDENTIFIABLE_ABORT = ErrorCode("IDENTIFIABLE_ABORT", 500) - APPROVAL_REQUIRED(403), - ACCESS_DENIED(403), - UNAUTHENTICATED(401), - DESTROY_FORBIDDEN(403), - POLICY_VIOLATION(403), - AUTHORITY_VIOLATION(403), - NOT_THRESHOLD(403), - NOT_MONO(403), + val APPROVAL_REQUIRED = ErrorCode("APPROVAL_REQUIRED", 403) + val ACCESS_DENIED = ErrorCode("ACCESS_DENIED", 403) + val UNAUTHENTICATED = ErrorCode("UNAUTHENTICATED", 401) + val DESTROY_FORBIDDEN = ErrorCode("DESTROY_FORBIDDEN", 403) + val POLICY_VIOLATION = ErrorCode("POLICY_VIOLATION", 403) + val AUTHORITY_VIOLATION = ErrorCode("AUTHORITY_VIOLATION", 403) + val NOT_THRESHOLD = ErrorCode("NOT_THRESHOLD", 403) + val NOT_MONO = ErrorCode("NOT_MONO", 403) - WARNING(statusCode = 299), + val WARNING = ErrorCode("WARNING", 299) + val HSM_ERROR = ErrorCode("HSM_ERROR", 500) - CONSISTENCY_CHECK_FAILED(500), - ROTATE_NEEDED(503), - INCONSISTENT_KEEPER(500), - TAMPERED_KEEPER(500), - SESSION_MAX_ROUNDS_EXCEEDED(500), - NOT_COORDINATOR(501), - HSM_ERROR(500), - INTERNAL_ERROR(500) -} + val CONSISTENCY_CHECK_FAILED = ErrorCode("CONSISTENCY_CHECK_FAILED", 500) + val ROTATE_NEEDED = ErrorCode("ROTATE_NEEDED", 503) + val RECOVER_NEEDED = ErrorCode("RECOVER_NEEDED", 503) + val INCONSISTENT_KEEPER = ErrorCode("INCONSISTENT_KEEPER", 500) + val TAMPERED_KEEPER = ErrorCode("TAMPERED_KEEPER", 500) + val SESSION_MAX_ROUNDS_EXCEEDED = ErrorCode("SESSION_MAX_ROUNDS_EXCEEDED", 500) + val NOT_COORDINATOR = ErrorCode("NOT_COORDINATOR", 501) + val INTERNAL_ERROR = ErrorCode("INTERNAL_ERROR", 500) +} \ No newline at end of file diff --git a/src/main/kotlin/org/exploit/keeper/constant/common/ServerType.kt b/src/main/kotlin/org/exploit/keeper/constant/common/ServerType.kt new file mode 100644 index 00000000..fc17d8b4 --- /dev/null +++ b/src/main/kotlin/org/exploit/keeper/constant/common/ServerType.kt @@ -0,0 +1,6 @@ +package org.exploit.keeper.constant.common + +enum class ServerType { + PUBLIC, + INTERNAL +} \ No newline at end of file diff --git a/src/main/kotlin/org/exploit/keeper/constant/dkg/ConsistencyVerdict.kt b/src/main/kotlin/org/exploit/keeper/constant/dkg/ConsistencyVerdict.kt index 0ef56e87..523105ea 100644 --- a/src/main/kotlin/org/exploit/keeper/constant/dkg/ConsistencyVerdict.kt +++ b/src/main/kotlin/org/exploit/keeper/constant/dkg/ConsistencyVerdict.kt @@ -4,5 +4,6 @@ enum class ConsistencyVerdict { OK, SYNC_NEEDED, ROTATE_NEEDED, + RECOVER_NEEDED, MISSING } diff --git a/src/main/kotlin/org/exploit/keeper/constant/permission/Permission.kt b/src/main/kotlin/org/exploit/keeper/constant/permission/Permission.kt index 9f75df9a..aa818d55 100644 --- a/src/main/kotlin/org/exploit/keeper/constant/permission/Permission.kt +++ b/src/main/kotlin/org/exploit/keeper/constant/permission/Permission.kt @@ -8,9 +8,6 @@ object Permission { private const val KEY_SIGN = "tkeeper.key.%s.sign" private const val KEY_VERIFY = "tkeeper.key.%s.verify" - private const val KEY_ENCRYPT = "tkeeper.key.%s.encrypt" - private const val KEY_DECRYPT = "tkeeper.key.%s.decrypt" - private const val KEY_DESTROY = "tkeeper.key.%s.destroy" private const val SYSTEM_UNSEAL = "tkeeper.system.unseal" @@ -57,10 +54,6 @@ object Permission { fun keyVerify(key: String): String = KEY_VERIFY.format(key) - fun keyEncrypt(key: String): String = KEY_ENCRYPT.format(key) - - fun keyDecrypt(key: String): String = KEY_DECRYPT.format(key) - fun keyDestroy(key: String): String = KEY_DESTROY.format(key) fun auditLogVerify(): String = AUDIT_LOG_VERIFY 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 8ba0d157..a0b32555 100644 --- a/src/main/kotlin/org/exploit/keeper/controller/support/ControllerSupport.kt +++ b/src/main/kotlin/org/exploit/keeper/controller/support/ControllerSupport.kt @@ -13,9 +13,11 @@ import org.exploit.keeper.constant.media.MsgPack import org.exploit.keeper.exception.TKeeperException import org.exploit.keeper.factory.JacksonFactory import org.exploit.keeper.model.api.Operation +import org.exploit.keeper.model.api.SensitiveResponse import org.exploit.keeper.model.approval.RequiredApprovalDetails import org.exploit.keeper.model.audit.ctx.Ctx import org.exploit.keeper.model.capability.* +import org.exploit.keeper.model.error.ErrorCode import org.exploit.keeper.model.error.ErrorMessage import org.exploit.keeper.model.internal.transport.Codec import org.exploit.keeper.service.audit.AuditService @@ -156,7 +158,7 @@ class ControllerSupport( finishStatus(HttpStatus.NO_CONTENT, msgpack) fun finishErrorJson( - err: ErrorType, + err: ErrorCode, details: String? = null, imposters: Collection? = null, dead: Collection? = null, @@ -165,7 +167,7 @@ class ControllerSupport( finishError(err, details, json, imposters, dead, approvals) fun finishErrorMsgpack( - err: ErrorType, + err: ErrorCode, details: String? = null, imposters: Collection? = null, dead: Collection? = null, @@ -174,14 +176,14 @@ class ControllerSupport( finishError(err, details, msgpack, imposters, dead, approvals) fun finishError( - err: ErrorType, + err: ErrorCode, details: String? = null, codec: Codec, imposters: Collection? = null, dead: Collection? = null, approvals: List? = null ): HttpResponse { - if (!finishAndAudit(err.statusCode, err, details, imposters, dead, approvals)) + if (!finishAndAudit(err.statusCode, err.name, details, imposters, dead, approvals)) return auditFailed(codec, imposters, dead) return writeError(codec, err, details, imposters, dead, approvals) @@ -281,7 +283,7 @@ class ControllerSupport( } catch (e: Exception) { finishAndAudit( statusCode = ErrorType.INVALID_REQUEST_BODY.statusCode, - error = ErrorType.INVALID_REQUEST_BODY + error = ErrorType.INVALID_REQUEST_BODY.name ) return writeError(codec, ErrorType.INVALID_REQUEST_BODY, details = e.message) @@ -312,18 +314,26 @@ class ControllerSupport( else -> null } - val bytes = codec.mapper.writeValueAsBytes(out) - - if (!finishAndAudit(HttpStatus.OK.code(), null, null, imposters, dead)) - return auditFailed(codec, imposters, dead) + val bytes = try { + codec.mapper.writeValueAsBytes(out) + } finally { + if (out is SensitiveResponse) out.destroy() + } - return HttpResponse.of( - ResponseHeaders.builder(HttpStatus.OK) - .contentType(codec.mediaType) - .set(HttpHeaderNames.CACHE_CONTROL, "no-store") - .build(), - HttpData.copyOf(bytes) - ) + return try { + if (!finishAndAudit(HttpStatus.OK.code(), null, null, imposters, dead)) + return auditFailed(codec, imposters, dead) + + HttpResponse.of( + ResponseHeaders.builder(HttpStatus.OK) + .contentType(codec.mediaType) + .set(HttpHeaderNames.CACHE_CONTROL, "no-store") + .build(), + HttpData.copyOf(bytes) + ) + } finally { + bytes.fill(0) + } } private fun finishStatus( @@ -340,7 +350,7 @@ class ControllerSupport( fun finishAndAudit( statusCode: Int, - error: ErrorType?, + error: String?, details: String? = null, imposters: Collection? = null, dead: Collection? = null, @@ -409,14 +419,14 @@ class ControllerSupport( fun writeError( codec: Codec, - error: ErrorType, + error: ErrorCode, details: String? = null, imposters: Collection? = null, dead: Collection? = null, approvals: List? = null, ): HttpResponse { val body = ErrorMessage( - error = error, + error = error.name, details = details, imposters = imposters?.sorted() ?: emptyList(), dead = dead?.sorted() ?: emptyList(), @@ -457,9 +467,6 @@ class ControllerSupport( fun auditNotAvailable(codec: Codec): HttpResponse = writeError(codec, ErrorType.AUDIT_NOT_AVAILABLE) - fun auditNotAvailable(mapper: ObjectMapper): HttpResponse = - writeError(Codec(MediaType.JSON, mapper), ErrorType.AUDIT_NOT_AVAILABLE) - private companion object { val LOGGER: Logger = LoggerFactory.getLogger(ControllerSupport::class.java) } diff --git a/src/main/kotlin/org/exploit/keeper/exception/TKeeperException.kt b/src/main/kotlin/org/exploit/keeper/exception/TKeeperException.kt index 655354d2..0fc135d8 100644 --- a/src/main/kotlin/org/exploit/keeper/exception/TKeeperException.kt +++ b/src/main/kotlin/org/exploit/keeper/exception/TKeeperException.kt @@ -1,10 +1,10 @@ package org.exploit.keeper.exception -import org.exploit.keeper.constant.common.ErrorType import org.exploit.keeper.model.approval.RequiredApprovalDetails +import org.exploit.keeper.model.error.ErrorCode open class TKeeperException( - val errorType: ErrorType, + val errorType: ErrorCode, val details: String? = null, val dead: Set = emptySet(), val imposters: Set = emptySet(), @@ -14,7 +14,7 @@ open class TKeeperException( ) { constructor( - errorType: ErrorType, + errorType: ErrorCode, details: String? = null, cause: Throwable, dead: Set = emptySet(), diff --git a/src/main/kotlin/org/exploit/keeper/factory/JettyxFactory.kt b/src/main/kotlin/org/exploit/keeper/factory/JettyxFactory.kt index 8ae4d554..2e3e3d1f 100644 --- a/src/main/kotlin/org/exploit/keeper/factory/JettyxFactory.kt +++ b/src/main/kotlin/org/exploit/keeper/factory/JettyxFactory.kt @@ -15,6 +15,7 @@ import org.exploit.keeper.service.auth.external.ExternalRequestAuthenticator import org.exploit.keeper.service.auth.internal.InternalPathPolicy import org.exploit.keeper.service.auth.jettyx.InternalResponseVerifier import org.exploit.keeper.service.auth.jettyx.KeeperJettyxAuthenticator +import org.exploit.keeper.service.auth.jettyx.PeerSpkiHostnameVerifier import org.exploit.keeper.service.boot.KeeperBootService import org.exploit.keeper.service.db.RocksKeyDB import org.exploit.keeper.service.integrity.KeeperInternalSigner @@ -79,6 +80,10 @@ class JettyxFactory(private val config: KeeperConfig) { sslContext.keyStoreType = clientConfig.keyStoreType ?: storeType(keyStorePath) } + PeerSpkiHostnameVerifier.from(config.peers)?.let { + sslContext.hostnameVerifier = it + } + builder.sslContextFactory(sslContext) } diff --git a/src/main/kotlin/org/exploit/keeper/factory/KeeperConfigFactory.kt b/src/main/kotlin/org/exploit/keeper/factory/KeeperConfigFactory.kt index 8cfeb72b..48d6ab32 100644 --- a/src/main/kotlin/org/exploit/keeper/factory/KeeperConfigFactory.kt +++ b/src/main/kotlin/org/exploit/keeper/factory/KeeperConfigFactory.kt @@ -145,7 +145,7 @@ class KeeperConfigFactory { session = parseSession(k), servers = servers, client = parseClient(k), - approval = parseApproval(k), + approval = parseApproval(k) ) } diff --git a/src/main/kotlin/org/exploit/keeper/model/api/SensitiveResponse.kt b/src/main/kotlin/org/exploit/keeper/model/api/SensitiveResponse.kt new file mode 100644 index 00000000..433580c1 --- /dev/null +++ b/src/main/kotlin/org/exploit/keeper/model/api/SensitiveResponse.kt @@ -0,0 +1,5 @@ +package org.exploit.keeper.model.api + +import javax.security.auth.Destroyable + +interface SensitiveResponse : Destroyable diff --git a/src/main/kotlin/org/exploit/keeper/model/audit/ctx/Outcome.kt b/src/main/kotlin/org/exploit/keeper/model/audit/ctx/Outcome.kt index fddd3ca4..4d2884b4 100644 --- a/src/main/kotlin/org/exploit/keeper/model/audit/ctx/Outcome.kt +++ b/src/main/kotlin/org/exploit/keeper/model/audit/ctx/Outcome.kt @@ -1,13 +1,12 @@ package org.exploit.keeper.model.audit.ctx import com.fasterxml.jackson.annotation.JsonInclude -import org.exploit.keeper.constant.common.ErrorType import org.exploit.keeper.model.approval.RequiredApprovalDetails @JsonInclude(JsonInclude.Include.NON_NULL) data class Outcome( val statusCode: Int? = null, - val error: ErrorType? = null, + val error: String? = null, val details: String? = null, val approvals: List? = null ) diff --git a/src/main/kotlin/org/exploit/keeper/model/error/ErrorCode.kt b/src/main/kotlin/org/exploit/keeper/model/error/ErrorCode.kt new file mode 100644 index 00000000..aac48548 --- /dev/null +++ b/src/main/kotlin/org/exploit/keeper/model/error/ErrorCode.kt @@ -0,0 +1,8 @@ +package org.exploit.keeper.model.error + +class ErrorCode(val name: String, val statusCode: Int = 400) { + override fun equals(other: Any?): Boolean = + this === other || (other is ErrorCode && name == other.name) + + override fun hashCode(): Int = name.hashCode() +} \ No newline at end of file diff --git a/src/main/kotlin/org/exploit/keeper/model/error/ErrorMessage.kt b/src/main/kotlin/org/exploit/keeper/model/error/ErrorMessage.kt index 3d277906..2a74ba2e 100644 --- a/src/main/kotlin/org/exploit/keeper/model/error/ErrorMessage.kt +++ b/src/main/kotlin/org/exploit/keeper/model/error/ErrorMessage.kt @@ -1,16 +1,17 @@ package org.exploit.keeper.model.error import com.fasterxml.jackson.annotation.JsonInclude -import org.exploit.keeper.constant.common.ErrorType import org.exploit.keeper.model.approval.RequiredApprovalDetails import org.exploit.keeper.model.capability.HasDead import org.exploit.keeper.model.capability.HasImposters @JsonInclude(JsonInclude.Include.NON_NULL) data class ErrorMessage( - val error: ErrorType, + val error: String, val details: String? = null, override val imposters: List = emptyList(), override val dead: List = emptyList(), val approvals: List? = null, -): HasImposters, HasDead +): HasImposters, HasDead { + constructor(error: ErrorCode): this(error.name) +} diff --git a/src/main/kotlin/org/exploit/keeper/model/internal/PeerTlsAddress.kt b/src/main/kotlin/org/exploit/keeper/model/internal/PeerTlsAddress.kt new file mode 100644 index 00000000..cdc3a65a --- /dev/null +++ b/src/main/kotlin/org/exploit/keeper/model/internal/PeerTlsAddress.kt @@ -0,0 +1,18 @@ +package org.exploit.keeper.model.internal + +import java.net.URI + +data class PeerTlsAddress(val host: String, val port: Int) { + companion object { + fun from(url: String): PeerTlsAddress { + val uri = URI.create(url) + val host = requireNotNull(uri.host) { "Peer URL must contain a host" }.lowercase() + val port = if (uri.port >= 0) uri.port else when (uri.scheme.lowercase()) { + "https" -> 443 + "http" -> 80 + else -> throw IllegalArgumentException("Unsupported peer URL scheme") + } + return PeerTlsAddress(host, port) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/exploit/keeper/server/ServerLauncher.kt b/src/main/kotlin/org/exploit/keeper/server/ServerLauncher.kt index ddc6d557..9228ee40 100644 --- a/src/main/kotlin/org/exploit/keeper/server/ServerLauncher.kt +++ b/src/main/kotlin/org/exploit/keeper/server/ServerLauncher.kt @@ -17,11 +17,13 @@ import io.netty.channel.ChannelOption import jakarta.inject.Singleton import org.exploit.keeper.config.keeper.KeeperConfig import org.exploit.keeper.config.keeper.KeeperServerConfig +import org.exploit.keeper.constant.common.ServerType import org.exploit.keeper.controller.KeeperController import org.exploit.keeper.controller.external.ExternalController import org.exploit.keeper.controller.internal.InternalController import org.exploit.keeper.controller.support.ControllerSupport import org.exploit.keeper.model.internal.transport.Codec +import org.exploit.keeper.server.handler.ServerEventHandler import org.exploit.keeper.service.audit.AuditService import org.exploit.keeper.service.auth.external.ExternalRequestAuthenticator import org.exploit.keeper.service.auth.external.TokenRequestAuthenticator @@ -29,6 +31,7 @@ import org.exploit.keeper.service.auth.internal.InternalAuthenticator import org.exploit.keeper.service.auth.internal.InternalResponseSigner import org.exploit.keeper.service.db.RocksKeyDB import org.exploit.keeper.service.decorator.AuditStartDecorator +import org.exploit.keeper.service.decorator.BeforeRequestDecoratorProvider import org.exploit.keeper.service.decorator.ExternalAuthDecorator import org.exploit.keeper.service.decorator.VirtualRequestDecorator import org.exploit.keeper.service.exception.KeeperExceptionHandler @@ -50,6 +53,8 @@ class ServerLauncher( private val vt: VirtualRequestDecorator, private val externalControllers: List, private val internalControllers: List, + private val beforeRequestDecorators: List, + private val eventHandlers: List, private val tlsConfig: ServerTLSConfigurer ) { @@ -67,22 +72,26 @@ class ServerLauncher( "Public server TLS must be enabled for the configured auth provider" } + eventHandlers.forEach { it.onStart() } + db.clearRestartRequired() val pub = buildServer( + type = ServerType.PUBLIC, cfg = keeperConfig.servers.public, codec = support.json, authDecorator = externalAuth, responseDecorator = null, - controllers = externalControllers, + controllers = externalControllers ) val inl = buildServer( + type = ServerType.INTERNAL, cfg = keeperConfig.servers.internal, codec = support.msgpack, authDecorator = internalAuth, responseDecorator = internalResponses, - controllers = internalControllers, + controllers = internalControllers ) publicServer = pub @@ -107,6 +116,7 @@ class ServerLauncher( } private fun buildServer( + type: ServerType, cfg: KeeperServerConfig, codec: Codec, authDecorator: DecoratingHttpServiceFunction, @@ -131,6 +141,8 @@ class ServerLauncher( val logFormatter = LogFormatter.builderForText() .requestHeadersSanitizer(headersSanitizer) .responseHeadersSanitizer(headersSanitizer) + .requestContentSanitizer { _, _ -> "" } + .responseContentSanitizer { _, _ -> "" } .build() val logWriter = LogWriter.builder() @@ -148,6 +160,7 @@ class ServerLauncher( sb.decorator(authDecorator) sb.decorator(exceptionHandler) responseDecorator?.let { sb.decorator(it) } + beforeRequestDecorators.sorted().forEach { it.addDecorator(type, sb, codec) } sb.decorator(vt) sb.decorator(AuditStartDecorator(support, audit, codec, keeperConfig)) sb.decorator(LoggingService.builder() diff --git a/src/main/kotlin/org/exploit/keeper/server/handler/ServerEventHandler.kt b/src/main/kotlin/org/exploit/keeper/server/handler/ServerEventHandler.kt new file mode 100644 index 00000000..470ef96e --- /dev/null +++ b/src/main/kotlin/org/exploit/keeper/server/handler/ServerEventHandler.kt @@ -0,0 +1,5 @@ +package org.exploit.keeper.server.handler + +interface ServerEventHandler { + fun onStart() {} +} \ No newline at end of file diff --git a/src/main/kotlin/org/exploit/keeper/service/audit/AuditHandle.kt b/src/main/kotlin/org/exploit/keeper/service/audit/AuditHandle.kt index 47715dd0..e4b9ab16 100644 --- a/src/main/kotlin/org/exploit/keeper/service/audit/AuditHandle.kt +++ b/src/main/kotlin/org/exploit/keeper/service/audit/AuditHandle.kt @@ -1,6 +1,5 @@ package org.exploit.keeper.service.audit -import org.exploit.keeper.constant.common.ErrorType import org.exploit.keeper.model.approval.RequiredApprovalDetails import org.exploit.keeper.model.audit.AuditEvent import org.exploit.keeper.model.audit.AuditRecord @@ -19,7 +18,7 @@ class AuditHandle( fun finish( statusCode: Int, - error: ErrorType?, + error: String?, details: String? = null, imposters: Collection? = null, dead: Collection? = null, diff --git a/src/main/kotlin/org/exploit/keeper/service/auth/internal/InternalAuthenticator.kt b/src/main/kotlin/org/exploit/keeper/service/auth/internal/InternalAuthenticator.kt index 6fb44c45..bdc81aa0 100644 --- a/src/main/kotlin/org/exploit/keeper/service/auth/internal/InternalAuthenticator.kt +++ b/src/main/kotlin/org/exploit/keeper/service/auth/internal/InternalAuthenticator.kt @@ -200,7 +200,7 @@ class InternalAuthenticator( method = req.method().name, path = ctx.path(), query = query, - body = req.content().array(), + body = req.content().array().copyOf(), token = token ) } catch (t: Throwable) { @@ -340,6 +340,7 @@ class InternalAuthenticator( } override fun destroy() { + body.fill(0) (signature as? Destroyable)?.destroy() if (!retainPublicKey) (pubFromHeader as? Destroyable)?.destroy() diff --git a/src/main/kotlin/org/exploit/keeper/service/auth/jettyx/KeeperJettyxAuthenticator.kt b/src/main/kotlin/org/exploit/keeper/service/auth/jettyx/KeeperJettyxAuthenticator.kt index 98ec4736..6c2f9bff 100644 --- a/src/main/kotlin/org/exploit/keeper/service/auth/jettyx/KeeperJettyxAuthenticator.kt +++ b/src/main/kotlin/org/exploit/keeper/service/auth/jettyx/KeeperJettyxAuthenticator.kt @@ -47,7 +47,12 @@ class KeeperJettyxAuthenticator( val actorAuth = if (paths.requiresActorAuth(path)) extractAuthToken() else null val token = actorAuth?.second ?: "" - val bodyHash64 = HashMethod.SHA256.process(readBody(request)).toBase64() + val body = readBody(request) + val bodyHash64 = try { + HashMethod.SHA256.process(body).toBase64() + } finally { + body.fill(0) + } var bootData: BootData? = null var requestHash: ByteArray? = null val signed = signer.get().signCurrent(db) { version -> diff --git a/src/main/kotlin/org/exploit/keeper/service/auth/jettyx/PeerSpkiHostnameVerifier.kt b/src/main/kotlin/org/exploit/keeper/service/auth/jettyx/PeerSpkiHostnameVerifier.kt new file mode 100644 index 00000000..08393f04 --- /dev/null +++ b/src/main/kotlin/org/exploit/keeper/service/auth/jettyx/PeerSpkiHostnameVerifier.kt @@ -0,0 +1,56 @@ +package org.exploit.keeper.service.auth.jettyx + +import org.exploit.keeper.config.keeper.KeeperPeerConfig +import org.exploit.keeper.model.internal.PeerTlsAddress +import org.exploit.keeper.util.extension.crypto.decodeBase64OrNull +import java.security.MessageDigest +import java.security.cert.X509Certificate +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.SSLPeerUnverifiedException +import javax.net.ssl.SSLSession + +internal class PeerSpkiHostnameVerifier( + private val pins: Map, +) : HostnameVerifier { + override fun verify(hostname: String, session: SSLSession): Boolean { + val certificate = try { + session.peerCertificates.firstOrNull() as? X509Certificate + } catch (_: SSLPeerUnverifiedException) { + null + } ?: return false + + val address = PeerTlsAddress(hostname.lowercase(), session.peerPort) + val expected = pins[address] ?: return false + val actual = MessageDigest.getInstance("SHA-256").digest(certificate.publicKey.encoded) + + return MessageDigest.isEqual(expected, actual) + } + + companion object { + fun from(peers: List): PeerSpkiHostnameVerifier? { + val configured = peers.mapNotNull { peer -> + peer.tlsSpkiSha256?.let { encoded -> + val pin = encoded.decodeBase64OrNull() + ?.takeIf { it.size == SHA256_BYTES } + ?: throw IllegalArgumentException( + "Peer ${peer.id} tls-spki-sha256 must be a base64-encoded SHA-256 digest" + ) + PeerTlsAddress.from(peer.internalUrl) to pin + } + } + if (configured.isEmpty()) return null + + require(configured.size == peers.size) { + "tls-spki-sha256 must be configured for every peer when certificate binding is enabled" + } + + require(configured.map { it.first }.distinct().size == configured.size) { + "Peer TLS endpoints must be unique when certificate binding is enabled" + } + return PeerSpkiHostnameVerifier(configured.toMap()) + } + + private const val SHA256_BYTES = 32 + } +} + diff --git a/src/main/kotlin/org/exploit/keeper/service/consistency/KeeperConsistencyService.kt b/src/main/kotlin/org/exploit/keeper/service/consistency/KeeperConsistencyService.kt index 6fa88059..2849eef0 100644 --- a/src/main/kotlin/org/exploit/keeper/service/consistency/KeeperConsistencyService.kt +++ b/src/main/kotlin/org/exploit/keeper/service/consistency/KeeperConsistencyService.kt @@ -32,20 +32,25 @@ class KeeperConsistencyService( fun check(keyId: String, clients: List): ConsistencyCheck { val versions = readVersions(keyId, clients) - val actives = versions.values.map { it.activeGen }.toSet() - val allMissing = actives.size == 1 && actives.first() == null + val allMissing = versions.values.all { it.activeGen == null && it.pendingGen == null } if (allMissing) { return ConsistencyCheck(ConsistencyVerdict.MISSING, versions = versions) } + val actives = versions.values.map { it.activeGen }.toSet() val maxSeen = maxSeenGen(versions) - val minActive = versions.values.mapNotNull { it.activeGen }.minOrNull() - val maxActive = versions.values.mapNotNull { it.activeGen }.maxOrNull() - - if (minActive != null && maxActive != null && minActive == maxActive) { + if (actives.size == 1 && actives.first() != null) { return ConsistencyCheck(ConsistencyVerdict.OK, versions = versions) } + if (versions.values.any { it.activeGen == null && it.pendingGen == null }) { + return ConsistencyCheck( + verdict = ConsistencyVerdict.RECOVER_NEEDED, + targetGeneration = maxSeen, + versions = versions + ) + } + val canSyncAll = versions.values.all { v -> v.activeGen == maxSeen || v.pendingGen == maxSeen } @@ -74,7 +79,8 @@ class KeeperConsistencyService( val chk = check(keyId, clients) when (chk.verdict) { - ConsistencyVerdict.MISSING -> return chk + ConsistencyVerdict.MISSING, + ConsistencyVerdict.RECOVER_NEEDED -> return chk ConsistencyVerdict.OK -> { val active = requireUniformActiveOrNull(chk.versions) ?: return chk @@ -186,6 +192,7 @@ class KeeperConsistencyService( val pendingRaw = db.vault.readPending(tx, keyId, generation) if (pendingRaw == null) { db.meta.dropPendingMeta(tx, keyId, generation) + handlers.forEach { it.onPendingMissing(tx, keyId, generation) } return false } @@ -206,8 +213,14 @@ class KeeperConsistencyService( } fun dropPending(tx: Transaction, keyId: String, generation: Int) { - val handlers = handlersFor(db.vault.readPending(tx, keyId, generation)) - cleanupPending(tx, keyId, generation, handlers) + val raw = db.vault.readPending(tx, keyId, generation) + if (raw == null) { + db.vault.deletePending(tx, keyId, generation) + db.meta.dropPendingMeta(tx, keyId, generation) + handlers.forEach { it.onPendingMissing(tx, keyId, generation) } + } else { + cleanupPending(tx, keyId, generation, handlersFor(raw)) + } } fun cleanupStalePendingAboveActive( diff --git a/src/main/kotlin/org/exploit/keeper/service/consistency/KeySyncHandler.kt b/src/main/kotlin/org/exploit/keeper/service/consistency/KeySyncHandler.kt index 600f7a30..8f087222 100644 --- a/src/main/kotlin/org/exploit/keeper/service/consistency/KeySyncHandler.kt +++ b/src/main/kotlin/org/exploit/keeper/service/consistency/KeySyncHandler.kt @@ -17,6 +17,8 @@ interface KeySyncHandler { fun onActiveRolledBack(tx: Transaction, keyId: String, generation: Int) {} + fun onRecoveryReset(tx: Transaction, keyId: String) {} + fun onPendingShareSaved( tx: Transaction, algorithm: KeyAlgorithmProvider, @@ -42,4 +44,4 @@ interface KeySyncHandler { generation: Int, seed: ByteArray ) {} -} \ 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 3c861b1e..bcacb798 100644 --- a/src/main/kotlin/org/exploit/keeper/service/control/KeyAuthorityControl.kt +++ b/src/main/kotlin/org/exploit/keeper/service/control/KeyAuthorityControl.kt @@ -7,6 +7,7 @@ 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 @@ -252,7 +253,7 @@ class KeyAuthorityControl( publicKeyMaterial.validateDistinct(keys, ErrorType.INVALID_AUTHORITY) } - private fun intentBuilder(type: String, errorType: ErrorType): IntentBuilder { + private fun intentBuilder(type: String, errorType: ErrorCode): IntentBuilder { val matches = intentBuilders.filter { it.type() == type } if (matches.size != 1) throw TKeeperException( diff --git a/src/main/kotlin/org/exploit/keeper/service/control/validator/PublicKeyMaterialValidator.kt b/src/main/kotlin/org/exploit/keeper/service/control/validator/PublicKeyMaterialValidator.kt index 1a0d6f5a..cf798cba 100644 --- a/src/main/kotlin/org/exploit/keeper/service/control/validator/PublicKeyMaterialValidator.kt +++ b/src/main/kotlin/org/exploit/keeper/service/control/validator/PublicKeyMaterialValidator.kt @@ -2,8 +2,8 @@ package org.exploit.keeper.service.control.validator import jakarta.inject.Singleton import org.exploit.crypto.spi.key.PublicKey -import org.exploit.keeper.constant.common.ErrorType import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.model.error.ErrorCode import org.exploit.keeper.model.keyset.control.ExternalPublicKey import org.exploit.keeper.service.key.KeyAlgorithmProvider import org.exploit.keeper.service.key.KeyAlgorithms @@ -17,7 +17,7 @@ class PublicKeyMaterialValidator( private val algorithms: KeyAlgorithms, private val schemes: SigSchemes ) { - fun validateDistinct(keys: List, errorType: ErrorType) { + fun validateDistinct(keys: List, errorType: ErrorCode) { withPublicKeys(keys, errorType) { publicKeys -> val fingerprints = publicKeys.map { (_, key) -> key.fingerprint().toBase64() } if (fingerprints.toSet().size != fingerprints.size) @@ -27,7 +27,7 @@ class PublicKeyMaterialValidator( fun withPublicKeys( keys: List, - errorType: ErrorType, + errorType: ErrorCode, block: (List>) -> T ): T { val publicKeys = ArrayList>(keys.size) diff --git a/src/main/kotlin/org/exploit/keeper/service/db/RocksKeyDB.kt b/src/main/kotlin/org/exploit/keeper/service/db/RocksKeyDB.kt index b6b205cc..fa35f843 100644 --- a/src/main/kotlin/org/exploit/keeper/service/db/RocksKeyDB.kt +++ b/src/main/kotlin/org/exploit/keeper/service/db/RocksKeyDB.kt @@ -6,6 +6,7 @@ import org.exploit.keeper.constant.common.ErrorType import org.exploit.keeper.constant.common.StoreState import org.exploit.keeper.exception.TKeeperException import org.exploit.keeper.model.api.incoming.KeeperInitData +import org.exploit.keeper.model.error.ErrorCode import org.exploit.keeper.model.internal.key.DeserializedKey import org.exploit.keeper.model.internal.key.DeserializedPrivateKey import org.exploit.keeper.model.internal.key.DeserializedPublicKey @@ -414,7 +415,7 @@ class RocksKeyDB(private val dbPath: String, signer: BeanProvider isCommitted(tx, logicalId, gen) } fun isCommitted(tx: Transaction, logicalId: String, gen: Int): Boolean { + return committedSessionId(tx, logicalId, gen) != null + } + + fun committedSessionId(tx: Transaction, logicalId: String, gen: Int): String? { val key = DESTROY_COMMITTED.format(logicalId, gen) - val raw = ops.getSigned(tx, key) ?: return false + val raw = ops.getSigned(tx, key) ?: return null val sessionId = String(raw, StandardCharsets.UTF_8) val session = loadSession(tx, sessionId) - ?: return true + ?: return sessionId if (session.state != DestroySessionState.COMMITTED || session.intent.keyId != logicalId || session.intent.generation != gen ) throw tampered(key, "destroy marker identity mismatch") - return true + return sessionId + } + + fun committedGenerations(tx: Transaction, logicalId: String): Set = buildSet { + val prefix = "__destroy_committed__${logicalId}__version__" + ops.newIterator(tx).use { iterator -> + iterator.seek(prefix.toByteArray(StandardCharsets.UTF_8)) + while (iterator.isValid) { + val recordId = iterator.key().toString(StandardCharsets.UTF_8) + if (!recordId.startsWith(prefix)) break + val suffix = recordId.removePrefix(prefix) + val generation = suffix.toIntOrNull() + ?.takeIf { it > 0 && suffix == it.toString() } + ?: throw tampered(recordId, "invalid destroy generation record") + if (!isCommitted(tx, logicalId, generation)) + throw tampered(recordId, "invalid destroy marker") + add(generation) + iterator.next() + } + } } fun markCommitted(logicalId: String, gen: Int, sessionId: String) = @@ -40,6 +63,28 @@ class DestroyStore(val ops: KeyStoreOps, val objectMapper: ObjectMapper): DataSt ) } + fun deleteCommitted(tx: Transaction, logicalId: String, gen: Int) { + ops.delete(tx, DESTROY_COMMITTED.format(logicalId, gen)) + } + + fun clearForRecovery(tx: Transaction, logicalId: String) { + val sessionRecords = ArrayList() + ops.newIterator(tx).use { iterator -> + iterator.seek(DESTROY_SESSION_PREFIX.toByteArray(StandardCharsets.UTF_8)) + while (iterator.isValid) { + val recordId = iterator.key().toString(StandardCharsets.UTF_8) + if (!recordId.startsWith(DESTROY_SESSION_PREFIX)) break + val sessionId = recordId.removePrefix(DESTROY_SESSION_PREFIX) + val session = runCatching { loadSession(tx, sessionId) }.getOrNull() + if (session?.intent?.keyId == logicalId) sessionRecords += recordId + iterator.next() + } + } + sessionRecords.forEach { ops.delete(tx, it) } + ops.deleteByPrefix(tx, "__destroy_committed__${logicalId}__version__") + ops.deleteByPrefix(tx, "__destroy_active__${logicalId}__version__") + } + fun saveSession(session: KeyDestroySession) = ops.runInTransaction { tx -> saveSession(tx, session) } @@ -152,6 +197,7 @@ class DestroyStore(val ops: KeyStoreOps, val objectMapper: ObjectMapper): DataSt private companion object { const val DESTROY_SESSION = "__destroy_session__%s" + const val DESTROY_SESSION_PREFIX = "__destroy_session__" const val DESTROY_ACTIVE = "__destroy_active__%s__version__%s" const val DESTROY_COMMITTED = "__destroy_committed__%s__version__%s" } diff --git a/src/main/kotlin/org/exploit/keeper/service/db/store/ExpireStore.kt b/src/main/kotlin/org/exploit/keeper/service/db/store/ExpireStore.kt index 29e046a8..79b1c9e9 100644 --- a/src/main/kotlin/org/exploit/keeper/service/db/store/ExpireStore.kt +++ b/src/main/kotlin/org/exploit/keeper/service/db/store/ExpireStore.kt @@ -24,6 +24,26 @@ class ExpireStore(private val ops: KeyStoreOps): DataStore { clear(tx, ExpireType.PROCESS, logicalId, gen) } + fun clearForRecovery(tx: Transaction, logicalId: String) { + val encodedId = encodeId(logicalId) + listOf(P_A, P_P).forEach { prefix -> + val keys = ArrayList() + ops.newIterator(tx).use { iterator -> + iterator.seek(prefix.toByteArray(StandardCharsets.UTF_8)) + while (iterator.isValid) { + val recordId = iterator.key().toString(StandardCharsets.UTF_8) + if (!recordId.startsWith(prefix)) break + val parts = recordId.removePrefix(prefix).split(':') + if (parts.size == 3 && parts[1] == encodedId) keys += recordId + iterator.next() + } + } + keys.forEach { ops.delete(tx, it) } + } + ops.deleteByPrefix(tx, "$P_REV_A$encodedId:") + ops.deleteByPrefix(tx, "$P_REV_P$encodedId:") + } + fun scan( type: ExpireType, fromEpochSec: Long = 0, @@ -142,4 +162,4 @@ class ExpireStore(private val ops: KeyStoreOps): DataStore { return true } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/org/exploit/keeper/service/db/store/KeyStoreOps.kt b/src/main/kotlin/org/exploit/keeper/service/db/store/KeyStoreOps.kt index 6e311f2b..ebd49cf3 100644 --- a/src/main/kotlin/org/exploit/keeper/service/db/store/KeyStoreOps.kt +++ b/src/main/kotlin/org/exploit/keeper/service/db/store/KeyStoreOps.kt @@ -188,6 +188,19 @@ class KeyStoreOps( tx.delete(cf, id.toByteArray()) } + fun deleteByPrefix(tx: Transaction, prefix: String) { + val prefixBytes = prefix.toByteArray(Charsets.UTF_8) + val keys = ArrayList() + newIterator(tx).use { iterator -> + iterator.seek(prefixBytes) + while (iterator.isValid && iterator.key().startsWith(prefixBytes)) { + keys += iterator.key().copyOf() + iterator.next() + } + } + keys.forEach { tx.delete(cf, it) } + } + private fun extractAndVerify(id: String, signed: ByteArray, tx: Transaction?): ByteArray { val payload = deserialize(id, signed) if (!verifySigned(id, payload, tx)) @@ -270,4 +283,9 @@ class KeyStoreOps( val data: ByteArray, val signed: Boolean ) + + private fun ByteArray.startsWith(prefix: ByteArray): Boolean { + if (size < prefix.size) return false + return prefix.indices.all { this[it] == prefix[it] } + } } diff --git a/src/main/kotlin/org/exploit/keeper/service/db/store/KeyVersionStore.kt b/src/main/kotlin/org/exploit/keeper/service/db/store/KeyVersionStore.kt index 774ac0d1..cd5dfa58 100644 --- a/src/main/kotlin/org/exploit/keeper/service/db/store/KeyVersionStore.kt +++ b/src/main/kotlin/org/exploit/keeper/service/db/store/KeyVersionStore.kt @@ -94,6 +94,18 @@ class KeyVersionStore(val ops: KeyStoreOps) : DataStore { fun maxActiveGeneration(tx: Transaction, logicalId: String): Int? = scanMaxGenerationNames(tx, ACTIVE_PREFIX.format(logicalId)) + fun activeGenerations(tx: Transaction, logicalId: String): List = + scanGenerationNames(tx, ACTIVE_PREFIX.format(logicalId)) + + fun pendingGenerations(tx: Transaction, logicalId: String): List = + scanGenerationNames(tx, PENDING_PREFIX.format(logicalId)) + + fun clearForRecovery(tx: Transaction, logicalId: String) { + ops.deleteByPrefix(tx, ACTIVE_PREFIX.format(logicalId)) + ops.deleteByPrefix(tx, PENDING_PREFIX.format(logicalId)) + clearCurrentGeneration(tx, logicalId) + } + private fun scanMaxGeneration(tx: Transaction, prefix: String, requireSigned: Boolean): Int? { val prefixBytes = prefix.toByteArray(StandardCharsets.UTF_8) var max: Int? = null @@ -139,6 +151,23 @@ class KeyVersionStore(val ops: KeyStoreOps) : DataStore { return max } + private fun scanGenerationNames(tx: Transaction, prefix: String): List { + val prefixBytes = prefix.toByteArray(StandardCharsets.UTF_8) + val generations = ArrayList() + + ops.newIterator(tx).use { iterator -> + iterator.seek(prefixBytes) + while (iterator.isValid) { + val key = iterator.key().toString(StandardCharsets.UTF_8) + if (!key.startsWith(prefix)) break + generations += parseGeneration(key, prefix) + iterator.next() + } + } + + return generations + } + private fun parseGeneration(key: String, prefix: String): Int { val suffix = key.substring(prefix.length) val generation = suffix.toIntOrNull() diff --git a/src/main/kotlin/org/exploit/keeper/service/db/store/LogicalKeyStore.kt b/src/main/kotlin/org/exploit/keeper/service/db/store/LogicalKeyStore.kt index 4497f94e..df3593b8 100644 --- a/src/main/kotlin/org/exploit/keeper/service/db/store/LogicalKeyStore.kt +++ b/src/main/kotlin/org/exploit/keeper/service/db/store/LogicalKeyStore.kt @@ -173,6 +173,13 @@ class LogicalKeyStore(val ops: KeyStoreOps, private val db: RocksKeyDB): DataSto versions.clearCurrentGeneration(tx, logicalId) } + fun clearForRecovery(tx: Transaction, logicalId: String) { + versions.clearForRecovery(tx, logicalId) + ops.deleteByPrefix(tx, "__logical_key__${logicalId}__version__") + ops.deleteByPrefix(tx, "__pending_logical_key__${logicalId}__version__") + ops.delete(tx, logicalId) + } + fun saveKey(logicalId: String, generation: Int, raw: ByteArray) = ops.runInTransaction { tx -> saveKey(tx, logicalId, generation, raw) } diff --git a/src/main/kotlin/org/exploit/keeper/service/db/store/MetaStore.kt b/src/main/kotlin/org/exploit/keeper/service/db/store/MetaStore.kt index e0be7525..54b88d34 100644 --- a/src/main/kotlin/org/exploit/keeper/service/db/store/MetaStore.kt +++ b/src/main/kotlin/org/exploit/keeper/service/db/store/MetaStore.kt @@ -222,6 +222,45 @@ class MetaStore(val ops: KeyStoreOps, private val db: RocksKeyDB, private val ob return readMeta(key, raw, logicalId, generation) } + fun loadAllVersionedMeta(tx: Transaction, logicalId: String): Map = buildMap { + val prefix = "$KEYSET_META_VERSION_PREFIX${logicalId}__version__" + ops.newIterator(tx).use { iterator -> + iterator.seek(prefix.toByteArray(Charsets.UTF_8)) + while (iterator.isValid) { + val recordId = iterator.key().toString(Charsets.UTF_8) + if (!recordId.startsWith(prefix)) break + val suffix = recordId.removePrefix(prefix) + val generation = suffix.toIntOrNull() + ?.takeIf { it > 0 && suffix == "%010d".format(it) } + ?: throw tampered(recordId, "invalid metadata generation record") + put(generation, loadVersionedMeta(tx, logicalId, generation) + ?: throw tampered(recordId, "missing metadata generation")) + iterator.next() + } + } + } + + fun pendingVersionedGenerations(tx: Transaction, logicalId: String): Set { + val prefix = "__pending_keyset_meta__${logicalId}__version__" + return scanGenerationRecords(tx, prefix) + } + + private fun scanGenerationRecords(tx: Transaction, prefix: String): Set = buildSet { + ops.newIterator(tx).use { iterator -> + iterator.seek(prefix.toByteArray(Charsets.UTF_8)) + while (iterator.isValid) { + val recordId = iterator.key().toString(Charsets.UTF_8) + if (!recordId.startsWith(prefix)) break + val suffix = recordId.removePrefix(prefix) + val generation = suffix.toIntOrNull() + ?.takeIf { it > 0 && suffix == "%010d".format(it) } + ?: throw tampered(recordId, "invalid metadata generation record") + add(generation) + iterator.next() + } + } + } + fun saveVersionedMeta(meta: KeySetMeta) = ops.runInTransaction { tx -> saveVersionedMeta(tx, meta) } @@ -230,6 +269,16 @@ class MetaStore(val ops: KeyStoreOps, private val db: RocksKeyDB, private val ob ops.saveSigned(tx, KEYSET_META_VERSION.format(meta.logicalId, meta.currentGeneration), bytes) } + fun clearForRecovery(tx: Transaction, logicalId: String) { + ops.delete(tx, KEYSET_META.format(logicalId)) + ops.delete(tx, PENDING_KEYSET_META.format(logicalId)) + ops.deleteByPrefix(tx, "$KEYSET_META_VERSION_PREFIX${logicalId}__version__") + ops.deleteByPrefix(tx, "__pending_keyset_meta__${logicalId}__version__") + ops.deleteByPrefix(tx, "__import_marker__${logicalId}__version__") + db.ownerIndex.clearForRecovery(tx, logicalId) + db.expire.clearForRecovery(tx, logicalId) + } + private fun deleteHead(logicalId: String) = ops.runInTransaction { tx -> deleteHead(tx, logicalId) } diff --git a/src/main/kotlin/org/exploit/keeper/service/db/store/OwnerIndexStore.kt b/src/main/kotlin/org/exploit/keeper/service/db/store/OwnerIndexStore.kt index 764ddb28..0c35ee24 100644 --- a/src/main/kotlin/org/exploit/keeper/service/db/store/OwnerIndexStore.kt +++ b/src/main/kotlin/org/exploit/keeper/service/db/store/OwnerIndexStore.kt @@ -43,6 +43,23 @@ class OwnerIndexStore(private val ops: KeyStoreOps): DataStore { ops.delete(tx, revK) } + fun clearForRecovery(tx: Transaction, logicalId: String) { + val prefix = P_IDX.toByteArray(StandardCharsets.UTF_8) + val keys = ArrayList() + ops.newIterator(tx).use { iterator -> + iterator.seek(prefix) + while (iterator.isValid) { + val recordId = iterator.key().toString(StandardCharsets.UTF_8) + if (!recordId.startsWith(P_IDX)) break + val indexedId = recordId.removePrefix(P_IDX).substringAfter(':', missingDelimiterValue = "") + if (indexedId == logicalId) keys += recordId + iterator.next() + } + } + keys.forEach { ops.delete(tx, it) } + ops.delete(tx, revKey(logicalId)) + } + fun listByOwner(owner: String, limit: Int = 200, cursor: String? = null): Page { val safeLimit = limit.coerceIn(1, 2000) val ownerEnc = encodeOwner(owner) diff --git a/src/main/kotlin/org/exploit/keeper/service/decorator/BeforeRequestDecoratorProvider.kt b/src/main/kotlin/org/exploit/keeper/service/decorator/BeforeRequestDecoratorProvider.kt new file mode 100644 index 00000000..7b6079c4 --- /dev/null +++ b/src/main/kotlin/org/exploit/keeper/service/decorator/BeforeRequestDecoratorProvider.kt @@ -0,0 +1,15 @@ +package org.exploit.keeper.service.decorator + +import com.linecorp.armeria.server.ServerBuilder +import org.exploit.keeper.constant.common.ServerType +import org.exploit.keeper.model.internal.transport.Codec + +interface BeforeRequestDecoratorProvider: Comparable { + fun priority(): Int = 0 + + fun addDecorator(serverType: ServerType, sb: ServerBuilder, codec: Codec) + + override fun compareTo(other: BeforeRequestDecoratorProvider): Int { + return priority().compareTo(other.priority()) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/exploit/keeper/service/exception/KeeperExceptionHandler.kt b/src/main/kotlin/org/exploit/keeper/service/exception/KeeperExceptionHandler.kt index 49d9523c..e63c3014 100644 --- a/src/main/kotlin/org/exploit/keeper/service/exception/KeeperExceptionHandler.kt +++ b/src/main/kotlin/org/exploit/keeper/service/exception/KeeperExceptionHandler.kt @@ -12,6 +12,7 @@ import org.exploit.keeper.constant.common.ErrorType import org.exploit.keeper.controller.support.ControllerSupport import org.exploit.keeper.exception.TKeeperException import org.exploit.keeper.model.approval.RequiredApprovalDetails +import org.exploit.keeper.model.error.ErrorCode import org.exploit.keeper.model.internal.transport.Codec import org.exploit.keeper.util.extension.toKeeperId import org.exploit.keeper.util.extension.toKeeperSet @@ -130,7 +131,7 @@ class KeeperExceptionHandler( } private fun auditedResponse( - errorType: ErrorType, + errorType: ErrorCode, details: String? = null, imposters: Set? = null, dead: Set? = null, @@ -142,7 +143,7 @@ class KeeperExceptionHandler( val audited = try { support.finishAndAudit( statusCode = errorType.statusCode, - error = errorType, + error = errorType.name, details = details, imposters = imposterIds, dead = deadIds, diff --git a/src/main/kotlin/org/exploit/keeper/util/extension/Future.kt b/src/main/kotlin/org/exploit/keeper/util/extension/Future.kt index 78d15555..ebb867fc 100644 --- a/src/main/kotlin/org/exploit/keeper/util/extension/Future.kt +++ b/src/main/kotlin/org/exploit/keeper/util/extension/Future.kt @@ -2,6 +2,7 @@ package org.exploit.keeper.util.extension import org.exploit.jettyx.model.HttpResponse import org.exploit.keeper.api.client.TKeeperClient +import org.exploit.keeper.model.api.SensitiveResponse import java.util.concurrent.CompletableFuture fun List>.wait(): List { @@ -18,6 +19,7 @@ fun CompletableFuture>.succeed(): CompletableFuture if (e != null) throw e + (response.body as? SensitiveResponse)?.destroy() error("Unexpected response: ${response.status}, ${response.rawResponse?.contentAsString}") } @@ -59,4 +61,4 @@ fun List.performAndReturnDead(action: (TKeeperClient) -> .toSet() return responseFromPeers to failed -} \ No newline at end of file +} diff --git a/src/main/kotlin/org/exploit/keeper/util/extension/Misc.kt b/src/main/kotlin/org/exploit/keeper/util/extension/Misc.kt index 8a496238..eba39597 100644 --- a/src/main/kotlin/org/exploit/keeper/util/extension/Misc.kt +++ b/src/main/kotlin/org/exploit/keeper/util/extension/Misc.kt @@ -1,7 +1,7 @@ package org.exploit.keeper.util.extension -import org.exploit.keeper.constant.common.ErrorType import org.exploit.keeper.exception.TKeeperException +import org.exploit.keeper.model.error.ErrorCode import org.exploit.keeper.util.KeySerializer import java.nio.ByteBuffer import java.util.function.Function @@ -25,17 +25,17 @@ fun Collection.toKeeperList() = map { it.toKeeperId() } fun Collection.toKeeperSet() = map { it.toKeeperId() }.toSet() -fun Collection.findOrThrow(e: ErrorType, predicate: (T) -> Boolean): T { +fun Collection.findOrThrow(e: ErrorCode, predicate: (T) -> Boolean): T { return firstOrNull(predicate) ?: throw TKeeperException(e, "Not supported for this operation") } -inline fun ensure(condition: Boolean, lazyMessage: () -> ErrorType) { +inline fun ensure(condition: Boolean, lazyMessage: () -> ErrorCode) { if (!condition) { throw TKeeperException(lazyMessage()) } } -inline fun ensure(condition: Boolean, error: ErrorType, message: () -> String) { +inline fun ensure(condition: Boolean, error: ErrorCode, message: () -> String) { if (!condition) { throw TKeeperException(error, message.invoke()) } diff --git a/src/main/kotlin/org/exploit/keeper/util/keygen/KeyGenGeneration.kt b/src/main/kotlin/org/exploit/keeper/util/keygen/KeyGenGeneration.kt index aa2bf7d1..595b3cb8 100644 --- a/src/main/kotlin/org/exploit/keeper/util/keygen/KeyGenGeneration.kt +++ b/src/main/kotlin/org/exploit/keeper/util/keygen/KeyGenGeneration.kt @@ -39,7 +39,9 @@ object KeyGenGeneration { KeyGenMode.ROTATE -> when (chk.verdict) { ConsistencyVerdict.MISSING -> throw TKeeperException(ErrorType.KEY_NOT_FOUND, keyId) ConsistencyVerdict.OK -> nextActiveOrThrow() - ConsistencyVerdict.SYNC_NEEDED -> throw TKeeperException(ErrorType.CONSISTENCY_CHECK_FAILED, keyId) + ConsistencyVerdict.SYNC_NEEDED, + ConsistencyVerdict.RECOVER_NEEDED -> + throw TKeeperException(ErrorType.RECOVER_NEEDED, "Recover needed for $keyId") ConsistencyVerdict.ROTATE_NEEDED -> throw TKeeperException( ErrorType.ROTATE_NEEDED, diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 75b15324..50aa5bc4 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -7,6 +7,9 @@ boot { } keeper { + recovery = false + recovery = ${?KEEPER_RECOVERY} + database { path = "data/keeper.db" path = ${?KEEPER_DATABASE_PATH} diff --git a/src/test/kotlin/org/exploit/keeper/tests/auth/PeerSpkiHostnameVerifierTest.kt b/src/test/kotlin/org/exploit/keeper/tests/auth/PeerSpkiHostnameVerifierTest.kt new file mode 100644 index 00000000..4539ed73 --- /dev/null +++ b/src/test/kotlin/org/exploit/keeper/tests/auth/PeerSpkiHostnameVerifierTest.kt @@ -0,0 +1,30 @@ +package org.exploit.keeper.tests.auth + +import org.exploit.keeper.config.keeper.KeeperPeerConfig +import org.exploit.keeper.model.internal.PeerTlsAddress +import org.exploit.keeper.service.auth.jettyx.PeerSpkiHostnameVerifier +import org.junit.jupiter.api.Test +import java.util.* +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class PeerSpkiHostnameVerifierTest { + @Test + fun `peer address binds host and effective TLS port`() { + assertEquals(PeerTlsAddress("keeper-1", 443), PeerTlsAddress.from("https://KEEPER-1")) + assertEquals(PeerTlsAddress("keeper-1", 9443), PeerTlsAddress.from("https://keeper-1:9443")) + } + + @Test + fun `pinning requires every configured peer`() { + val pin = Base64.getEncoder().encodeToString(ByteArray(32) { it.toByte() }) + val peers = listOf( + KeeperPeerConfig(1, "https://keeper-1:9443", pin), + KeeperPeerConfig(2, "https://keeper-2:9443"), + ) + + assertFailsWith { PeerSpkiHostnameVerifier.from(peers) } + assertNotNull(PeerSpkiHostnameVerifier.from(peers.map { it.copy(tlsSpkiSha256 = pin) })) + } +} diff --git a/src/test/kotlin/org/exploit/keeper/tests/http/SensitiveResponseCleanupTest.kt b/src/test/kotlin/org/exploit/keeper/tests/http/SensitiveResponseCleanupTest.kt new file mode 100644 index 00000000..f86746a1 --- /dev/null +++ b/src/test/kotlin/org/exploit/keeper/tests/http/SensitiveResponseCleanupTest.kt @@ -0,0 +1,42 @@ +package org.exploit.keeper.tests.http + +import org.eclipse.jetty.http.HttpFields +import org.exploit.jettyx.model.HttpResponse +import org.exploit.keeper.api.client.TKeeperClient.Companion.verifySensitiveResponse +import org.exploit.keeper.model.api.SensitiveResponse +import org.exploit.keeper.util.extension.succeed +import java.util.concurrent.CompletableFuture +import kotlin.test.Test +import kotlin.test.assertFails +import kotlin.test.assertTrue + +class SensitiveResponseCleanupTest { + @Test + fun `verification failure destroys decoded sensitive response`() { + val body = TestSensitiveResponse() + val response = HttpResponse(200, HttpFields.EMPTY, body, null, null) + + assertFails { verifySensitiveResponse(response) { error("invalid signature") } } + assertTrue(body.destroyed) + } + + @Test + fun `unexpected status destroys decoded sensitive response`() { + val body = TestSensitiveResponse() + val response = HttpResponse(500, HttpFields.EMPTY, body, null, null) + + assertFails { CompletableFuture.completedFuture(response).succeed().join() } + assertTrue(body.destroyed) + } + + private class TestSensitiveResponse : SensitiveResponse { + var destroyed = false + private set + + override fun destroy() { + destroyed = true + } + + override fun isDestroyed(): Boolean = destroyed + } +} diff --git a/src/test/kotlin/org/exploit/keeper/tests/model/KeeperInitDataValidationTest.kt b/src/test/kotlin/org/exploit/keeper/tests/model/KeeperInitDataValidationTest.kt index 59aa2888..0c1bab87 100644 --- a/src/test/kotlin/org/exploit/keeper/tests/model/KeeperInitDataValidationTest.kt +++ b/src/test/kotlin/org/exploit/keeper/tests/model/KeeperInitDataValidationTest.kt @@ -10,20 +10,20 @@ import kotlin.test.assertFailsWith class KeeperInitDataValidationTest { @Test fun `peer id cannot exceed total`() { - assertError(ErrorType.INVALID_PEER_ID) { + assertError(ErrorType.INVALID_PEER_ID.name) { KeeperInitData(peerId = 4, threshold = 2, total = 3).validate() } } @Test fun `quorum cannot exceed shamir field capacity`() { - assertError(ErrorType.INVALID_TOTAL) { + assertError(ErrorType.INVALID_TOTAL.name) { KeeperInitData(peerId = 1, threshold = 2, total = 256).validate() } } - private fun assertError(expected: ErrorType, block: () -> Unit) { + private fun assertError(expected: String, block: () -> Unit) { val error = assertFailsWith { block() } - assertEquals(expected, error.errorType) + assertEquals(expected, error.errorType.name) } }