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