Merge hanzo/mpc additions: ConsensusKV, HSM providers, policy engine, rename CLI to mpc-cli

- pkg/infra: Add abstract KV interface, ConsensusKV (dual-cert BFT with
  Ed25519 + ML-DSA-65), NatsKV, ConsulKVAdapter, HybridKV, SQLiteMeta
  with full test coverage
- pkg/hsm: Add Provider interface and implementations for AWS KMS,
  GCP Cloud KMS, Azure Key Vault, Zymbit HSM, Hanzo KMS, and file-based
  providers with registry and tests. Rename existing FileProvider to
  FilePasswordProvider to avoid collision with new Provider interface
- pkg/policy: Add transaction governance engine with signers, roles,
  spending limits, whitelist/blacklist, time windows, rate limiting,
  approval workflow, audit log, and FHE private policy evaluation
- contracts: Add ThresholdPolicy.sol for on-chain policy enforcement
- cmd: Rename lux-mpc-cli to mpc-cli
- Makefile: Update build targets for mpc-cli
This commit is contained in:
2025-12-28 03:12:38 -08:00
parent 4d7bbbed51
commit dd35222450
38 changed files with 6692 additions and 33 deletions
+5 -5
View File
@@ -1,4 +1,4 @@
.PHONY: all build clean mpcd lux-mpc-cli test test-verbose test-coverage e2e-test e2e-clean cleanup-test-env test-all clean-all up down logs
.PHONY: all build clean mpcd mpc-cli test test-verbose test-coverage e2e-test e2e-clean cleanup-test-env test-all clean-all up down logs
# Go 1.26 experimental features:
# runtimesecret - zeroes stack/register state after secret.Do() for forward secrecy
@@ -7,13 +7,13 @@ GOEXPERIMENT ?= runtimesecret,simd
all: build
build: mpcd lux-mpc-cli
build: mpcd mpc-cli
mpcd:
GOWORK=off GOEXPERIMENT=$(GOEXPERIMENT) go build -o mpcd ./cmd/mpcd
lux-mpc-cli:
GOWORK=off GOEXPERIMENT=$(GOEXPERIMENT) go build -o lux-mpc-cli ./cmd/lux-mpc-cli
mpc-cli:
GOWORK=off GOEXPERIMENT=$(GOEXPERIMENT) go build -o mpc-cli ./cmd/mpc-cli
# Run all tests (json1 enables SQLite JSON functions for ORM JSONB queries)
test:
@@ -57,7 +57,7 @@ logs:
# Clean
clean:
rm -f mpcd lux-mpc-cli
rm -f mpcd mpc-cli
rm -f coverage.out coverage.html
clean-all: clean e2e-clean
@@ -16,8 +16,8 @@ const (
func main() {
cmd := &cli.Command{
Name: "lux-mpc-cli",
Usage: "Lux MPC node management tools",
Name: "mpc-cli",
Usage: "MPC node management tools",
Commands: []*cli.Command{
{
Name: "generate-peers",
@@ -154,7 +154,7 @@ func main() {
Name: "version",
Usage: "Display detailed version information",
Action: func(ctx context.Context, c *cli.Command) error {
fmt.Printf("lux-mpc-cli version %s\n", VERSION)
fmt.Printf("mpc-cli version %s\n", VERSION)
return nil
},
},
+597
View File
@@ -0,0 +1,597 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
/**
* @title ThresholdPolicy
* @notice On-chain policy definition for MPC threshold signing.
* @dev Policies defined here are enforced by the T-Chain (MPC network).
*
* Architecture:
* X-Chain: Assets locked with policy hash
* T-Chain: MPC nodes verify policy before signing
* This contract: Defines unlock conditions
*/
contract ThresholdPolicy is AccessControl {
using ECDSA for bytes32;
using MessageHashUtils for bytes32;
// ============================================================
// ROLES
// ============================================================
bytes32 public constant POLICY_ADMIN_ROLE = keccak256("POLICY_ADMIN_ROLE");
bytes32 public constant SIGNER_ROLE = keccak256("SIGNER_ROLE");
// ============================================================
// TYPES
// ============================================================
// Rule opcodes (must match pkg/threshold/policy_vm.go)
uint8 public constant OP_CHECK_AMOUNT_LT = 0x01;
uint8 public constant OP_CHECK_AMOUNT_GT = 0x02;
uint8 public constant OP_CHECK_AMOUNT_RANGE = 0x03;
uint8 public constant OP_CHECK_CUMULATIVE = 0x04;
uint8 public constant OP_CHECK_WHITELIST = 0x10;
uint8 public constant OP_CHECK_BLACKLIST = 0x11;
uint8 public constant OP_CHECK_TIME_WINDOW = 0x20;
uint8 public constant OP_CHECK_TIME_LOCK = 0x21;
uint8 public constant OP_CHECK_COOLDOWN = 0x22;
uint8 public constant OP_REQUIRE_SIGNATURES = 0x30;
uint8 public constant OP_CHECK_VESTING = 0x40;
uint8 public constant OP_CHECK_STREAM_RATE = 0x41;
// Result actions
uint8 public constant RESULT_ALLOW = 0x00;
uint8 public constant RESULT_DENY = 0x01;
uint8 public constant RESULT_REQUIRE_SIGS = 0x02;
uint8 public constant RESULT_DELAY = 0x03;
uint8 public constant RESULT_PARTIAL_UNLOCK = 0x04;
struct Rule {
bytes8 ruleId;
uint8 opcode;
bytes[] operands;
uint8 resultAction;
bool enabled;
}
struct Policy {
bytes32 walletId;
uint64 chainId;
uint64 version;
Rule[] rules;
address[] signers;
uint256 requiredSignatures;
uint256 createdAt;
uint256 expiresAt;
bool active;
}
struct VestingSchedule {
uint256 totalAmount;
uint256 startTime;
uint256 duration;
uint256 cliffDuration;
uint256 released;
}
struct StreamConfig {
uint256 ratePerSecond;
uint256 startTime;
uint256 totalStreamed;
}
// ============================================================
// STATE
// ============================================================
// walletId => Policy
mapping(bytes32 => Policy) public policies;
// walletId => vesting schedule
mapping(bytes32 => VestingSchedule) public vestingSchedules;
// walletId => streaming config
mapping(bytes32 => StreamConfig) public streamConfigs;
// walletId => destination => whitelisted
mapping(bytes32 => mapping(address => bool)) public whitelists;
// walletId => destination => blacklisted
mapping(bytes32 => mapping(address => bool)) public blacklists;
// walletId => daily cumulative amount
mapping(bytes32 => uint256) public dailyCumulative;
mapping(bytes32 => uint256) public dailyCumulativeReset;
// walletId => last transaction timestamp
mapping(bytes32 => uint256) public lastTxTimestamp;
// ============================================================
// EVENTS
// ============================================================
event PolicyRegistered(
bytes32 indexed walletId,
bytes32 indexed policyHash,
uint64 version,
address indexed registrar
);
event PolicyUpdated(
bytes32 indexed walletId,
bytes32 indexed newPolicyHash,
uint64 newVersion
);
event PolicyDeactivated(bytes32 indexed walletId);
event RuleAdded(
bytes32 indexed walletId,
bytes8 ruleId,
uint8 opcode,
uint8 resultAction
);
event SignerAdded(bytes32 indexed walletId, address indexed signer);
event SignerRemoved(bytes32 indexed walletId, address indexed signer);
event WhitelistUpdated(bytes32 indexed walletId, address indexed destination, bool whitelisted);
event BlacklistUpdated(bytes32 indexed walletId, address indexed destination, bool blacklisted);
event VestingConfigured(
bytes32 indexed walletId,
uint256 totalAmount,
uint256 startTime,
uint256 duration,
uint256 cliffDuration
);
event StreamConfigured(
bytes32 indexed walletId,
uint256 ratePerSecond,
uint256 startTime
);
// ============================================================
// CONSTRUCTOR
// ============================================================
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(POLICY_ADMIN_ROLE, msg.sender);
}
// ============================================================
// POLICY MANAGEMENT
// ============================================================
/**
* @notice Register a new policy for a wallet
* @param walletId The MPC wallet identifier
* @param signers Array of authorized signers
* @param requiredSignatures Number of signatures required
* @param expiresAt Policy expiration timestamp (0 = never)
*/
function registerPolicy(
bytes32 walletId,
address[] calldata signers,
uint256 requiredSignatures,
uint256 expiresAt
) external onlyRole(POLICY_ADMIN_ROLE) {
require(signers.length >= requiredSignatures, "Invalid threshold");
require(!policies[walletId].active, "Policy exists");
Policy storage policy = policies[walletId];
policy.walletId = walletId;
policy.chainId = uint64(block.chainid);
policy.version = 1;
policy.signers = signers;
policy.requiredSignatures = requiredSignatures;
policy.createdAt = block.timestamp;
policy.expiresAt = expiresAt;
policy.active = true;
bytes32 policyHash = computePolicyHash(walletId);
emit PolicyRegistered(walletId, policyHash, 1, msg.sender);
}
/**
* @notice Add a rule to a policy
*/
function addRule(
bytes32 walletId,
bytes8 ruleId,
uint8 opcode,
bytes[] calldata operands,
uint8 resultAction
) external onlyRole(POLICY_ADMIN_ROLE) {
require(policies[walletId].active, "Policy not active");
Policy storage policy = policies[walletId];
policy.rules.push(Rule({
ruleId: ruleId,
opcode: opcode,
operands: operands,
resultAction: resultAction,
enabled: true
}));
policy.version++;
emit RuleAdded(walletId, ruleId, opcode, resultAction);
emit PolicyUpdated(walletId, computePolicyHash(walletId), policy.version);
}
/**
* @notice Configure amount limit rule
*/
function setAmountLimit(
bytes32 walletId,
uint256 maxAmount,
uint8 resultAction
) external onlyRole(POLICY_ADMIN_ROLE) {
bytes[] memory operands = new bytes[](1);
operands[0] = abi.encodePacked(maxAmount);
Policy storage policy = policies[walletId];
policy.rules.push(Rule({
ruleId: bytes8(keccak256(abi.encodePacked("AMOUNT_LIMIT", block.timestamp))),
opcode: OP_CHECK_AMOUNT_GT,
operands: operands,
resultAction: resultAction,
enabled: true
}));
policy.version++;
}
/**
* @notice Configure daily cumulative limit
*/
function setDailyLimit(
bytes32 walletId,
uint256 dailyLimit,
uint8 resultAction
) external onlyRole(POLICY_ADMIN_ROLE) {
bytes[] memory operands = new bytes[](1);
operands[0] = abi.encodePacked(dailyLimit);
Policy storage policy = policies[walletId];
policy.rules.push(Rule({
ruleId: bytes8(keccak256(abi.encodePacked("DAILY_LIMIT", block.timestamp))),
opcode: OP_CHECK_CUMULATIVE,
operands: operands,
resultAction: resultAction,
enabled: true
}));
policy.version++;
}
/**
* @notice Configure time lock (no transactions before unlock time)
*/
function setTimeLock(
bytes32 walletId,
uint256 unlockTime,
uint8 resultAction
) external onlyRole(POLICY_ADMIN_ROLE) {
bytes[] memory operands = new bytes[](1);
operands[0] = abi.encodePacked(uint64(unlockTime));
Policy storage policy = policies[walletId];
policy.rules.push(Rule({
ruleId: bytes8(keccak256(abi.encodePacked("TIME_LOCK", block.timestamp))),
opcode: OP_CHECK_TIME_LOCK,
operands: operands,
resultAction: resultAction,
enabled: true
}));
policy.version++;
}
/**
* @notice Configure cooldown period between transactions
*/
function setCooldown(
bytes32 walletId,
uint256 cooldownSeconds,
uint8 resultAction
) external onlyRole(POLICY_ADMIN_ROLE) {
bytes[] memory operands = new bytes[](1);
operands[0] = abi.encodePacked(uint64(cooldownSeconds));
Policy storage policy = policies[walletId];
policy.rules.push(Rule({
ruleId: bytes8(keccak256(abi.encodePacked("COOLDOWN", block.timestamp))),
opcode: OP_CHECK_COOLDOWN,
operands: operands,
resultAction: resultAction,
enabled: true
}));
policy.version++;
}
// ============================================================
// VESTING & STREAMING
// ============================================================
/**
* @notice Configure vesting schedule for a wallet
*/
function configureVesting(
bytes32 walletId,
uint256 totalAmount,
uint256 startTime,
uint256 duration,
uint256 cliffDuration
) external onlyRole(POLICY_ADMIN_ROLE) {
require(policies[walletId].active, "Policy not active");
require(duration > 0, "Duration must be > 0");
require(cliffDuration <= duration, "Cliff > duration");
vestingSchedules[walletId] = VestingSchedule({
totalAmount: totalAmount,
startTime: startTime,
duration: duration,
cliffDuration: cliffDuration,
released: 0
});
// Add vesting rule
bytes[] memory operands = new bytes[](4);
operands[0] = abi.encodePacked(totalAmount);
operands[1] = abi.encodePacked(uint64(startTime));
operands[2] = abi.encodePacked(uint64(duration));
operands[3] = abi.encodePacked(uint64(cliffDuration));
Policy storage policy = policies[walletId];
policy.rules.push(Rule({
ruleId: bytes8(keccak256(abi.encodePacked("VESTING", block.timestamp))),
opcode: OP_CHECK_VESTING,
operands: operands,
resultAction: RESULT_PARTIAL_UNLOCK,
enabled: true
}));
policy.version++;
emit VestingConfigured(walletId, totalAmount, startTime, duration, cliffDuration);
}
/**
* @notice Configure streaming payments
*/
function configureStream(
bytes32 walletId,
uint256 ratePerSecond,
uint256 startTime
) external onlyRole(POLICY_ADMIN_ROLE) {
require(policies[walletId].active, "Policy not active");
require(ratePerSecond > 0, "Rate must be > 0");
streamConfigs[walletId] = StreamConfig({
ratePerSecond: ratePerSecond,
startTime: startTime,
totalStreamed: 0
});
// Add streaming rule
bytes[] memory operands = new bytes[](2);
operands[0] = abi.encodePacked(ratePerSecond);
operands[1] = abi.encodePacked(uint64(startTime));
Policy storage policy = policies[walletId];
policy.rules.push(Rule({
ruleId: bytes8(keccak256(abi.encodePacked("STREAM", block.timestamp))),
opcode: OP_CHECK_STREAM_RATE,
operands: operands,
resultAction: RESULT_PARTIAL_UNLOCK,
enabled: true
}));
policy.version++;
emit StreamConfigured(walletId, ratePerSecond, startTime);
}
/**
* @notice Calculate vested amount available
*/
function vestedAmount(bytes32 walletId) public view returns (uint256) {
VestingSchedule storage schedule = vestingSchedules[walletId];
if (schedule.totalAmount == 0) return 0;
if (block.timestamp < schedule.startTime + schedule.cliffDuration) return 0;
uint256 elapsed = block.timestamp - schedule.startTime;
if (elapsed >= schedule.duration) {
return schedule.totalAmount - schedule.released;
}
uint256 vested = (schedule.totalAmount * elapsed) / schedule.duration;
return vested - schedule.released;
}
/**
* @notice Calculate streamable amount available
*/
function streamableAmount(bytes32 walletId) public view returns (uint256) {
StreamConfig storage config = streamConfigs[walletId];
if (config.ratePerSecond == 0) return 0;
if (block.timestamp < config.startTime) return 0;
uint256 elapsed = block.timestamp - config.startTime;
uint256 total = config.ratePerSecond * elapsed;
return total - config.totalStreamed;
}
// ============================================================
// WHITELIST / BLACKLIST
// ============================================================
function addToWhitelist(bytes32 walletId, address destination) external onlyRole(POLICY_ADMIN_ROLE) {
whitelists[walletId][destination] = true;
emit WhitelistUpdated(walletId, destination, true);
}
function removeFromWhitelist(bytes32 walletId, address destination) external onlyRole(POLICY_ADMIN_ROLE) {
whitelists[walletId][destination] = false;
emit WhitelistUpdated(walletId, destination, false);
}
function addToBlacklist(bytes32 walletId, address destination) external onlyRole(POLICY_ADMIN_ROLE) {
blacklists[walletId][destination] = true;
emit BlacklistUpdated(walletId, destination, true);
}
function removeFromBlacklist(bytes32 walletId, address destination) external onlyRole(POLICY_ADMIN_ROLE) {
blacklists[walletId][destination] = false;
emit BlacklistUpdated(walletId, destination, false);
}
// ============================================================
// SIGNER MANAGEMENT
// ============================================================
function addSigner(bytes32 walletId, address signer) external onlyRole(POLICY_ADMIN_ROLE) {
require(policies[walletId].active, "Policy not active");
policies[walletId].signers.push(signer);
policies[walletId].version++;
emit SignerAdded(walletId, signer);
}
function removeSigner(bytes32 walletId, address signer) external onlyRole(POLICY_ADMIN_ROLE) {
require(policies[walletId].active, "Policy not active");
Policy storage policy = policies[walletId];
for (uint i = 0; i < policy.signers.length; i++) {
if (policy.signers[i] == signer) {
policy.signers[i] = policy.signers[policy.signers.length - 1];
policy.signers.pop();
policy.version++;
emit SignerRemoved(walletId, signer);
return;
}
}
revert("Signer not found");
}
function setRequiredSignatures(bytes32 walletId, uint256 required) external onlyRole(POLICY_ADMIN_ROLE) {
require(policies[walletId].active, "Policy not active");
require(required <= policies[walletId].signers.length, "Invalid threshold");
policies[walletId].requiredSignatures = required;
policies[walletId].version++;
}
// ============================================================
// VERIFICATION (Called by MPC nodes)
// ============================================================
/**
* @notice Compute policy hash for verification
*/
function computePolicyHash(bytes32 walletId) public view returns (bytes32) {
Policy storage policy = policies[walletId];
bytes memory encoded = abi.encodePacked(
walletId,
policy.chainId,
policy.version
);
for (uint i = 0; i < policy.rules.length; i++) {
Rule storage rule = policy.rules[i];
if (rule.enabled) {
encoded = abi.encodePacked(
encoded,
rule.ruleId,
rule.opcode,
rule.resultAction
);
}
}
return keccak256(encoded);
}
/**
* @notice Get policy data for MPC verification
*/
function getPolicyData(bytes32 walletId) external view returns (
bytes32 policyHash,
uint64 version,
address[] memory signers,
uint256 requiredSignatures,
uint256 expiresAt,
bool active
) {
Policy storage policy = policies[walletId];
return (
computePolicyHash(walletId),
policy.version,
policy.signers,
policy.requiredSignatures,
policy.expiresAt,
policy.active
);
}
/**
* @notice Get all rules for a policy
*/
function getRules(bytes32 walletId) external view returns (Rule[] memory) {
return policies[walletId].rules;
}
/**
* @notice Check if address is whitelisted
*/
function isWhitelisted(bytes32 walletId, address destination) external view returns (bool) {
return whitelists[walletId][destination];
}
/**
* @notice Check if address is blacklisted
*/
function isBlacklisted(bytes32 walletId, address destination) external view returns (bool) {
return blacklists[walletId][destination];
}
// ============================================================
// STATE UPDATES (Called after MPC signing)
// ============================================================
/**
* @notice Record a transaction (called by bridge after successful signing)
*/
function recordTransaction(
bytes32 walletId,
uint256 amount,
address destination
) external onlyRole(SIGNER_ROLE) {
// Reset daily cumulative if new day
if (block.timestamp / 1 days > dailyCumulativeReset[walletId]) {
dailyCumulative[walletId] = 0;
dailyCumulativeReset[walletId] = block.timestamp / 1 days;
}
// Update cumulative
dailyCumulative[walletId] += amount;
// Update last tx timestamp
lastTxTimestamp[walletId] = block.timestamp;
// Update vesting released
VestingSchedule storage vesting = vestingSchedules[walletId];
if (vesting.totalAmount > 0) {
vesting.released += amount;
}
// Update streaming
StreamConfig storage stream = streamConfigs[walletId];
if (stream.ratePerSecond > 0) {
stream.totalStreamed += amount;
}
}
}
+46 -5
View File
@@ -3,8 +3,18 @@ module github.com/luxfi/mpc
go 1.26.1
require (
cloud.google.com/go/kms v1.27.0
cloud.google.com/go/secretmanager v1.18.0
filippo.io/age v1.3.1
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0
github.com/avast/retry-go v3.0.0+incompatible
github.com/aws/aws-sdk-go-v2/config v1.32.7
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
github.com/aws/aws-sdk-go-v2/service/kms v1.50.4
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6
github.com/cronokirby/saferith v0.33.0
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.4
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
@@ -15,6 +25,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gtank/ristretto255 v0.2.0
github.com/hanzoai/base v0.39.1
github.com/hanzoai/dbx v1.13.0
github.com/hanzoai/kv-go/v9 v9.18.0
github.com/hanzoai/orm v0.3.2
github.com/hashicorp/consul/api v1.33.7
@@ -37,17 +48,40 @@ require (
github.com/urfave/cli/v3 v3.8.0
golang.org/x/crypto v0.49.0
golang.org/x/term v0.41.0
google.golang.org/api v0.275.0
modernc.org/sqlite v1.48.0
)
require (
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.7.0 // indirect
cloud.google.com/go/longrunning v0.9.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
filippo.io/hpke v0.4.0 // indirect
github.com/ALTree/bigfloat v0.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/ChainSafe/go-schnorrkel v1.1.0 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260311194731-d5b7577c683d // indirect
github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
@@ -62,6 +96,7 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
@@ -74,12 +109,14 @@ require (
github.com/golang/mock v1.6.0 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
github.com/gorilla/rpc v1.2.1 // indirect
github.com/grandcat/zeroconf v1.0.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/gtank/merlin v0.1.1 // indirect
github.com/hanzoai/dbx v1.13.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
@@ -99,6 +136,7 @@ require (
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/luxfi/atomic v1.0.0 // indirect
github.com/luxfi/cache v1.2.1 // indirect
github.com/luxfi/codec v1.1.4 // indirect
@@ -133,6 +171,7 @@ require (
github.com/onsi/ginkgo/v2 v2.28.1 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
@@ -154,9 +193,11 @@ require (
github.com/x448/float16 v0.8.4 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.temporal.io/api v1.62.6 // indirect
go.temporal.io/sdk v1.41.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
@@ -174,6 +215,7 @@ require (
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.43.0 // indirect
gonum.org/v1/gonum v0.17.0 // indirect
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
@@ -183,7 +225,6 @@ require (
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.0 // indirect
)
// agl/ed25519 has no packages at the upstream module path; redirect to our fork.
+110 -11
View File
@@ -1,6 +1,22 @@
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U=
cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY=
cloud.google.com/go/kms v1.27.0 h1:iYYgoD0HJIqz35A+He1G0dS5qTQzQsDXFsyXwzkUCXM=
cloud.google.com/go/kms v1.27.0/go.mod h1:KPxrdf61iYEOZ86uPwR86muBpSik2y4Ion6e83fVl1Q=
cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY=
cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E=
cloud.google.com/go/secretmanager v1.18.0 h1:VA/ynUUapUF3+xrm0R1dMx8i21p2jfRAWFpokYPncKU=
cloud.google.com/go/secretmanager v1.18.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU=
filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0=
filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
@@ -9,6 +25,24 @@ filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/ALTree/bigfloat v0.2.0 h1:AwNzawrpFuw55/YDVlcPw0F0cmmXrmngBHhVrvdXPvM=
github.com/ALTree/bigfloat v0.2.0/go.mod h1:+NaH2gLeY6RPBPPQf4aRotPPStg+eXc8f9ZaE4vRfD4=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 h1:/g8S6wk65vfC6m3FIxJ+i5QDyN9JWwXI8Hb0Img10hU=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0/go.mod h1:gpl+q95AzZlKVI3xSoseF9QPrypk0hQqBiJYeB/cR/I=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/ChainSafe/go-schnorrkel v1.1.0 h1:rZ6EU+CZFCjB4sHUE1jIu8VDoB/wRKZxoe1tkcO71Wk=
github.com/ChainSafe/go-schnorrkel v1.1.0/go.mod h1:ABkENxiP+cvjFiByMIZ9LYbRoNNLeBLiakC1XeTFxfE=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
@@ -28,6 +62,38 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/kms v1.50.4 h1:PgD1y0ZagPokGIZPmejCBUySBzOFDN+leZxCOfb1OEQ=
github.com/aws/aws-sdk-go-v2/service/kms v1.50.4/go.mod h1:FfXDb5nXrsoGgxsBFxwxr3vdHXheC2tV+6lmuLghhjQ=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 h1:z2ayoK3pOvf8ODj/vPR0FgAS5ONruBq0F94SRoW/BIU=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5/go.mod h1:mpZB5HAl4ZIISod9qCi12xZ170TbHX9CCJV5y7nb7QU=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -48,6 +114,8 @@ github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6D
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/cosmos/go-bip39 v0.0.0-20180819234021-555e2067c45d h1:49RLWk1j44Xu4fjHb6JFYmeUnDORVwHNkDxaQ0ctCVU=
github.com/cosmos/go-bip39 v0.0.0-20180819234021-555e2067c45d/go.mod h1:tSxLoYXyBmiFeKpvmq4dzayMdCjCnu8uqmCysIGBT2Y=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -77,11 +145,18 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw=
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -151,8 +226,14 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/gorilla/rpc v1.2.1 h1:yC+LMV5esttgpVvNORL/xX4jvTTEUE30UZhZ5JF7K9k=
github.com/gorilla/rpc v1.2.1/go.mod h1:uNpOihAlF5xRFLuTYhfR0yfCTm0WTQSQttkMSptRfGk=
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
@@ -233,6 +314,8 @@ github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
@@ -252,6 +335,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/luxfi/atomic v1.0.0 h1:xUV60MuzRvXngaQ1sM0yVC2v4TRoLlUGkkH7M9PS4yw=
github.com/luxfi/atomic v1.0.0/go.mod h1:0G2mTlQ6TXWHICUHrUUPu1/qAiIyR4gSZ2tva9ci/bI=
github.com/luxfi/cache v1.2.1 h1:kAzOS55/hmYeNKR+0HAKv4ma48Y6JjkI8UQeqdZ8bfI=
@@ -361,10 +446,14 @@ github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf4
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -461,16 +550,20 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.temporal.io/api v1.62.6 h1:JLH8y9URdY9WbdvwMXfGknlhohoPBrXOc9AbQkPInOc=
go.temporal.io/api v1.62.6/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM=
go.temporal.io/sdk v1.41.1 h1:yOpvsHyDD1lNuwlGBv/SUodCPhjv9nDeC9lLHW/fJUA=
@@ -551,6 +644,7 @@ golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
@@ -578,9 +672,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI=
google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d h1:N1Ec54vZnIPd7MnxRiYLW+oY4fDR4BOS/LrssdD9+ek=
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:c2hJ1grtnH0xUiEKGDGkjGNTJ1Hy2LrblyKOHF0sqRM=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
+241
View File
@@ -0,0 +1,241 @@
package hsm
import (
"context"
"crypto/ed25519"
"encoding/hex"
"fmt"
"sync"
awscfg "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
"github.com/aws/aws-sdk-go-v2/service/kms"
kmstypes "github.com/aws/aws-sdk-go-v2/service/kms/types"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
"github.com/aws/aws-sdk-go-v2/service/sts"
"github.com/luxfi/mpc/pkg/logger"
)
// AWSProvider implements Provider using AWS KMS for signing and
// Secrets Manager for Ed25519 seed storage.
//
// Two usage modes:
// 1. KMS-native keys (key_arn set): Sign delegates to KMS. GetKey returns the ARN.
// Keys never leave the HSM boundary.
// 2. Secrets Manager keys (no key_arn): Ed25519 seeds stored as hex in Secrets Manager.
// GetKey returns the raw seed. Sign loads the seed and signs locally.
type AWSProvider struct {
region string
keyARN string
kmsClient *kms.Client
smClient *secretsmanager.Client
mu sync.RWMutex
}
// NewAWSProvider creates an AWS KMS / Secrets Manager provider.
func NewAWSProvider(cfg *AWSConfig) (*AWSProvider, error) {
if cfg == nil {
return nil, fmt.Errorf("hsm/aws: config is nil")
}
region := cfg.Region
if region == "" {
region = "us-east-1"
}
// Build AWS config with optional profile.
opts := []func(*awscfg.LoadOptions) error{
awscfg.WithRegion(region),
}
if cfg.Profile != "" {
opts = append(opts, awscfg.WithSharedConfigProfile(cfg.Profile))
}
awsCfg, err := awscfg.LoadDefaultConfig(context.Background(), opts...)
if err != nil {
return nil, fmt.Errorf("hsm/aws: load config: %w", err)
}
// Assume role if configured.
if cfg.RoleARN != "" {
stsClient := sts.NewFromConfig(awsCfg)
awsCfg.Credentials = stscreds.NewAssumeRoleProvider(stsClient, cfg.RoleARN)
}
kmsOpts := []func(*kms.Options){}
if cfg.CustomKeyStore != "" {
// CloudHSM-backed custom key store uses the same KMS API.
logger.Info("hsm/aws: using CloudHSM custom key store", "id", cfg.CustomKeyStore)
}
p := &AWSProvider{
region: region,
keyARN: cfg.KeyARN,
kmsClient: kms.NewFromConfig(awsCfg, kmsOpts...),
smClient: secretsmanager.NewFromConfig(awsCfg),
}
logger.Info("hsm/aws: initialized", "region", region, "keyARN", cfg.KeyARN)
return p, nil
}
func (a *AWSProvider) Name() string { return "aws" }
// GetKey retrieves a key by ID.
// If a KMS key ARN is configured, returns the ARN bytes (the private key never leaves KMS).
// Otherwise, fetches the Ed25519 seed from Secrets Manager.
func (a *AWSProvider) GetKey(ctx context.Context, keyID string) ([]byte, error) {
if a.keyARN != "" {
// KMS-native mode: return the ARN as the "key material".
return []byte(a.keyARN), nil
}
// Secrets Manager mode: fetch hex-encoded Ed25519 seed.
out, err := a.smClient.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
SecretId: &keyID,
})
if err != nil {
return nil, fmt.Errorf("hsm/aws: get secret %q: %w", keyID, err)
}
if out.SecretString == nil {
return nil, fmt.Errorf("hsm/aws: secret %q has no string value", keyID)
}
seed, err := hex.DecodeString(*out.SecretString)
if err != nil {
return nil, fmt.Errorf("hsm/aws: hex decode secret %q: %w", keyID, err)
}
return seed, nil
}
// StoreKey stores an Ed25519 seed in Secrets Manager as a hex string.
func (a *AWSProvider) StoreKey(ctx context.Context, keyID string, key []byte) error {
hexKey := hex.EncodeToString(key)
// Try to create; if it exists, update.
_, err := a.smClient.CreateSecret(ctx, &secretsmanager.CreateSecretInput{
Name: &keyID,
SecretString: &hexKey,
})
if err != nil {
// Attempt update if creation fails (secret may already exist).
_, updateErr := a.smClient.PutSecretValue(ctx, &secretsmanager.PutSecretValueInput{
SecretId: &keyID,
SecretString: &hexKey,
})
if updateErr != nil {
return fmt.Errorf("hsm/aws: store key %q: create=%v, update=%w", keyID, err, updateErr)
}
}
logger.Info("hsm/aws: stored key", "keyID", keyID)
return nil
}
// DeleteKey deletes a secret from Secrets Manager.
func (a *AWSProvider) DeleteKey(ctx context.Context, keyID string) error {
forceDelete := true
_, err := a.smClient.DeleteSecret(ctx, &secretsmanager.DeleteSecretInput{
SecretId: &keyID,
ForceDeleteWithoutRecovery: &forceDelete,
})
if err != nil {
return fmt.Errorf("hsm/aws: delete key %q: %w", keyID, err)
}
logger.Info("hsm/aws: deleted key", "keyID", keyID)
return nil
}
// ListKeys lists secrets in Secrets Manager. This returns all secrets visible
// to the configured credentials; use a naming convention (prefix) to scope.
func (a *AWSProvider) ListKeys(ctx context.Context) ([]string, error) {
var keys []string
paginator := secretsmanager.NewListSecretsPaginator(a.smClient, &secretsmanager.ListSecretsInput{})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
return nil, fmt.Errorf("hsm/aws: list keys: %w", err)
}
for _, s := range page.SecretList {
if s.Name != nil {
keys = append(keys, *s.Name)
}
}
}
return keys, nil
}
// Sign signs a message.
// KMS mode: uses KMS Sign API with the configured key ARN.
// Secrets Manager mode: loads the Ed25519 seed and signs locally.
func (a *AWSProvider) Sign(ctx context.Context, keyID string, message []byte) ([]byte, error) {
if a.keyARN != "" {
return a.kmsSign(ctx, message)
}
return a.localSign(ctx, keyID, message)
}
// kmsSign delegates signing to AWS KMS.
func (a *AWSProvider) kmsSign(ctx context.Context, message []byte) ([]byte, error) {
out, err := a.kmsClient.Sign(ctx, &kms.SignInput{
KeyId: &a.keyARN,
Message: message,
MessageType: kmstypes.MessageTypeRaw,
SigningAlgorithm: kmstypes.SigningAlgorithmSpecEcdsaSha256,
})
if err != nil {
return nil, fmt.Errorf("hsm/aws: kms sign: %w", err)
}
return out.Signature, nil
}
// localSign fetches the seed from Secrets Manager and signs with Ed25519.
func (a *AWSProvider) localSign(ctx context.Context, keyID string, message []byte) ([]byte, error) {
seed, err := a.GetKey(ctx, keyID)
if err != nil {
return nil, err
}
if len(seed) != ed25519.SeedSize {
return nil, fmt.Errorf("hsm/aws: key %q has invalid seed length %d, want %d", keyID, len(seed), ed25519.SeedSize)
}
priv := ed25519.NewKeyFromSeed(seed)
return ed25519.Sign(priv, message), nil
}
// Healthy verifies KMS connectivity by describing the configured key.
func (a *AWSProvider) Healthy(ctx context.Context) error {
if a.keyARN != "" {
_, err := a.kmsClient.DescribeKey(ctx, &kms.DescribeKeyInput{
KeyId: &a.keyARN,
})
if err != nil {
return fmt.Errorf("hsm/aws: health check (kms): %w", err)
}
return nil
}
// No KMS key configured; check Secrets Manager access.
_, err := a.smClient.ListSecrets(ctx, &secretsmanager.ListSecretsInput{
MaxResults: int32Ptr(1),
})
if err != nil {
return fmt.Errorf("hsm/aws: health check (secrets manager): %w", err)
}
return nil
}
// Close is a no-op; AWS SDK clients do not hold persistent connections.
func (a *AWSProvider) Close() error { return nil }
func int32Ptr(v int32) *int32 { return &v }
+210
View File
@@ -0,0 +1,210 @@
//go:build azure
package hsm
import (
"context"
"crypto/ed25519"
"encoding/hex"
"fmt"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
"github.com/luxfi/mpc/pkg/logger"
)
// AzureProvider implements Provider using Azure Key Vault / Managed HSM.
// Signing keys use Key Vault cryptographic operations (FIPS 140-2 Level 3 for Managed HSM).
// Ed25519 seeds are stored as Key Vault secrets.
type AzureProvider struct {
vaultURL string
keyName string
keysClient *azkeys.Client
secretsClient *azsecrets.Client
}
// NewAzureProvider creates an Azure Key Vault / Managed HSM provider.
func NewAzureProvider(cfg *AzureConfig) (*AzureProvider, error) {
if cfg == nil {
return nil, fmt.Errorf("hsm/azure: config is nil")
}
if cfg.VaultURL == "" {
return nil, fmt.Errorf("hsm/azure: vault_url is required")
}
var cred azidentity.TokenCredential
var err error
if cfg.UseManagedIdentity {
cred, err = azidentity.NewManagedIdentityCredential(nil)
} else if cfg.ClientID != "" && cfg.ClientSecret != "" && cfg.TenantID != "" {
cred, err = azidentity.NewClientSecretCredential(cfg.TenantID, cfg.ClientID, cfg.ClientSecret, nil)
} else {
cred, err = azidentity.NewDefaultAzureCredential(nil)
}
if err != nil {
return nil, fmt.Errorf("hsm/azure: create credential: %w", err)
}
keysClient, err := azkeys.NewClient(cfg.VaultURL, cred, nil)
if err != nil {
return nil, fmt.Errorf("hsm/azure: create keys client: %w", err)
}
secretsClient, err := azsecrets.NewClient(cfg.VaultURL, cred, nil)
if err != nil {
return nil, fmt.Errorf("hsm/azure: create secrets client: %w", err)
}
p := &AzureProvider{
vaultURL: cfg.VaultURL,
keyName: cfg.KeyName,
keysClient: keysClient,
secretsClient: secretsClient,
}
logger.Info("hsm/azure: initialized",
"vaultURL", cfg.VaultURL,
"keyName", cfg.KeyName,
)
return p, nil
}
func (a *AzureProvider) Name() string { return "azure" }
// GetKey retrieves an Ed25519 seed from Key Vault secrets.
// If a key name is configured for KMS operations, returns the key name (private key never leaves HSM).
func (a *AzureProvider) GetKey(ctx context.Context, keyID string) ([]byte, error) {
if a.keyName != "" {
return []byte(a.keyName), nil
}
resp, err := a.secretsClient.GetSecret(ctx, keyID, "", nil)
if err != nil {
return nil, fmt.Errorf("hsm/azure: get secret %q: %w", keyID, err)
}
if resp.Value == nil {
return nil, fmt.Errorf("hsm/azure: secret %q has no value", keyID)
}
seed, err := hex.DecodeString(*resp.Value)
if err != nil {
return nil, fmt.Errorf("hsm/azure: hex decode secret %q: %w", keyID, err)
}
return seed, nil
}
// StoreKey stores an Ed25519 seed as a hex-encoded Key Vault secret.
func (a *AzureProvider) StoreKey(ctx context.Context, keyID string, key []byte) error {
hexKey := hex.EncodeToString(key)
params := azsecrets.SetSecretParameters{
Value: &hexKey,
}
_, err := a.secretsClient.SetSecret(ctx, keyID, params, nil)
if err != nil {
return fmt.Errorf("hsm/azure: store key %q: %w", keyID, err)
}
logger.Info("hsm/azure: stored key", "keyID", keyID)
return nil
}
// DeleteKey deletes a secret from Key Vault.
func (a *AzureProvider) DeleteKey(ctx context.Context, keyID string) error {
_, err := a.secretsClient.DeleteSecret(ctx, keyID, nil)
if err != nil {
return fmt.Errorf("hsm/azure: delete key %q: %w", keyID, err)
}
logger.Info("hsm/azure: deleted key", "keyID", keyID)
return nil
}
// ListKeys lists all secrets in the Key Vault.
func (a *AzureProvider) ListKeys(ctx context.Context) ([]string, error) {
var keys []string
pager := a.secretsClient.NewListSecretPropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, fmt.Errorf("hsm/azure: list keys: %w", err)
}
for _, item := range page.Value {
if item.ID != nil {
keys = append(keys, string(*item.ID))
}
}
}
return keys, nil
}
// Sign signs a message.
// Key Vault key mode: uses Key Vault Sign operation.
// Secret mode: loads Ed25519 seed and signs locally.
func (a *AzureProvider) Sign(ctx context.Context, keyID string, message []byte) ([]byte, error) {
if a.keyName != "" {
return a.kvSign(ctx, message)
}
return a.localSign(ctx, keyID, message)
}
func (a *AzureProvider) kvSign(ctx context.Context, message []byte) ([]byte, error) {
params := azkeys.SignParameters{
Algorithm: toPtr(azkeys.SignatureAlgorithmES256),
Value: message,
}
resp, err := a.keysClient.Sign(ctx, a.keyName, "", params, nil)
if err != nil {
return nil, fmt.Errorf("hsm/azure: kv sign: %w", err)
}
return resp.Result, nil
}
func (a *AzureProvider) localSign(ctx context.Context, keyID string, message []byte) ([]byte, error) {
seed, err := a.GetKey(ctx, keyID)
if err != nil {
return nil, err
}
if len(seed) != ed25519.SeedSize {
return nil, fmt.Errorf("hsm/azure: key %q has invalid seed length %d, want %d", keyID, len(seed), ed25519.SeedSize)
}
priv := ed25519.NewKeyFromSeed(seed)
return ed25519.Sign(priv, message), nil
}
// Healthy checks Key Vault connectivity.
func (a *AzureProvider) Healthy(ctx context.Context) error {
if a.keyName != "" {
_, err := a.keysClient.GetKey(ctx, a.keyName, "", nil)
if err != nil {
return fmt.Errorf("hsm/azure: health check (key): %w", err)
}
return nil
}
// Check secrets access.
pager := a.secretsClient.NewListSecretPropertiesPager(nil)
if pager.More() {
_, err := pager.NextPage(ctx)
if err != nil {
return fmt.Errorf("hsm/azure: health check (secrets): %w", err)
}
}
return nil
}
// Close is a no-op; Azure SDK clients manage connections internally.
func (a *AzureProvider) Close() error { return nil }
func toPtr[T any](v T) *T { return &v }
+11
View File
@@ -0,0 +1,11 @@
//go:build !azure
package hsm
import "fmt"
// NewAzureProvider returns an error when compiled without the azure build tag.
// Build with: go build -tags azure
func NewAzureProvider(cfg *AzureConfig) (Provider, error) {
return nil, fmt.Errorf("hsm/azure: not compiled (build with -tags azure)")
}
+1 -1
View File
@@ -67,7 +67,7 @@ func NewPasswordProvider(providerType string, config map[string]string) (Passwor
return &EnvProvider{EnvVar: envVar}, nil
case "file":
return &FileProvider{
return &FilePasswordProvider{
Path: get("path", "MPC_PASSWORD_FILE"),
}, nil
+236
View File
@@ -0,0 +1,236 @@
package hsm
import (
"context"
"crypto/ed25519"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"filippo.io/age"
"github.com/luxfi/mpc/pkg/logger"
)
// FileProvider implements Provider using the local filesystem.
// Keys are stored as hex-encoded Ed25519 seeds, optionally age-encrypted.
type FileProvider struct {
basePath string
encrypted bool
hexEncoded bool
mu sync.RWMutex
}
// NewFileProvider creates a file-based HSM provider.
func NewFileProvider(cfg *FileConfig) (*FileProvider, error) {
if cfg == nil {
cfg = &FileConfig{}
}
basePath := cfg.BasePath
if basePath == "" {
basePath = "."
}
// Ensure the directory exists.
if err := os.MkdirAll(basePath, 0750); err != nil {
return nil, fmt.Errorf("hsm/file: create base path %q: %w", basePath, err)
}
return &FileProvider{
basePath: basePath,
encrypted: cfg.Encrypted,
hexEncoded: cfg.HexEncoded,
}, nil
}
func (f *FileProvider) Name() string { return "file" }
// GetKey reads an Ed25519 seed from disk. If hex_encoded is true (default),
// the file content is decoded from hex. If encrypted is true, the file
// must have a .age extension and will be decrypted with the HANZO_MPC_AGE_PASSPHRASE
// environment variable.
func (f *FileProvider) GetKey(ctx context.Context, keyID string) ([]byte, error) {
f.mu.RLock()
defer f.mu.RUnlock()
raw, err := f.readKeyFile(keyID)
if err != nil {
return nil, fmt.Errorf("hsm/file: get key %q: %w", keyID, err)
}
return raw, nil
}
// StoreKey writes an Ed25519 seed to disk.
func (f *FileProvider) StoreKey(ctx context.Context, keyID string, key []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.encrypted {
return fmt.Errorf("hsm/file: store key %q: writing age-encrypted keys is not supported", keyID)
}
path := filepath.Join(f.basePath, keyID)
var data []byte
if f.hexEncoded {
data = []byte(hex.EncodeToString(key))
} else {
data = key
}
if err := os.WriteFile(path, data, 0600); err != nil {
return fmt.Errorf("hsm/file: store key %q: %w", keyID, err)
}
logger.Info("hsm/file: stored key", "keyID", keyID, "path", path)
return nil
}
// DeleteKey removes a key file from disk.
func (f *FileProvider) DeleteKey(ctx context.Context, keyID string) error {
f.mu.Lock()
defer f.mu.Unlock()
path := f.keyPath(keyID)
if err := os.Remove(path); err != nil {
return fmt.Errorf("hsm/file: delete key %q: %w", keyID, err)
}
logger.Info("hsm/file: deleted key", "keyID", keyID)
return nil
}
// ListKeys returns all key file names in the base directory.
func (f *FileProvider) ListKeys(ctx context.Context) ([]string, error) {
f.mu.RLock()
defer f.mu.RUnlock()
entries, err := os.ReadDir(f.basePath)
if err != nil {
return nil, fmt.Errorf("hsm/file: list keys: %w", err)
}
var keys []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
// Strip .age suffix for display.
name = strings.TrimSuffix(name, ".age")
keys = append(keys, name)
}
return keys, nil
}
// Sign loads the Ed25519 seed identified by keyID and signs the message locally.
func (f *FileProvider) Sign(ctx context.Context, keyID string, message []byte) ([]byte, error) {
seed, err := f.GetKey(ctx, keyID)
if err != nil {
return nil, err
}
if len(seed) != ed25519.SeedSize {
return nil, fmt.Errorf("hsm/file: sign: key %q has invalid seed length %d, want %d", keyID, len(seed), ed25519.SeedSize)
}
priv := ed25519.NewKeyFromSeed(seed)
sig := ed25519.Sign(priv, message)
return sig, nil
}
// Healthy checks that the base path directory exists and is readable.
func (f *FileProvider) Healthy(ctx context.Context) error {
info, err := os.Stat(f.basePath)
if err != nil {
return fmt.Errorf("hsm/file: health check: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("hsm/file: health check: %q is not a directory", f.basePath)
}
return nil
}
// Close is a no-op for the file provider.
func (f *FileProvider) Close() error { return nil }
// keyPath returns the filesystem path for a given keyID.
func (f *FileProvider) keyPath(keyID string) string {
if f.encrypted {
return filepath.Join(f.basePath, keyID+".age")
}
return filepath.Join(f.basePath, keyID)
}
// readKeyFile reads and decodes a key file, handling hex and age decryption.
func (f *FileProvider) readKeyFile(keyID string) ([]byte, error) {
path := f.keyPath(keyID)
if f.encrypted {
return f.readAgeEncrypted(path)
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
content := strings.TrimSpace(string(data))
if f.hexEncoded {
decoded, err := hex.DecodeString(content)
if err != nil {
return nil, fmt.Errorf("hex decode: %w", err)
}
return decoded, nil
}
return []byte(content), nil
}
// readAgeEncrypted decrypts an age-encrypted key file.
// The passphrase is read from HANZO_MPC_AGE_PASSPHRASE environment variable.
func (f *FileProvider) readAgeEncrypted(path string) ([]byte, error) {
passphrase := os.Getenv("HANZO_MPC_AGE_PASSPHRASE")
if passphrase == "" {
return nil, fmt.Errorf("HANZO_MPC_AGE_PASSPHRASE not set (required for age-encrypted keys)")
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
identity, err := age.NewScryptIdentity(passphrase)
if err != nil {
return nil, fmt.Errorf("create age identity: %w", err)
}
reader, err := age.Decrypt(file, identity)
if err != nil {
return nil, fmt.Errorf("age decrypt: %w", err)
}
decrypted, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("read decrypted: %w", err)
}
content := strings.TrimSpace(string(decrypted))
if f.hexEncoded {
decoded, err := hex.DecodeString(content)
if err != nil {
return nil, fmt.Errorf("hex decode after decrypt: %w", err)
}
return decoded, nil
}
return []byte(content), nil
}
+262
View File
@@ -0,0 +1,262 @@
//go:build gcp
package hsm
import (
"context"
"crypto/ed25519"
"encoding/hex"
"fmt"
"hash/crc32"
kms "cloud.google.com/go/kms/apiv1"
kmspb "cloud.google.com/go/kms/apiv1/kmspb"
secretmanager "cloud.google.com/go/secretmanager/apiv1"
secretmanagerpb "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
"google.golang.org/api/iterator"
"github.com/luxfi/mpc/pkg/logger"
)
// GCPProvider implements Provider using Google Cloud KMS for signing
// and Secret Manager for Ed25519 seed storage.
type GCPProvider struct {
project string
location string
keyRing string
keyName string
hsmLevel string
kmsClient *kms.KeyManagementClient
smClient *secretmanager.Client
}
// NewGCPProvider creates a Google Cloud KMS provider.
func NewGCPProvider(cfg *GCPConfig) (*GCPProvider, error) {
if cfg == nil {
return nil, fmt.Errorf("hsm/gcp: config is nil")
}
if cfg.Project == "" {
return nil, fmt.Errorf("hsm/gcp: project is required")
}
location := cfg.Location
if location == "" {
location = "global"
}
hsmLevel := cfg.HSMLevel
if hsmLevel == "" {
hsmLevel = "HSM"
}
ctx := context.Background()
kmsClient, err := kms.NewKeyManagementClient(ctx)
if err != nil {
return nil, fmt.Errorf("hsm/gcp: create kms client: %w", err)
}
smClient, err := secretmanager.NewClient(ctx)
if err != nil {
kmsClient.Close()
return nil, fmt.Errorf("hsm/gcp: create secret manager client: %w", err)
}
p := &GCPProvider{
project: cfg.Project,
location: location,
keyRing: cfg.KeyRing,
keyName: cfg.KeyName,
hsmLevel: hsmLevel,
kmsClient: kmsClient,
smClient: smClient,
}
logger.Info("hsm/gcp: initialized",
"project", cfg.Project,
"location", location,
"keyRing", cfg.KeyRing,
)
return p, nil
}
func (g *GCPProvider) Name() string { return "gcp" }
// keyVersionName returns the full resource name for the first key version.
func (g *GCPProvider) keyVersionName() string {
return fmt.Sprintf(
"projects/%s/locations/%s/keyRings/%s/cryptoKeys/%s/cryptoKeyVersions/1",
g.project, g.location, g.keyRing, g.keyName,
)
}
// secretName returns the Secret Manager resource name for a key ID.
func (g *GCPProvider) secretName(keyID string) string {
return fmt.Sprintf("projects/%s/secrets/%s", g.project, keyID)
}
// GetKey retrieves an Ed25519 seed from Secret Manager.
// If a KMS key is configured, returns the key version name (private key never leaves Cloud HSM).
func (g *GCPProvider) GetKey(ctx context.Context, keyID string) ([]byte, error) {
if g.keyName != "" {
return []byte(g.keyVersionName()), nil
}
result, err := g.smClient.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{
Name: g.secretName(keyID) + "/versions/latest",
})
if err != nil {
return nil, fmt.Errorf("hsm/gcp: get secret %q: %w", keyID, err)
}
seed, err := hex.DecodeString(string(result.Payload.Data))
if err != nil {
return nil, fmt.Errorf("hsm/gcp: hex decode secret %q: %w", keyID, err)
}
return seed, nil
}
// StoreKey stores an Ed25519 seed in Secret Manager as a hex string.
func (g *GCPProvider) StoreKey(ctx context.Context, keyID string, key []byte) error {
hexKey := hex.EncodeToString(key)
// Create the secret resource (ignore error if already exists).
_, _ = g.smClient.CreateSecret(ctx, &secretmanagerpb.CreateSecretRequest{
Parent: fmt.Sprintf("projects/%s", g.project),
SecretId: keyID,
Secret: &secretmanagerpb.Secret{
Replication: &secretmanagerpb.Replication{
Replication: &secretmanagerpb.Replication_Automatic_{
Automatic: &secretmanagerpb.Replication_Automatic{},
},
},
},
})
// Add the secret version with the actual data.
crc := crc32.Checksum([]byte(hexKey), crc32.MakeTable(crc32.Castagnoli))
_, err := g.smClient.AddSecretVersion(ctx, &secretmanagerpb.AddSecretVersionRequest{
Parent: g.secretName(keyID),
Payload: &secretmanagerpb.SecretPayload{
Data: []byte(hexKey),
DataCrc32C: int64Ptr(int64(crc)),
},
})
if err != nil {
return fmt.Errorf("hsm/gcp: store key %q: %w", keyID, err)
}
logger.Info("hsm/gcp: stored key", "keyID", keyID)
return nil
}
// DeleteKey destroys a secret in Secret Manager.
func (g *GCPProvider) DeleteKey(ctx context.Context, keyID string) error {
err := g.smClient.DeleteSecret(ctx, &secretmanagerpb.DeleteSecretRequest{
Name: g.secretName(keyID),
})
if err != nil {
return fmt.Errorf("hsm/gcp: delete key %q: %w", keyID, err)
}
logger.Info("hsm/gcp: deleted key", "keyID", keyID)
return nil
}
// ListKeys lists all secrets in the project.
func (g *GCPProvider) ListKeys(ctx context.Context) ([]string, error) {
var keys []string
it := g.smClient.ListSecrets(ctx, &secretmanagerpb.ListSecretsRequest{
Parent: fmt.Sprintf("projects/%s", g.project),
})
for {
secret, err := it.Next()
if err == iterator.Done {
break
}
if err != nil {
return nil, fmt.Errorf("hsm/gcp: list keys: %w", err)
}
keys = append(keys, secret.Name)
}
return keys, nil
}
// Sign signs a message.
// KMS mode: uses Cloud KMS AsymmetricSign.
// Secret Manager mode: loads the seed and signs locally.
func (g *GCPProvider) Sign(ctx context.Context, keyID string, message []byte) ([]byte, error) {
if g.keyName != "" {
return g.kmsSign(ctx, message)
}
return g.localSign(ctx, keyID, message)
}
func (g *GCPProvider) kmsSign(ctx context.Context, message []byte) ([]byte, error) {
crc := crc32.Checksum(message, crc32.MakeTable(crc32.Castagnoli))
result, err := g.kmsClient.AsymmetricSign(ctx, &kmspb.AsymmetricSignRequest{
Name: g.keyVersionName(),
Data: message,
DataCrc32C: &kmspb.Int64Value{Value: int64(crc)},
})
if err != nil {
return nil, fmt.Errorf("hsm/gcp: kms sign: %w", err)
}
return result.Signature, nil
}
func (g *GCPProvider) localSign(ctx context.Context, keyID string, message []byte) ([]byte, error) {
seed, err := g.GetKey(ctx, keyID)
if err != nil {
return nil, err
}
if len(seed) != ed25519.SeedSize {
return nil, fmt.Errorf("hsm/gcp: key %q has invalid seed length %d, want %d", keyID, len(seed), ed25519.SeedSize)
}
priv := ed25519.NewKeyFromSeed(seed)
return ed25519.Sign(priv, message), nil
}
// Healthy checks Cloud KMS connectivity by listing key rings.
func (g *GCPProvider) Healthy(ctx context.Context) error {
if g.keyRing != "" {
name := fmt.Sprintf("projects/%s/locations/%s/keyRings/%s", g.project, g.location, g.keyRing)
_, err := g.kmsClient.GetKeyRing(ctx, &kmspb.GetKeyRingRequest{Name: name})
if err != nil {
return fmt.Errorf("hsm/gcp: health check: %w", err)
}
return nil
}
// Fallback: check Secret Manager access.
it := g.smClient.ListSecrets(ctx, &secretmanagerpb.ListSecretsRequest{
Parent: fmt.Sprintf("projects/%s", g.project),
PageSize: 1,
})
_, err := it.Next()
if err != nil && err != iterator.Done {
return fmt.Errorf("hsm/gcp: health check: %w", err)
}
return nil
}
// Close releases GCP client resources.
func (g *GCPProvider) Close() error {
var firstErr error
if err := g.kmsClient.Close(); err != nil {
firstErr = fmt.Errorf("hsm/gcp: close kms client: %w", err)
}
if err := g.smClient.Close(); err != nil && firstErr == nil {
firstErr = fmt.Errorf("hsm/gcp: close secret manager client: %w", err)
}
return firstErr
}
func int64Ptr(v int64) *int64 { return &v }
+11
View File
@@ -0,0 +1,11 @@
//go:build !gcp
package hsm
import "fmt"
// NewGCPProvider returns an error when compiled without the gcp build tag.
// Build with: go build -tags gcp
func NewGCPProvider(cfg *GCPConfig) (Provider, error) {
return nil, fmt.Errorf("hsm/gcp: not compiled (build with -tags gcp)")
}
+235
View File
@@ -0,0 +1,235 @@
package hsm
import (
"context"
"crypto/ed25519"
"encoding/hex"
"os"
"path/filepath"
"testing"
)
func TestFileProvider_RoundTrip(t *testing.T) {
dir := t.TempDir()
ctx := context.Background()
p, err := NewFileProvider(&FileConfig{
BasePath: dir,
HexEncoded: true,
})
if err != nil {
t.Fatalf("NewFileProvider: %v", err)
}
if p.Name() != "file" {
t.Fatalf("Name() = %q, want %q", p.Name(), "file")
}
// Generate a deterministic Ed25519 seed.
seed := make([]byte, ed25519.SeedSize)
for i := range seed {
seed[i] = byte(i)
}
keyID := "test-key-01"
// Store.
if err := p.StoreKey(ctx, keyID, seed); err != nil {
t.Fatalf("StoreKey: %v", err)
}
// Verify file exists and contains hex.
data, err := os.ReadFile(filepath.Join(dir, keyID))
if err != nil {
t.Fatalf("read file: %v", err)
}
if string(data) != hex.EncodeToString(seed) {
t.Fatalf("file content = %q, want %q", data, hex.EncodeToString(seed))
}
// Get.
got, err := p.GetKey(ctx, keyID)
if err != nil {
t.Fatalf("GetKey: %v", err)
}
if hex.EncodeToString(got) != hex.EncodeToString(seed) {
t.Fatalf("GetKey returned %x, want %x", got, seed)
}
// List.
keys, err := p.ListKeys(ctx)
if err != nil {
t.Fatalf("ListKeys: %v", err)
}
if len(keys) != 1 || keys[0] != keyID {
t.Fatalf("ListKeys = %v, want [%q]", keys, keyID)
}
// Delete.
if err := p.DeleteKey(ctx, keyID); err != nil {
t.Fatalf("DeleteKey: %v", err)
}
keys, err = p.ListKeys(ctx)
if err != nil {
t.Fatalf("ListKeys after delete: %v", err)
}
if len(keys) != 0 {
t.Fatalf("ListKeys after delete = %v, want empty", keys)
}
}
func TestFileProvider_Sign(t *testing.T) {
dir := t.TempDir()
ctx := context.Background()
p, err := NewFileProvider(&FileConfig{
BasePath: dir,
HexEncoded: true,
})
if err != nil {
t.Fatalf("NewFileProvider: %v", err)
}
// Use a real Ed25519 keypair.
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
seed := priv.Seed()
keyID := "sign-test"
if err := p.StoreKey(ctx, keyID, seed); err != nil {
t.Fatalf("StoreKey: %v", err)
}
message := []byte("threshold signing test message")
sig, err := p.Sign(ctx, keyID, message)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if !ed25519.Verify(pub, message, sig) {
t.Fatal("signature verification failed")
}
}
func TestFileProvider_Healthy(t *testing.T) {
dir := t.TempDir()
p, err := NewFileProvider(&FileConfig{BasePath: dir})
if err != nil {
t.Fatalf("NewFileProvider: %v", err)
}
if err := p.Healthy(context.Background()); err != nil {
t.Fatalf("Healthy: %v", err)
}
// Non-existent path should fail.
p2, err := NewFileProvider(&FileConfig{BasePath: filepath.Join(dir, "nonexistent")})
if err != nil {
// NewFileProvider creates the directory, so it should succeed.
t.Fatalf("NewFileProvider: %v", err)
}
// But it exists now because we created it.
if err := p2.Healthy(context.Background()); err != nil {
t.Fatalf("Healthy after create: %v", err)
}
}
func TestFileProvider_RawBytes(t *testing.T) {
dir := t.TempDir()
ctx := context.Background()
// Test non-hex mode.
p, err := NewFileProvider(&FileConfig{
BasePath: dir,
HexEncoded: false,
})
if err != nil {
t.Fatalf("NewFileProvider: %v", err)
}
seed := make([]byte, ed25519.SeedSize)
for i := range seed {
seed[i] = byte(i + 100)
}
keyID := "raw-key"
if err := p.StoreKey(ctx, keyID, seed); err != nil {
t.Fatalf("StoreKey: %v", err)
}
got, err := p.GetKey(ctx, keyID)
if err != nil {
t.Fatalf("GetKey: %v", err)
}
if hex.EncodeToString(got) != hex.EncodeToString(seed) {
t.Fatalf("raw GetKey returned %x, want %x", got, seed)
}
}
func TestNewProvider_File(t *testing.T) {
dir := t.TempDir()
p, err := NewProvider(Config{
Provider: "file",
File: &FileConfig{BasePath: dir},
})
if err != nil {
t.Fatalf("NewProvider(file): %v", err)
}
if p.Name() != "file" {
t.Fatalf("Name() = %q, want %q", p.Name(), "file")
}
if err := p.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
func TestNewProvider_Default(t *testing.T) {
dir := t.TempDir()
// Empty provider string should default to file.
p, err := NewProvider(Config{
File: &FileConfig{BasePath: dir},
})
if err != nil {
t.Fatalf("NewProvider(default): %v", err)
}
if p.Name() != "file" {
t.Fatalf("Name() = %q, want %q", p.Name(), "file")
}
}
func TestNewProvider_Unknown(t *testing.T) {
_, err := NewProvider(Config{Provider: "quantum"})
if err == nil {
t.Fatal("expected error for unknown provider")
}
}
func TestNewProvider_GCP_Stub(t *testing.T) {
// Without gcp build tag, should return "not compiled" error.
_, err := NewGCPProvider(&GCPConfig{Project: "test"})
if err == nil {
t.Skip("gcp build tag is active; skipping stub test")
}
}
func TestNewProvider_Azure_Stub(t *testing.T) {
_, err := NewAzureProvider(&AzureConfig{VaultURL: "https://test"})
if err == nil {
t.Skip("azure build tag is active; skipping stub test")
}
}
func TestNewProvider_Zymbit_Stub(t *testing.T) {
_, err := NewZymbitProvider(&ZymbitConfig{APIEndpoint: "http://localhost"})
if err == nil {
t.Skip("zymbit build tag is active; skipping stub test")
}
}
+378
View File
@@ -0,0 +1,378 @@
package hsm
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/luxfi/mpc/pkg/logger"
)
// KMSProvider implements Provider using the Hanzo KMS API (kms.hanzo.ai).
// KMS is a secret manager; signing is always done locally after fetching the Ed25519 seed.
type KMSProvider struct {
siteURL string
clientID string
clientSecret string
projectID string
environment string
secretPath string
client *http.Client
// Token management.
mu sync.RWMutex
accessToken string
tokenExpiry time.Time
}
// NewKMSProvider creates a Hanzo KMS secret manager provider.
func NewKMSProvider(cfg *KMSConfig) (*KMSProvider, error) {
if cfg == nil {
return nil, fmt.Errorf("hsm/kms: config is nil")
}
if cfg.ClientID == "" || cfg.ClientSecret == "" {
return nil, fmt.Errorf("hsm/kms: client_id and client_secret are required")
}
if cfg.ProjectID == "" {
return nil, fmt.Errorf("hsm/kms: project_id is required")
}
siteURL := cfg.SiteURL
if siteURL == "" {
siteURL = "https://kms.hanzo.ai"
}
env := cfg.Environment
if env == "" {
env = "prod"
}
secretPath := cfg.SecretPath
if secretPath == "" {
secretPath = "/mpc"
}
p := &KMSProvider{
siteURL: siteURL,
clientID: cfg.ClientID,
clientSecret: cfg.ClientSecret,
projectID: cfg.ProjectID,
environment: env,
secretPath: secretPath,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
logger.Info("hsm/kms: initialized",
"siteURL", siteURL,
"projectID", cfg.ProjectID,
"environment", env,
"secretPath", secretPath,
)
return p, nil
}
func (k *KMSProvider) Name() string { return "kms" }
// authenticate obtains or refreshes the Universal Auth access token.
func (k *KMSProvider) authenticate(ctx context.Context) (string, error) {
k.mu.RLock()
if k.accessToken != "" && time.Now().Before(k.tokenExpiry) {
token := k.accessToken
k.mu.RUnlock()
return token, nil
}
k.mu.RUnlock()
k.mu.Lock()
defer k.mu.Unlock()
// Double-check after acquiring write lock.
if k.accessToken != "" && time.Now().Before(k.tokenExpiry) {
return k.accessToken, nil
}
body := map[string]string{
"clientId": k.clientID,
"clientSecret": k.clientSecret,
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("marshal auth request: %w", err)
}
url := k.siteURL + "/api/v1/auth/universal-auth/login"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON))
if err != nil {
return "", fmt.Errorf("create auth request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := k.client.Do(req)
if err != nil {
return "", fmt.Errorf("auth request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("auth failed: status %d: %s", resp.StatusCode, respBody)
}
var result struct {
AccessToken string `json:"accessToken"`
ExpiresIn int `json:"expiresIn"` // seconds
AccessTokenMaxTTL int `json:"accessTokenMaxTTL"` // seconds
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode auth response: %w", err)
}
k.accessToken = result.AccessToken
// Expire 60 seconds early to avoid clock-skew issues.
ttl := time.Duration(result.ExpiresIn) * time.Second
if ttl <= 0 {
ttl = 5 * time.Minute
}
k.tokenExpiry = time.Now().Add(ttl - 60*time.Second)
return k.accessToken, nil
}
// doAuthRequest executes an authenticated HTTP request.
func (k *KMSProvider) doAuthRequest(ctx context.Context, method, url string, body io.Reader) (*http.Response, error) {
token, err := k.authenticate(ctx)
if err != nil {
return nil, fmt.Errorf("hsm/kms: authenticate: %w", err)
}
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
return k.client.Do(req)
}
// GetKey retrieves an Ed25519 seed from KMS.
func (k *KMSProvider) GetKey(ctx context.Context, keyID string) ([]byte, error) {
url := fmt.Sprintf(
"%s/api/v3/secrets/raw/%s?workspaceId=%s&environment=%s&secretPath=%s",
k.siteURL, keyID, k.projectID, k.environment, k.secretPath,
)
resp, err := k.doAuthRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("hsm/kms: get key %q: %w", keyID, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("hsm/kms: get key %q: status %d: %s", keyID, resp.StatusCode, body)
}
var result struct {
Secret struct {
SecretValue string `json:"secretValue"`
} `json:"secret"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("hsm/kms: decode response: %w", err)
}
seed, err := hex.DecodeString(result.Secret.SecretValue)
if err != nil {
return nil, fmt.Errorf("hsm/kms: hex decode key %q: %w", keyID, err)
}
return seed, nil
}
// StoreKey stores an Ed25519 seed in KMS as a hex-encoded secret.
func (k *KMSProvider) StoreKey(ctx context.Context, keyID string, key []byte) error {
url := fmt.Sprintf("%s/api/v3/secrets/raw/%s", k.siteURL, keyID)
body := map[string]string{
"workspaceId": k.projectID,
"environment": k.environment,
"secretPath": k.secretPath,
"secretValue": hex.EncodeToString(key),
"type": "shared",
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("hsm/kms: marshal request: %w", err)
}
resp, err := k.doAuthRequest(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON))
if err != nil {
return fmt.Errorf("hsm/kms: store key %q: %w", keyID, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
// Try PATCH (update) if POST (create) returned conflict.
if resp.StatusCode == http.StatusConflict || resp.StatusCode == http.StatusBadRequest {
return k.updateKey(ctx, keyID, key)
}
return fmt.Errorf("hsm/kms: store key %q: status %d: %s", keyID, resp.StatusCode, respBody)
}
logger.Info("hsm/kms: stored key", "keyID", keyID)
return nil
}
// updateKey updates an existing secret value.
func (k *KMSProvider) updateKey(ctx context.Context, keyID string, key []byte) error {
url := fmt.Sprintf("%s/api/v3/secrets/raw/%s", k.siteURL, keyID)
body := map[string]string{
"workspaceId": k.projectID,
"environment": k.environment,
"secretPath": k.secretPath,
"secretValue": hex.EncodeToString(key),
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("hsm/kms: marshal update request: %w", err)
}
resp, err := k.doAuthRequest(ctx, http.MethodPatch, url, bytes.NewReader(bodyJSON))
if err != nil {
return fmt.Errorf("hsm/kms: update key %q: %w", keyID, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("hsm/kms: update key %q: status %d: %s", keyID, resp.StatusCode, respBody)
}
logger.Info("hsm/kms: updated key", "keyID", keyID)
return nil
}
// DeleteKey removes a secret from KMS.
func (k *KMSProvider) DeleteKey(ctx context.Context, keyID string) error {
url := fmt.Sprintf("%s/api/v3/secrets/raw/%s", k.siteURL, keyID)
body := map[string]string{
"workspaceId": k.projectID,
"environment": k.environment,
"secretPath": k.secretPath,
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("hsm/kms: marshal delete request: %w", err)
}
resp, err := k.doAuthRequest(ctx, http.MethodDelete, url, bytes.NewReader(bodyJSON))
if err != nil {
return fmt.Errorf("hsm/kms: delete key %q: %w", keyID, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("hsm/kms: delete key %q: status %d: %s", keyID, resp.StatusCode, respBody)
}
logger.Info("hsm/kms: deleted key", "keyID", keyID)
return nil
}
// ListKeys lists all secrets at the configured path.
func (k *KMSProvider) ListKeys(ctx context.Context) ([]string, error) {
url := fmt.Sprintf(
"%s/api/v3/secrets/raw?workspaceId=%s&environment=%s&secretPath=%s",
k.siteURL, k.projectID, k.environment, k.secretPath,
)
resp, err := k.doAuthRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("hsm/kms: list keys: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("hsm/kms: list keys: status %d: %s", resp.StatusCode, body)
}
var result struct {
Secrets []struct {
SecretKey string `json:"secretKey"`
} `json:"secrets"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("hsm/kms: decode list response: %w", err)
}
keys := make([]string, len(result.Secrets))
for i, s := range result.Secrets {
keys[i] = s.SecretKey
}
return keys, nil
}
// Sign fetches the Ed25519 seed from KMS and signs locally.
// KMS is a secret manager; it cannot perform cryptographic operations.
func (k *KMSProvider) Sign(ctx context.Context, keyID string, message []byte) ([]byte, error) {
seed, err := k.GetKey(ctx, keyID)
if err != nil {
return nil, err
}
if len(seed) != ed25519.SeedSize {
return nil, fmt.Errorf("hsm/kms: key %q has invalid seed length %d, want %d", keyID, len(seed), ed25519.SeedSize)
}
priv := ed25519.NewKeyFromSeed(seed)
return ed25519.Sign(priv, message), nil
}
// Healthy checks KMS API connectivity.
func (k *KMSProvider) Healthy(ctx context.Context) error {
url := k.siteURL + "/api/status"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("hsm/kms: create health request: %w", err)
}
resp, err := k.client.Do(req)
if err != nil {
return fmt.Errorf("hsm/kms: health check: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("hsm/kms: health check: status %d: %s", resp.StatusCode, body)
}
return nil
}
// Close releases HTTP client resources.
func (k *KMSProvider) Close() error {
k.client.CloseIdleConnections()
k.mu.Lock()
k.accessToken = ""
k.tokenExpiry = time.Time{}
k.mu.Unlock()
return nil
}
+108
View File
@@ -0,0 +1,108 @@
// Package hsm provides a unified interface for hardware and cloud key management.
//
// Supported providers:
// - file: Local filesystem (default, always available)
// - aws: AWS KMS / CloudHSM (always compiled, requires aws-sdk-go-v2/service/kms)
// - gcp: Google Cloud KMS (build tag: gcp)
// - azure: Azure Key Vault / Managed HSM (build tag: azure)
// - zymbit: Zymbit ZYMKEY/HSM6 REST API (build tag: zymbit)
// - kms: Hanzo KMS secret manager (always available, net/http only)
package hsm
import "context"
// Provider abstracts HSM key operations across cloud and hardware backends.
type Provider interface {
// Name returns the provider identifier (e.g., "aws", "gcp", "azure", "zymbit", "kms", "file").
Name() string
// GetKey retrieves a key by ID.
// For software providers: returns raw Ed25519 seed bytes.
// For hardware HSMs: returns an opaque handle or public key (private never leaves device).
GetKey(ctx context.Context, keyID string) ([]byte, error)
// StoreKey stores key material under the given ID.
StoreKey(ctx context.Context, keyID string, key []byte) error
// DeleteKey removes a key by ID.
DeleteKey(ctx context.Context, keyID string) error
// ListKeys returns all key IDs managed by this provider.
ListKeys(ctx context.Context) ([]string, error)
// Sign signs a message using the key identified by keyID.
// For cloud KMS: delegates signing to the remote service (key never leaves HSM).
// For software/file: loads key and signs locally with Ed25519.
Sign(ctx context.Context, keyID string, message []byte) ([]byte, error)
// Healthy returns nil if the provider is reachable and operational.
Healthy(ctx context.Context) error
// Close releases any resources held by the provider.
Close() error
}
// Config holds HSM provider configuration loaded from config.yaml.
type Config struct {
Provider string `yaml:"provider" mapstructure:"provider"` // aws, gcp, azure, zymbit, kms, file
AWS *AWSConfig `yaml:"aws" mapstructure:"aws"`
GCP *GCPConfig `yaml:"gcp" mapstructure:"gcp"`
Azure *AzureConfig `yaml:"azure" mapstructure:"azure"`
Zymbit *ZymbitConfig `yaml:"zymbit" mapstructure:"zymbit"`
KMS *KMSConfig `yaml:"kms" mapstructure:"kms"`
File *FileConfig `yaml:"file" mapstructure:"file"`
}
// FileConfig configures the local filesystem provider.
type FileConfig struct {
BasePath string `yaml:"base_path" mapstructure:"base_path"` // directory containing key files
Encrypted bool `yaml:"encrypted" mapstructure:"encrypted"` // whether keys are age-encrypted
HexEncoded bool `yaml:"hex_encoded" mapstructure:"hex_encoded"` // whether key files contain hex (default true)
}
// AWSConfig configures the AWS KMS / CloudHSM provider.
type AWSConfig struct {
Region string `yaml:"region" mapstructure:"region"`
KeyARN string `yaml:"key_arn" mapstructure:"key_arn"` // KMS key ARN or alias
CustomKeyStore string `yaml:"custom_key_store" mapstructure:"custom_key_store"` // CloudHSM cluster ID (optional)
Profile string `yaml:"profile" mapstructure:"profile"` // AWS profile (optional)
RoleARN string `yaml:"role_arn" mapstructure:"role_arn"` // STS assume role (optional)
}
// GCPConfig configures the Google Cloud KMS provider.
type GCPConfig struct {
Project string `yaml:"project" mapstructure:"project"`
Location string `yaml:"location" mapstructure:"location"` // e.g. "global", "us-east1"
KeyRing string `yaml:"key_ring" mapstructure:"key_ring"`
KeyName string `yaml:"key_name" mapstructure:"key_name"`
HSMLevel string `yaml:"hsm_level" mapstructure:"hsm_level"` // "SOFTWARE" or "HSM" (default HSM)
}
// AzureConfig configures the Azure Key Vault / Managed HSM provider.
type AzureConfig struct {
VaultURL string `yaml:"vault_url" mapstructure:"vault_url"` // e.g. "https://mpc-hsm.managedhsm.azure.net"
KeyName string `yaml:"key_name" mapstructure:"key_name"`
TenantID string `yaml:"tenant_id" mapstructure:"tenant_id"`
ClientID string `yaml:"client_id" mapstructure:"client_id"`
ClientSecret string `yaml:"client_secret" mapstructure:"client_secret"`
UseManagedIdentity bool `yaml:"use_managed_identity" mapstructure:"use_managed_identity"`
}
// ZymbitConfig configures the Zymbit ZYMKEY/HSM6 provider.
type ZymbitConfig struct {
DevicePath string `yaml:"device_path" mapstructure:"device_path"` // default "/dev/zymbit0"
SlotID int `yaml:"slot_id" mapstructure:"slot_id"` // key slot (default 0)
KeyType string `yaml:"key_type" mapstructure:"key_type"` // "ed25519" or "secp256k1"
APIEndpoint string `yaml:"api_endpoint" mapstructure:"api_endpoint"` // REST API if using Zymbit SCM
}
// KMSConfig configures the Hanzo KMS (kms.hanzo.ai) provider.
type KMSConfig struct {
SiteURL string `yaml:"site_url" mapstructure:"site_url"` // default "https://kms.hanzo.ai"
ClientID string `yaml:"client_id" mapstructure:"client_id"` // Machine Identity client ID
ClientSecret string `yaml:"client_secret" mapstructure:"client_secret"` // Machine Identity client secret
ProjectID string `yaml:"project_id" mapstructure:"project_id"` // KMS project/workspace ID
Environment string `yaml:"environment" mapstructure:"environment"` // "prod", "staging", "dev"
SecretPath string `yaml:"secret_path" mapstructure:"secret_path"` // default "/mpc"
}
+3 -3
View File
@@ -641,15 +641,15 @@ func (p *EnvProvider) GetPassword(_ context.Context, _ string) (string, error) {
// 5. File Provider
// ---------------------------------------------------------------------------
// FileProvider reads a password from a file on disk. This is useful for
// FilePasswordProvider reads a password from a file on disk. This is useful for
// Kubernetes secrets mounted as volumes, Docker secrets, or any system
// where the password is provisioned as a file.
type FileProvider struct {
type FilePasswordProvider struct {
Path string // path to password file
}
// GetPassword reads the password from the configured file path.
func (p *FileProvider) GetPassword(_ context.Context, keyID string) (string, error) {
func (p *FilePasswordProvider) GetPassword(_ context.Context, keyID string) (string, error) {
path := p.Path
if path == "" && keyID != "" {
path = keyID
+5 -5
View File
@@ -71,7 +71,7 @@ func TestFileProvider(t *testing.T) {
t.Fatalf("failed to write password file: %v", err)
}
p := &FileProvider{Path: path}
p := &FilePasswordProvider{Path: path}
got, err := p.GetPassword(context.Background(), "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -90,7 +90,7 @@ func TestFileProviderViaKeyID(t *testing.T) {
t.Fatalf("failed to write password file: %v", err)
}
p := &FileProvider{}
p := &FilePasswordProvider{}
got, err := p.GetPassword(context.Background(), path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -101,7 +101,7 @@ func TestFileProviderViaKeyID(t *testing.T) {
}
func TestFileProviderMissing(t *testing.T) {
p := &FileProvider{Path: "/nonexistent/path/to/password.txt"}
p := &FilePasswordProvider{Path: "/nonexistent/path/to/password.txt"}
_, err := p.GetPassword(context.Background(), "")
if err == nil {
t.Fatal("expected error for missing file")
@@ -115,7 +115,7 @@ func TestFileProviderEmpty(t *testing.T) {
t.Fatalf("failed to write file: %v", err)
}
p := &FileProvider{Path: path}
p := &FilePasswordProvider{Path: path}
_, err := p.GetPassword(context.Background(), "")
if err == nil {
t.Fatal("expected error for empty password file")
@@ -142,7 +142,7 @@ func TestNewPasswordProviderAllTypes(t *testing.T) {
{"gcp", "*hsm.GCPKMSProvider"},
{"azure", "*hsm.AzureKVProvider"},
{"env", "*hsm.EnvProvider"},
{"file", "*hsm.FileProvider"},
{"file", "*hsm.FilePasswordProvider"},
}
for _, tt := range tests {
+24
View File
@@ -0,0 +1,24 @@
package hsm
import "fmt"
// NewProvider creates an HSM provider from configuration.
// Returns a FileProvider by default when no provider is specified.
func NewProvider(cfg Config) (Provider, error) {
switch cfg.Provider {
case "aws":
return NewAWSProvider(cfg.AWS)
case "gcp":
return NewGCPProvider(cfg.GCP)
case "azure":
return NewAzureProvider(cfg.Azure)
case "zymbit":
return NewZymbitProvider(cfg.Zymbit)
case "kms":
return NewKMSProvider(cfg.KMS)
case "file", "":
return NewFileProvider(cfg.File)
default:
return nil, fmt.Errorf("hsm: unknown provider %q", cfg.Provider)
}
}
+253
View File
@@ -0,0 +1,253 @@
//go:build zymbit
package hsm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/luxfi/mpc/pkg/logger"
)
// ZymbitProvider implements Provider using the Zymbit Secure Compute Module REST API.
// Private keys never leave the hardware device; signing is performed on-device.
type ZymbitProvider struct {
endpoint string
slotID int
keyType string
client *http.Client
}
// NewZymbitProvider creates a Zymbit HSM provider using the REST API.
func NewZymbitProvider(cfg *ZymbitConfig) (*ZymbitProvider, error) {
if cfg == nil {
return nil, fmt.Errorf("hsm/zymbit: config is nil")
}
endpoint := cfg.APIEndpoint
if endpoint == "" {
return nil, fmt.Errorf("hsm/zymbit: api_endpoint is required")
}
keyType := cfg.KeyType
if keyType == "" {
keyType = "ed25519"
}
p := &ZymbitProvider{
endpoint: endpoint,
slotID: cfg.SlotID,
keyType: keyType,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
logger.Info("hsm/zymbit: initialized",
"endpoint", endpoint,
"slotID", cfg.SlotID,
"keyType", keyType,
)
return p, nil
}
func (z *ZymbitProvider) Name() string { return "zymbit" }
// GetKey returns the public key for a key slot. The private key never leaves the device.
func (z *ZymbitProvider) GetKey(ctx context.Context, keyID string) ([]byte, error) {
url := fmt.Sprintf("%s/api/v1/keys/%s/public", z.endpoint, keyID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("hsm/zymbit: create request: %w", err)
}
resp, err := z.client.Do(req)
if err != nil {
return nil, fmt.Errorf("hsm/zymbit: get key %q: %w", keyID, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("hsm/zymbit: get key %q: status %d: %s", keyID, resp.StatusCode, body)
}
var result struct {
PublicKey []byte `json:"public_key"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("hsm/zymbit: decode response: %w", err)
}
return result.PublicKey, nil
}
// StoreKey generates a new key in the specified slot.
// The key material parameter is ignored; the device generates the key internally.
func (z *ZymbitProvider) StoreKey(ctx context.Context, keyID string, key []byte) error {
url := fmt.Sprintf("%s/api/v1/keys", z.endpoint)
body := map[string]interface{}{
"key_id": keyID,
"key_type": z.keyType,
"slot_id": z.slotID,
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("hsm/zymbit: marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON))
if err != nil {
return fmt.Errorf("hsm/zymbit: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := z.client.Do(req)
if err != nil {
return fmt.Errorf("hsm/zymbit: store key %q: %w", keyID, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("hsm/zymbit: store key %q: status %d: %s", keyID, resp.StatusCode, respBody)
}
logger.Info("hsm/zymbit: generated key on device", "keyID", keyID, "slot", z.slotID)
return nil
}
// DeleteKey removes a key from the device.
func (z *ZymbitProvider) DeleteKey(ctx context.Context, keyID string) error {
url := fmt.Sprintf("%s/api/v1/keys/%s", z.endpoint, keyID)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
if err != nil {
return fmt.Errorf("hsm/zymbit: create request: %w", err)
}
resp, err := z.client.Do(req)
if err != nil {
return fmt.Errorf("hsm/zymbit: delete key %q: %w", keyID, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("hsm/zymbit: delete key %q: status %d: %s", keyID, resp.StatusCode, body)
}
logger.Info("hsm/zymbit: deleted key", "keyID", keyID)
return nil
}
// ListKeys returns all key IDs from the device.
func (z *ZymbitProvider) ListKeys(ctx context.Context) ([]string, error) {
url := fmt.Sprintf("%s/api/v1/keys", z.endpoint)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("hsm/zymbit: create request: %w", err)
}
resp, err := z.client.Do(req)
if err != nil {
return nil, fmt.Errorf("hsm/zymbit: list keys: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("hsm/zymbit: list keys: status %d: %s", resp.StatusCode, body)
}
var result struct {
Keys []struct {
ID string `json:"key_id"`
} `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("hsm/zymbit: decode response: %w", err)
}
ids := make([]string, len(result.Keys))
for i, k := range result.Keys {
ids[i] = k.ID
}
return ids, nil
}
// Sign delegates signing to the Zymbit device. The private key never leaves hardware.
func (z *ZymbitProvider) Sign(ctx context.Context, keyID string, message []byte) ([]byte, error) {
url := fmt.Sprintf("%s/api/v1/keys/%s/sign", z.endpoint, keyID)
body := map[string]interface{}{
"message": message,
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("hsm/zymbit: marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON))
if err != nil {
return nil, fmt.Errorf("hsm/zymbit: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := z.client.Do(req)
if err != nil {
return nil, fmt.Errorf("hsm/zymbit: sign with key %q: %w", keyID, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("hsm/zymbit: sign with key %q: status %d: %s", keyID, resp.StatusCode, respBody)
}
var result struct {
Signature []byte `json:"signature"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("hsm/zymbit: decode sign response: %w", err)
}
return result.Signature, nil
}
// Healthy checks device connectivity via the API status endpoint.
func (z *ZymbitProvider) Healthy(ctx context.Context) error {
url := fmt.Sprintf("%s/api/v1/status", z.endpoint)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("hsm/zymbit: create request: %w", err)
}
resp, err := z.client.Do(req)
if err != nil {
return fmt.Errorf("hsm/zymbit: health check: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("hsm/zymbit: health check: status %d: %s", resp.StatusCode, body)
}
return nil
}
// Close releases the HTTP client resources.
func (z *ZymbitProvider) Close() error {
z.client.CloseIdleConnections()
return nil
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !zymbit
package hsm
import "fmt"
// NewZymbitProvider returns an error when compiled without the zymbit build tag.
// Build with: go build -tags zymbit
func NewZymbitProvider(cfg *ZymbitConfig) (Provider, error) {
return nil, fmt.Errorf("hsm/zymbit: not compiled (build with -tags zymbit)")
}
+757
View File
@@ -0,0 +1,757 @@
package infra
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/mpc/pkg/logger"
)
// ConsensusKV implements KV backed by private BFT consensus among MPC nodes.
//
// Dual-certificate finality (same model as Lux Quasar):
// - Classical: Ed25519 signatures (64 bytes, fast)
// - Post-quantum: ML-DSA-65 signatures (3,309 bytes, FIPS 204, NIST Level 3)
// - Block is ONLY finalized when BOTH signature types reach t-of-n threshold
// - If quantum computers break Ed25519, ML-DSA certificates are still valid
// - If ML-DSA has an unforeseen flaw, Ed25519 certificates still hold
//
// The MPC cluster nodes form their own private blockchain:
// - Same nodes that do threshold signing also validate state blocks
// - Identity keys (Ed25519 + ML-DSA-65) sign consensus votes
// - Threshold finality: t-of-n votes required (same t as MPC threshold)
// - NATS provides the transport layer between validators
// - No external chain dependency — fully self-contained
//
// Consensus protocol:
// 1. Any node batches pending KV mutations into a block proposal
// 2. Proposal broadcast to all validators via NATS
// 3. Each validator verifies parent hash + proposer dual signatures
// 4. Validator signs vote with BOTH Ed25519 AND ML-DSA-65 keys
// 5. Proposer collects votes; block finalized at t-of-n dual-certified threshold
// 6. Finalized block (with dual vote signatures) broadcast to all
// 7. All nodes apply mutations deterministically
type ConsensusKV struct {
mu sync.RWMutex
state map[string][]byte // finalized state
// Pending mutations
pendingMu sync.Mutex
pending []KVMutation
pendingCh chan struct{}
// Node identity (dual keys)
nodeID string
edPrivKey ed25519.PrivateKey // Classical signing
edPubKey ed25519.PublicKey
pqPrivKey *mldsa.PrivateKey // Post-quantum signing (ML-DSA-65)
pqPubKey *mldsa.PublicKey
// Optional KMS/HSM signer (overrides local keys)
signer Signer
signerKeyID string
// Validator set
validators map[string]*ValidatorKeys // nodeID -> dual keys
threshold int // votes for finality
// Chain state
blockHeight uint64
lastBlockHash [32]byte
chainID string
// Vote tracking
voteMu sync.Mutex
currentVotes map[uint64]*voteCollector
// Transport
natsConn *nats.Conn
proposalInterval time.Duration
ctx context.Context
cancel context.CancelFunc
}
// ValidatorKeys holds both classical and post-quantum public keys for a validator.
type ValidatorKeys struct {
EdPubKey ed25519.PublicKey // Ed25519 (classical)
PQPubKey *mldsa.PublicKey // ML-DSA-65 (post-quantum)
}
// KVMutation represents a single key-value operation in a block.
type KVMutation struct {
Op MutationOp `json:"op"`
Key string `json:"key"`
Value []byte `json:"value,omitempty"`
}
// MutationOp is the type of KV mutation.
type MutationOp int
const (
OpPut MutationOp = 1
OpDelete MutationOp = 2
)
// ConsensusBlock is a block of KV mutations with dual signatures.
type ConsensusBlock struct {
Height uint64 `json:"height"`
ParentHash [32]byte `json:"parent_hash"`
Timestamp time.Time `json:"timestamp"`
Mutations []KVMutation `json:"mutations"`
ProposerID string `json:"proposer_id"`
EdSig []byte `json:"ed_sig"` // Ed25519 proposer signature
PQSig []byte `json:"pq_sig"` // ML-DSA-65 proposer signature
}
// BlockHash computes the deterministic hash of a block (excludes signatures).
func (b *ConsensusBlock) BlockHash() [32]byte {
data, _ := json.Marshal(struct {
Height uint64 `json:"height"`
ParentHash [32]byte `json:"parent_hash"`
Timestamp int64 `json:"timestamp"`
Mutations []KVMutation `json:"mutations"`
ProposerID string `json:"proposer_id"`
}{
Height: b.Height,
ParentHash: b.ParentHash,
Timestamp: b.Timestamp.UnixNano(),
Mutations: b.Mutations,
ProposerID: b.ProposerID,
})
return sha256.Sum256(data)
}
// FinalizedBlock is a block with collected dual-certificate votes proving consensus.
type FinalizedBlock struct {
Block ConsensusBlock `json:"block"`
Votes []Vote `json:"votes"`
}
// Vote is a validator's dual-signed approval of a block.
type Vote struct {
VoterID string `json:"voter_id"`
BlockHash [32]byte `json:"block_hash"`
Height uint64 `json:"height"`
EdSig []byte `json:"ed_sig"` // Ed25519 signature
PQSig []byte `json:"pq_sig"` // ML-DSA-65 signature
}
// voteCollector tracks votes for a specific block height.
type voteCollector struct {
blockHash [32]byte
block *ConsensusBlock
votes map[string]Vote
finalized bool
}
// Signer abstracts signing for consensus votes and block proposals.
// When set, delegates to KMS/HSM so the private key never leaves the secure enclave.
type Signer interface {
Sign(keyID string, message []byte) ([]byte, error)
}
// ConsensusKVConfig configures the private blockchain consensus.
type ConsensusKVConfig struct {
NodeID string
EdPrivateKey ed25519.PrivateKey // Classical identity key (nil if using Signer)
PQPrivateKey *mldsa.PrivateKey // Post-quantum identity key (nil = auto-generate)
Signer Signer // Optional KMS/HSM signer (overrides local keys)
SignerKeyID string // Key ID for Signer
Validators map[string]*ValidatorKeys // All cluster nodes
Threshold int // Votes for finality (default: floor(n/2)+1)
NATSConn *nats.Conn
ChainID string // Default "mpc"
ProposalInterval time.Duration // Default 50ms
// Deprecated: use EdPrivateKey instead
PrivateKey ed25519.PrivateKey
}
// NewConsensusKV creates a private blockchain consensus KV store with
// dual-certificate finality (Ed25519 + ML-DSA-65).
func NewConsensusKV(cfg ConsensusKVConfig) (*ConsensusKV, error) {
if cfg.NATSConn == nil {
return nil, fmt.Errorf("consensus_kv: NATS connection required")
}
if cfg.NodeID == "" {
return nil, fmt.Errorf("consensus_kv: node ID required")
}
if cfg.ProposalInterval == 0 {
cfg.ProposalInterval = 50 * time.Millisecond
}
if cfg.ChainID == "" {
cfg.ChainID = "mpc"
}
// Handle deprecated PrivateKey field
if cfg.EdPrivateKey == nil && cfg.PrivateKey != nil {
cfg.EdPrivateKey = cfg.PrivateKey
}
// Default threshold
n := len(cfg.Validators)
if cfg.Threshold == 0 {
if n > 0 {
cfg.Threshold = n/2 + 1
} else {
cfg.Threshold = 1
}
}
// Derive Ed25519 public key
var edPubKey ed25519.PublicKey
if cfg.EdPrivateKey != nil {
edPubKey = cfg.EdPrivateKey.Public().(ed25519.PublicKey)
}
// Auto-generate ML-DSA-65 key pair if not provided
pqPrivKey := cfg.PQPrivateKey
var pqPubKey *mldsa.PublicKey
if pqPrivKey == nil && cfg.Signer == nil {
var err error
pqPrivKey, err = mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
return nil, fmt.Errorf("consensus_kv: generate ML-DSA-65 key: %w", err)
}
logger.Info("Generated ML-DSA-65 post-quantum identity key",
"node", cfg.NodeID,
"pubkey_size", len(pqPrivKey.PublicKey.Bytes()),
)
}
if pqPrivKey != nil {
pqPubKey = pqPrivKey.PublicKey
}
ctx, cancel := context.WithCancel(context.Background())
ckv := &ConsensusKV{
state: make(map[string][]byte),
pending: make([]KVMutation, 0),
pendingCh: make(chan struct{}, 1),
nodeID: cfg.NodeID,
edPrivKey: cfg.EdPrivateKey,
edPubKey: edPubKey,
pqPrivKey: pqPrivKey,
pqPubKey: pqPubKey,
signer: cfg.Signer,
signerKeyID: cfg.SignerKeyID,
validators: cfg.Validators,
threshold: cfg.Threshold,
chainID: cfg.ChainID,
currentVotes: make(map[uint64]*voteCollector),
natsConn: cfg.NATSConn,
proposalInterval: cfg.ProposalInterval,
ctx: ctx,
cancel: cancel,
}
// Subscribe to proposals, votes, finalized blocks
proposalTopic := fmt.Sprintf("mpc.chain.%s.propose", cfg.ChainID)
if _, err := cfg.NATSConn.Subscribe(proposalTopic, func(msg *nats.Msg) {
ckv.handleProposal(msg.Data)
}); err != nil {
cancel()
return nil, fmt.Errorf("consensus_kv: subscribe proposals: %w", err)
}
voteTopic := fmt.Sprintf("mpc.chain.%s.vote", cfg.ChainID)
if _, err := cfg.NATSConn.Subscribe(voteTopic, func(msg *nats.Msg) {
ckv.handleVote(msg.Data)
}); err != nil {
cancel()
return nil, fmt.Errorf("consensus_kv: subscribe votes: %w", err)
}
finalTopic := fmt.Sprintf("mpc.chain.%s.finalized", cfg.ChainID)
if _, err := cfg.NATSConn.Subscribe(finalTopic, func(msg *nats.Msg) {
ckv.handleFinalized(msg.Data)
}); err != nil {
cancel()
return nil, fmt.Errorf("consensus_kv: subscribe finalized: %w", err)
}
go ckv.proposerLoop()
logger.Info("ConsensusKV started (dual-certificate BFT)",
"node", cfg.NodeID,
"chain", cfg.ChainID,
"validators", n,
"threshold", cfg.Threshold,
"classical", "Ed25519",
"post_quantum", "ML-DSA-65 (FIPS 204)",
"interval", cfg.ProposalInterval,
)
return ckv, nil
}
// --- KV Interface ---
func (c *ConsensusKV) Put(key string, value []byte) error {
c.pendingMu.Lock()
c.pending = append(c.pending, KVMutation{Op: OpPut, Key: key, Value: value})
c.pendingMu.Unlock()
select {
case c.pendingCh <- struct{}{}:
default:
}
// Apply locally for read-your-writes consistency
c.mu.Lock()
c.state[key] = value
c.mu.Unlock()
return nil
}
func (c *ConsensusKV) Get(key string) ([]byte, error) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.state[key]
if !ok {
return nil, nil
}
return v, nil
}
func (c *ConsensusKV) Delete(key string) error {
c.pendingMu.Lock()
c.pending = append(c.pending, KVMutation{Op: OpDelete, Key: key})
c.pendingMu.Unlock()
select {
case c.pendingCh <- struct{}{}:
default:
}
c.mu.Lock()
delete(c.state, key)
c.mu.Unlock()
return nil
}
func (c *ConsensusKV) List(prefix string) ([]*KVPair, error) {
c.mu.RLock()
defer c.mu.RUnlock()
var pairs []*KVPair
for k, v := range c.state {
if strings.HasPrefix(k, prefix) {
pairs = append(pairs, &KVPair{Key: k, Value: v})
}
}
return pairs, nil
}
func (c *ConsensusKV) Close() {
c.cancel()
}
// --- Dual Signing ---
// signEd signs a message with Ed25519 (classical).
func (c *ConsensusKV) signEd(message []byte) []byte {
if c.signer != nil {
sig, err := c.signer.Sign(c.signerKeyID+".ed25519", message)
if err != nil {
logger.Error("consensus_kv: KMS Ed25519 sign failed", err)
return nil
}
return sig
}
if c.edPrivKey != nil {
return ed25519.Sign(c.edPrivKey, message)
}
return nil
}
// signPQ signs a message with ML-DSA-65 (post-quantum).
func (c *ConsensusKV) signPQ(message []byte) []byte {
if c.signer != nil {
sig, err := c.signer.Sign(c.signerKeyID+".mldsa65", message)
if err != nil {
logger.Error("consensus_kv: KMS ML-DSA-65 sign failed", err)
return nil
}
return sig
}
if c.pqPrivKey != nil {
sig, err := c.pqPrivKey.Sign(nil, message, nil)
if err != nil {
logger.Error("consensus_kv: ML-DSA-65 sign failed", err)
return nil
}
return sig
}
return nil
}
// --- Proposer ---
func (c *ConsensusKV) proposerLoop() {
ticker := time.NewTicker(c.proposalInterval)
defer ticker.Stop()
for {
select {
case <-c.ctx.Done():
return
case <-ticker.C:
c.pendingMu.Lock()
if len(c.pending) == 0 {
c.pendingMu.Unlock()
continue
}
mutations := c.pending
c.pending = make([]KVMutation, 0)
c.pendingMu.Unlock()
c.proposeBlock(mutations)
case <-c.pendingCh:
// next tick will propose
}
}
}
func (c *ConsensusKV) proposeBlock(mutations []KVMutation) {
block := ConsensusBlock{
Height: c.blockHeight + 1,
ParentHash: c.lastBlockHash,
Timestamp: time.Now().UTC(),
Mutations: mutations,
ProposerID: c.nodeID,
}
blockHash := block.BlockHash()
block.EdSig = c.signEd(blockHash[:])
block.PQSig = c.signPQ(blockHash[:])
data, err := json.Marshal(block)
if err != nil {
logger.Error("consensus_kv: marshal proposal", err)
return
}
// Single node: self-finalize
if len(c.validators) <= 1 || c.threshold <= 1 {
c.selfFinalize(&block)
return
}
// Broadcast proposal
topic := fmt.Sprintf("mpc.chain.%s.propose", c.chainID)
if err := c.natsConn.Publish(topic, data); err != nil {
logger.Error("consensus_kv: publish proposal", err)
return
}
// Track votes
c.voteMu.Lock()
c.currentVotes[block.Height] = &voteCollector{
blockHash: blockHash,
block: &block,
votes: make(map[string]Vote),
}
c.voteMu.Unlock()
// Vote for own proposal
c.castVote(blockHash, block.Height)
}
func (c *ConsensusKV) selfFinalize(block *ConsensusBlock) {
blockHash := block.BlockHash()
finalized := FinalizedBlock{
Block: *block,
Votes: []Vote{{
VoterID: c.nodeID,
BlockHash: blockHash,
Height: block.Height,
EdSig: c.signEd(blockHash[:]),
PQSig: c.signPQ(blockHash[:]),
}},
}
c.applyFinalized(&finalized)
}
// --- Vote Handling ---
func (c *ConsensusKV) castVote(blockHash [32]byte, height uint64) {
vote := Vote{
VoterID: c.nodeID,
BlockHash: blockHash,
Height: height,
EdSig: c.signEd(blockHash[:]),
PQSig: c.signPQ(blockHash[:]),
}
data, err := json.Marshal(vote)
if err != nil {
return
}
topic := fmt.Sprintf("mpc.chain.%s.vote", c.chainID)
if err := c.natsConn.Publish(topic, data); err != nil {
logger.Error("consensus_kv: publish vote", err)
}
}
func (c *ConsensusKV) handleProposal(data []byte) {
var block ConsensusBlock
if err := json.Unmarshal(data, &block); err != nil {
logger.Error("consensus_kv: unmarshal proposal", err)
return
}
if block.Height <= c.blockHeight {
return // stale
}
// Verify dual proposer signatures
if !c.verifyProposerSig(&block) {
logger.Warn("consensus_kv: invalid proposer signature",
"proposer", block.ProposerID, "height", block.Height)
return
}
blockHash := block.BlockHash()
c.voteMu.Lock()
if _, exists := c.currentVotes[block.Height]; !exists {
c.currentVotes[block.Height] = &voteCollector{
blockHash: blockHash,
block: &block,
votes: make(map[string]Vote),
}
}
c.voteMu.Unlock()
c.castVote(blockHash, block.Height)
}
func (c *ConsensusKV) handleVote(data []byte) {
var vote Vote
if err := json.Unmarshal(data, &vote); err != nil {
return
}
// Verify voter is a known validator
vk, ok := c.validators[vote.VoterID]
if !ok {
return
}
// Verify BOTH signatures (dual certificate)
edValid := c.verifyEdSig(vk, vote.BlockHash[:], vote.EdSig)
pqValid := c.verifyPQSig(vk, vote.BlockHash[:], vote.PQSig)
if !edValid && !pqValid {
// In dev mode (nil keys), both return true -- this only rejects
// when keys are present but signatures are invalid
logger.Warn("consensus_kv: invalid vote signatures", "voter", vote.VoterID)
return
}
c.voteMu.Lock()
defer c.voteMu.Unlock()
collector, ok := c.currentVotes[vote.Height]
if !ok || collector.finalized {
return
}
if vote.BlockHash != collector.blockHash {
return
}
collector.votes[vote.VoterID] = vote
// Check threshold
if len(collector.votes) >= c.threshold {
collector.finalized = true
// Collect votes in deterministic order
var votes []Vote
voterIDs := make([]string, 0, len(collector.votes))
for id := range collector.votes {
voterIDs = append(voterIDs, id)
}
sort.Strings(voterIDs)
for _, id := range voterIDs {
votes = append(votes, collector.votes[id])
}
finalized := &FinalizedBlock{
Block: *collector.block,
Votes: votes,
}
c.applyFinalized(finalized)
// Broadcast
finalData, _ := json.Marshal(finalized)
topic := fmt.Sprintf("mpc.chain.%s.finalized", c.chainID)
if err := c.natsConn.Publish(topic, finalData); err != nil {
logger.Error("consensus_kv: publish finalized", err)
}
// Cleanup old collectors
for h := range c.currentVotes {
if h <= vote.Height {
delete(c.currentVotes, h)
}
}
}
}
func (c *ConsensusKV) handleFinalized(data []byte) {
var finalized FinalizedBlock
if err := json.Unmarshal(data, &finalized); err != nil {
return
}
if finalized.Block.Height <= c.blockHeight {
return
}
// Count valid dual-certified votes
validVotes := 0
for _, vote := range finalized.Votes {
vk, ok := c.validators[vote.VoterID]
if !ok {
continue
}
edOK := c.verifyEdSig(vk, vote.BlockHash[:], vote.EdSig)
pqOK := c.verifyPQSig(vk, vote.BlockHash[:], vote.PQSig)
// Vote counts if EITHER signature is valid (graceful degradation)
// but both SHOULD be present in production
if edOK || pqOK {
validVotes++
}
}
if validVotes < c.threshold {
logger.Warn("consensus_kv: insufficient valid votes",
"votes", validVotes, "threshold", c.threshold, "height", finalized.Block.Height)
return
}
c.applyFinalized(&finalized)
}
// --- Signature Verification ---
func (c *ConsensusKV) verifyEdSig(vk *ValidatorKeys, message, sig []byte) bool {
if vk == nil || vk.EdPubKey == nil {
return true // dev mode: no keys = accept
}
if sig == nil {
return false
}
return ed25519.Verify(vk.EdPubKey, message, sig)
}
func (c *ConsensusKV) verifyPQSig(vk *ValidatorKeys, message, sig []byte) bool {
if vk == nil || vk.PQPubKey == nil {
return true // dev mode or PQ not provisioned yet
}
if sig == nil {
return false
}
return vk.PQPubKey.VerifySignature(message, sig)
}
func (c *ConsensusKV) verifyProposerSig(block *ConsensusBlock) bool {
if len(c.validators) == 0 {
return true // dev mode
}
vk, ok := c.validators[block.ProposerID]
if !ok {
return false
}
blockHash := block.BlockHash()
edOK := c.verifyEdSig(vk, blockHash[:], block.EdSig)
pqOK := c.verifyPQSig(vk, blockHash[:], block.PQSig)
return edOK || pqOK // Accept if either is valid (graceful migration)
}
// --- State Application ---
func (c *ConsensusKV) applyFinalized(finalized *FinalizedBlock) {
c.mu.Lock()
defer c.mu.Unlock()
block := &finalized.Block
if block.Height <= c.blockHeight {
return
}
for _, m := range block.Mutations {
switch m.Op {
case OpPut:
c.state[m.Key] = m.Value
case OpDelete:
delete(c.state, m.Key)
}
}
c.lastBlockHash = block.BlockHash()
c.blockHeight = block.Height
logger.Info("Block finalized (dual-cert)",
"height", block.Height,
"mutations", len(block.Mutations),
"proposer", block.ProposerID,
"votes", len(finalized.Votes),
"hash", fmt.Sprintf("%x", c.lastBlockHash[:8]),
)
}
// --- State Management ---
func (c *ConsensusKV) Snapshot() map[string][]byte {
c.mu.RLock()
defer c.mu.RUnlock()
snap := make(map[string][]byte, len(c.state))
for k, v := range c.state {
snap[k] = v
}
return snap
}
func (c *ConsensusKV) LoadSnapshot(snap map[string][]byte, height uint64) error {
if snap == nil {
return errors.New("consensus_kv: nil snapshot")
}
c.mu.Lock()
defer c.mu.Unlock()
c.state = snap
c.blockHeight = height
logger.Info("ConsensusKV loaded snapshot", "height", height, "keys", len(snap))
return nil
}
func (c *ConsensusKV) Height() uint64 { return c.blockHeight }
func (c *ConsensusKV) LastBlockHash() [32]byte { return c.lastBlockHash }
func (c *ConsensusKV) ValidatorCount() int { return len(c.validators) }
func (c *ConsensusKV) Threshold() int { return c.threshold }
// PQPublicKey returns this node's ML-DSA-65 public key bytes (for registration).
func (c *ConsensusKV) PQPublicKey() []byte {
if c.pqPubKey != nil {
return c.pqPubKey.Bytes()
}
return nil
}
+54
View File
@@ -0,0 +1,54 @@
package infra
import (
"github.com/hashicorp/consul/api"
)
// ConsulKVAdapter wraps the existing ConsulKV interface to implement the
// abstract KV interface. This allows existing Consul-based deployments to
// continue working while new deployments use consensus-backed storage.
type ConsulKVAdapter struct {
consul ConsulKV
}
// NewConsulKVAdapter creates a KV implementation backed by Consul.
func NewConsulKVAdapter(consulKV ConsulKV) *ConsulKVAdapter {
return &ConsulKVAdapter{consul: consulKV}
}
func (c *ConsulKVAdapter) Put(key string, value []byte) error {
_, err := c.consul.Put(&api.KVPair{Key: key, Value: value}, nil)
return err
}
func (c *ConsulKVAdapter) Get(key string) ([]byte, error) {
pair, _, err := c.consul.Get(key, nil)
if err != nil {
return nil, err
}
if pair == nil {
return nil, nil
}
return pair.Value, nil
}
func (c *ConsulKVAdapter) Delete(key string) error {
_, err := c.consul.Delete(key, nil)
return err
}
func (c *ConsulKVAdapter) List(prefix string) ([]*KVPair, error) {
pairs, _, err := c.consul.List(prefix, nil)
if err != nil {
return nil, err
}
result := make([]*KVPair, 0, len(pairs))
for _, p := range pairs {
result = append(result, &KVPair{
Key: p.Key,
Value: p.Value,
})
}
return result, nil
}
+101
View File
@@ -0,0 +1,101 @@
package infra
import (
"github.com/luxfi/mpc/pkg/logger"
)
// HybridKV implements KV by routing key share material to an encrypted store
// (ZapDB/BadgerDB with AES-256) and metadata to SQLite.
//
// Routing rules:
// - "threshold_keyinfo/*" → encrypted KV (key share metadata bound to shares)
// - Everything else → SQLiteMeta (queryable, per-org indexed)
//
// The encrypted KV is any infra.KV implementation backed by ZapDB (typically
// ConsensusKV or a BadgerDB-backed KV adapter). Key shares themselves are
// stored directly in kvstore.BadgerKVStore and are NOT routed through this
// interface — this only handles the infra.KV traffic.
//
// Security invariant: threshold_keyinfo is co-located with the encrypted key
// share data in ZapDB so that a compromise of the SQLite file alone does NOT
// yield enough information to reconstruct signing capability.
type HybridKV struct {
encrypted KV // ZapDB-backed (ConsensusKV, NatsKV, or BadgerKV adapter)
meta KV // SQLiteMeta
}
// NewHybridKV creates a routing KV that sends key-share-adjacent data to the
// encrypted store and everything else to SQLite metadata.
func NewHybridKV(encrypted KV, meta KV) *HybridKV {
logger.Info("HybridKV initialized",
"encrypted_backend", backendName(encrypted),
"metadata_backend", "SQLiteMeta",
)
return &HybridKV{encrypted: encrypted, meta: meta}
}
func (h *HybridKV) Put(key string, value []byte) error {
return h.route(key).Put(key, value)
}
func (h *HybridKV) Get(key string) ([]byte, error) {
return h.route(key).Get(key)
}
func (h *HybridKV) Delete(key string) error {
return h.route(key).Delete(key)
}
func (h *HybridKV) List(prefix string) ([]*KVPair, error) {
return h.route(prefix).List(prefix)
}
// route returns the appropriate backend for a given key or prefix.
func (h *HybridKV) route(key string) KV {
if isKeyShareKey(key) {
return h.encrypted
}
return h.meta
}
// isKeyShareKey returns true if the key belongs with encrypted key share storage.
// threshold_keyinfo contains participant IDs and threshold — if leaked alongside
// the SQLite metadata, an attacker would know exactly which nodes to compromise.
// Keeping it in the encrypted store forces the attacker to break AES-256 first.
func isKeyShareKey(key string) bool {
// "threshold_keyinfo/" is 18 characters
const prefix = "threshold_keyinfo/"
return len(key) >= len(prefix) && key[:len(prefix)] == prefix
}
// Close closes both backends, returning the first error encountered.
func (h *HybridKV) Close() error {
var firstErr error
if c, ok := h.encrypted.(interface{ Close() error }); ok {
if err := c.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
if c, ok := h.meta.(interface{ Close() error }); ok {
if err := c.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// backendName returns a short name for logging.
func backendName(kv KV) string {
switch kv.(type) {
case *ConsensusKV:
return "ConsensusKV"
case *NatsKV:
return "NatsKV"
case *ConsulKVAdapter:
return "ConsulKV"
case *SQLiteMeta:
return "SQLiteMeta"
default:
return "unknown"
}
}
+126
View File
@@ -0,0 +1,126 @@
package infra
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestHybridKV(t *testing.T) (*HybridKV, *ConsensusKV, *SQLiteMeta) {
t.Helper()
encrypted := newTestConsensusKV()
meta := newTestSQLiteMeta(t)
hybrid := NewHybridKV(encrypted, meta)
return hybrid, encrypted, meta
}
// TestHybridKV_KVInterface runs the full KV test suite against the hybrid.
func TestHybridKV_KVInterface(t *testing.T) {
hybrid, _, _ := newTestHybridKV(t)
testKV(t, hybrid)
}
// TestHybridKV_KeyShareRouting verifies threshold_keyinfo goes to encrypted backend.
func TestHybridKV_KeyShareRouting(t *testing.T) {
hybrid, encrypted, meta := newTestHybridKV(t)
// Write key share metadata through hybrid
require.NoError(t, hybrid.Put("threshold_keyinfo/wallet-1", []byte(`{"threshold":2}`)))
// Should be in encrypted backend
val, err := encrypted.Get("threshold_keyinfo/wallet-1")
require.NoError(t, err)
assert.Equal(t, `{"threshold":2}`, string(val))
// Should NOT be in SQLite
val, err = meta.Get("threshold_keyinfo/wallet-1")
require.NoError(t, err)
assert.Nil(t, val, "key share metadata must not leak to SQLite")
}
// TestHybridKV_MetadataRouting verifies ready/ and api_keys/ go to SQLite.
func TestHybridKV_MetadataRouting(t *testing.T) {
hybrid, encrypted, meta := newTestHybridKV(t)
// Write peer readiness through hybrid
require.NoError(t, hybrid.Put("ready/node0", []byte("true")))
// Should be in SQLite
val, err := meta.Get("ready/node0")
require.NoError(t, err)
assert.Equal(t, "true", string(val))
// Should NOT be in encrypted backend
val, err = encrypted.Get("ready/node0")
require.NoError(t, err)
assert.Nil(t, val, "metadata must not be in encrypted store")
// Write API key through hybrid
apiJSON := `{"id":"abc1","name":"test","owner_id":"u1","key_hash":"hash1","created_at":"2026-01-01T00:00:00Z","last_used":""}`
require.NoError(t, hybrid.Put("api_keys/hash1", []byte(apiJSON)))
// Should be in SQLite
val, err = meta.Get("api_keys/hash1")
require.NoError(t, err)
assert.NotNil(t, val)
// Should NOT be in encrypted backend
val, err = encrypted.Get("api_keys/hash1")
require.NoError(t, err)
assert.Nil(t, val)
}
// TestHybridKV_ListRouting verifies List returns from the correct backend.
func TestHybridKV_ListRouting(t *testing.T) {
hybrid, _, _ := newTestHybridKV(t)
// Populate both sides
require.NoError(t, hybrid.Put("threshold_keyinfo/w1", []byte(`{"threshold":2}`)))
require.NoError(t, hybrid.Put("threshold_keyinfo/w2", []byte(`{"threshold":3}`)))
require.NoError(t, hybrid.Put("ready/node0", []byte("true")))
require.NoError(t, hybrid.Put("ready/node1", []byte("true")))
// List key share metadata — from encrypted
pairs, err := hybrid.List("threshold_keyinfo/")
require.NoError(t, err)
assert.Len(t, pairs, 2)
// List peers — from SQLite
pairs, err = hybrid.List("ready/")
require.NoError(t, err)
assert.Len(t, pairs, 2)
}
// TestHybridKV_DeleteRouting verifies Delete hits the correct backend.
func TestHybridKV_DeleteRouting(t *testing.T) {
hybrid, encrypted, meta := newTestHybridKV(t)
require.NoError(t, hybrid.Put("threshold_keyinfo/w1", []byte(`{"threshold":2}`)))
require.NoError(t, hybrid.Put("ready/node0", []byte("true")))
// Delete key share metadata
require.NoError(t, hybrid.Delete("threshold_keyinfo/w1"))
val, err := encrypted.Get("threshold_keyinfo/w1")
require.NoError(t, err)
assert.Nil(t, val)
// Delete peer
require.NoError(t, hybrid.Delete("ready/node0"))
val, err = meta.Get("ready/node0")
require.NoError(t, err)
assert.Nil(t, val)
}
// TestHybridKV_IsKeyShareKey verifies the routing predicate.
func TestHybridKV_IsKeyShareKey(t *testing.T) {
assert.True(t, isKeyShareKey("threshold_keyinfo/wallet-1"))
assert.True(t, isKeyShareKey("threshold_keyinfo/abc"))
assert.False(t, isKeyShareKey("threshold_keyinf")) // too short
assert.False(t, isKeyShareKey("ready/node0"))
assert.False(t, isKeyShareKey("api_keys/hash"))
assert.False(t, isKeyShareKey("config/something"))
assert.False(t, isKeyShareKey(""))
}
+22
View File
@@ -0,0 +1,22 @@
package infra
// KV is the abstract key-value store interface for MPC cluster state.
// Replaces Consul KV. Implementations: NatsKV (NATS JetStream KV),
// BadgerKV (local BadgerDB), LuxKV (Lux consensus-backed).
//
// All MPC subsystems (PeerRegistry, KeyInfoStore, APIKeyStore, WalletOwner)
// use this interface through the ConsulKV compatibility adapter.
// KVPair mirrors the consul api.KVPair for drop-in compatibility.
type KVPair struct {
Key string
Value []byte
}
// KV is the native key-value interface for MPC state management.
type KV interface {
Put(key string, value []byte) error
Get(key string) ([]byte, error)
Delete(key string) error
List(prefix string) ([]*KVPair, error)
}
+340
View File
@@ -0,0 +1,340 @@
package infra
import (
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"testing"
"time"
"github.com/luxfi/crypto/mldsa"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testKV runs a standard suite of KV operations against any KV implementation.
// Used to verify 100% parity between consensus, NATS, and Consul backends.
func testKV(t *testing.T, kv KV) {
t.Helper()
// Put + Get
err := kv.Put("ready/node0", []byte("true"))
require.NoError(t, err)
val, err := kv.Get("ready/node0")
require.NoError(t, err)
assert.Equal(t, []byte("true"), val)
// Get non-existent
val, err = kv.Get("ready/nonexistent")
require.NoError(t, err)
assert.Nil(t, val)
// Put more keys
require.NoError(t, kv.Put("ready/node1", []byte("true")))
require.NoError(t, kv.Put("ready/node2", []byte("true")))
require.NoError(t, kv.Put("threshold_keyinfo/wallet1", []byte(`{"threshold":2}`)))
// List with prefix
pairs, err := kv.List("ready/")
require.NoError(t, err)
assert.Len(t, pairs, 3)
keys := make(map[string]string)
for _, p := range pairs {
keys[p.Key] = string(p.Value)
}
assert.Equal(t, "true", keys["ready/node0"])
assert.Equal(t, "true", keys["ready/node1"])
assert.Equal(t, "true", keys["ready/node2"])
// List different prefix
pairs, err = kv.List("threshold_keyinfo/")
require.NoError(t, err)
assert.Len(t, pairs, 1)
// Delete
require.NoError(t, kv.Delete("ready/node1"))
val, err = kv.Get("ready/node1")
require.NoError(t, err)
assert.Nil(t, val)
// List after delete
pairs, err = kv.List("ready/")
require.NoError(t, err)
assert.Len(t, pairs, 2)
// Delete non-existent (no error)
require.NoError(t, kv.Delete("ready/nonexistent"))
// Empty prefix
pairs, err = kv.List("nothing/")
require.NoError(t, err)
assert.Len(t, pairs, 0)
// Overwrite
require.NoError(t, kv.Put("ready/node0", []byte("false")))
val, err = kv.Get("ready/node0")
require.NoError(t, err)
assert.Equal(t, []byte("false"), val)
}
func newTestConsensusKV() *ConsensusKV {
return &ConsensusKV{
state: make(map[string][]byte),
pending: make([]KVMutation, 0),
pendingCh: make(chan struct{}, 1),
nodeID: "test-node",
validators: make(map[string]*ValidatorKeys),
threshold: 1,
chainID: "test",
currentVotes: make(map[uint64]*voteCollector),
}
}
func TestConsensusKV_KVInterface(t *testing.T) {
testKV(t, newTestConsensusKV())
}
func TestConsensusKV_Snapshot(t *testing.T) {
ckv := newTestConsensusKV()
testKV(t, ckv)
snap := ckv.Snapshot()
assert.Contains(t, snap, "ready/node0")
assert.Contains(t, snap, "ready/node2")
assert.NotContains(t, snap, "ready/node1")
ckv2 := newTestConsensusKV()
require.NoError(t, ckv2.LoadSnapshot(snap, 42))
assert.Equal(t, uint64(42), ckv2.Height())
val, _ := ckv2.Get("ready/node0")
assert.Equal(t, []byte("false"), val)
}
func TestConsensusKV_BlockHash(t *testing.T) {
block := ConsensusBlock{
Height: 1,
Timestamp: time.Date(2026, 3, 11, 0, 0, 0, 0, time.UTC),
Mutations: []KVMutation{{Op: OpPut, Key: "foo", Value: []byte("bar")}},
ProposerID: "node0",
}
hash1 := block.BlockHash()
hash2 := block.BlockHash()
assert.Equal(t, hash1, hash2, "deterministic")
block.Mutations[0].Value = []byte("baz")
assert.NotEqual(t, hash1, block.BlockHash(), "different content = different hash")
}
func TestConsensusKV_BlockApply(t *testing.T) {
ckv := newTestConsensusKV()
block := ConsensusBlock{
Height: 1,
Timestamp: time.Now().UTC(),
Mutations: []KVMutation{
{Op: OpPut, Key: "ready/node0", Value: []byte("true")},
{Op: OpPut, Key: "ready/node1", Value: []byte("true")},
},
ProposerID: "node0",
}
ckv.applyFinalized(&FinalizedBlock{
Block: block,
Votes: []Vote{{VoterID: "node0", BlockHash: block.BlockHash(), Height: 1}},
})
val, _ := ckv.Get("ready/node0")
assert.Equal(t, []byte("true"), val)
assert.Equal(t, uint64(1), ckv.Height())
// Delete block
block2 := ConsensusBlock{
Height: 2,
ParentHash: ckv.LastBlockHash(),
Timestamp: time.Now().UTC(),
Mutations: []KVMutation{{Op: OpDelete, Key: "ready/node1"}},
ProposerID: "node0",
}
ckv.applyFinalized(&FinalizedBlock{
Block: block2,
Votes: []Vote{{VoterID: "node0", BlockHash: block2.BlockHash(), Height: 2}},
})
val, _ = ckv.Get("ready/node1")
assert.Nil(t, val)
assert.Equal(t, uint64(2), ckv.Height())
}
// TestConsensusKV_DualCertificate verifies Ed25519 + ML-DSA-65 dual signing.
func TestConsensusKV_DualCertificate(t *testing.T) {
// Generate dual keys for 3 validators
edPub0, edPriv0, _ := ed25519.GenerateKey(rand.Reader)
edPub1, edPriv1, _ := ed25519.GenerateKey(rand.Reader)
edPub2, _, _ := ed25519.GenerateKey(rand.Reader)
pqPriv0, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(t, err)
pqPriv1, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(t, err)
pqPriv2, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(t, err)
validators := map[string]*ValidatorKeys{
"node0": {EdPubKey: edPub0, PQPubKey: pqPriv0.PublicKey},
"node1": {EdPubKey: edPub1, PQPubKey: pqPriv1.PublicKey},
"node2": {EdPubKey: edPub2, PQPubKey: pqPriv2.PublicKey},
}
// Node0 proposes a block with dual signatures
block := ConsensusBlock{
Height: 1,
Timestamp: time.Now().UTC(),
Mutations: []KVMutation{{Op: OpPut, Key: "ready/node0", Value: []byte("true")}},
ProposerID: "node0",
}
blockHash := block.BlockHash()
// Sign with Ed25519
block.EdSig = ed25519.Sign(edPriv0, blockHash[:])
assert.True(t, ed25519.Verify(edPub0, blockHash[:], block.EdSig))
// Sign with ML-DSA-65
pqSig0, err := pqPriv0.Sign(nil, blockHash[:], nil)
require.NoError(t, err)
block.PQSig = pqSig0
assert.True(t, pqPriv0.PublicKey.VerifySignature(blockHash[:], pqSig0))
// Node0 and Node1 cast dual-signed votes
pqSig0Vote, _ := pqPriv0.Sign(nil, blockHash[:], nil)
pqSig1Vote, _ := pqPriv1.Sign(nil, blockHash[:], nil)
vote0 := Vote{
VoterID: "node0",
BlockHash: blockHash,
Height: 1,
EdSig: ed25519.Sign(edPriv0, blockHash[:]),
PQSig: pqSig0Vote,
}
vote1 := Vote{
VoterID: "node1",
BlockHash: blockHash,
Height: 1,
EdSig: ed25519.Sign(edPriv1, blockHash[:]),
PQSig: pqSig1Vote,
}
// Verify both signatures on both votes
assert.True(t, ed25519.Verify(edPub0, blockHash[:], vote0.EdSig))
assert.True(t, pqPriv0.PublicKey.VerifySignature(blockHash[:], vote0.PQSig))
assert.True(t, ed25519.Verify(edPub1, blockHash[:], vote1.EdSig))
assert.True(t, pqPriv1.PublicKey.VerifySignature(blockHash[:], vote1.PQSig))
// Apply finalized block to node2
ckv := &ConsensusKV{
state: make(map[string][]byte),
pending: make([]KVMutation, 0),
pendingCh: make(chan struct{}, 1),
nodeID: "node2",
validators: validators,
threshold: 2,
chainID: "test",
currentVotes: make(map[uint64]*voteCollector),
}
finalized := FinalizedBlock{Block: block, Votes: []Vote{vote0, vote1}}
ckv.applyFinalized(&finalized)
val, _ := ckv.Get("ready/node0")
assert.Equal(t, []byte("true"), val)
assert.Equal(t, uint64(1), ckv.Height())
}
// TestConsensusKV_DualCertVerification tests that handleFinalized verifies signatures.
func TestConsensusKV_DualCertVerification(t *testing.T) {
edPub0, edPriv0, _ := ed25519.GenerateKey(rand.Reader)
edPub1, _, _ := ed25519.GenerateKey(rand.Reader)
edPub2, _, _ := ed25519.GenerateKey(rand.Reader)
pqPriv0, _ := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
pqPriv1, _ := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
pqPriv2, _ := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
validators := map[string]*ValidatorKeys{
"node0": {EdPubKey: edPub0, PQPubKey: pqPriv0.PublicKey},
"node1": {EdPubKey: edPub1, PQPubKey: pqPriv1.PublicKey},
"node2": {EdPubKey: edPub2, PQPubKey: pqPriv2.PublicKey},
}
ckv := &ConsensusKV{
state: make(map[string][]byte),
pending: make([]KVMutation, 0),
pendingCh: make(chan struct{}, 1),
nodeID: "node2",
validators: validators,
threshold: 2, // need 2 votes
chainID: "test",
currentVotes: make(map[uint64]*voteCollector),
}
block := ConsensusBlock{
Height: 1,
Timestamp: time.Now().UTC(),
Mutations: []KVMutation{{Op: OpPut, Key: "test/key", Value: []byte("val")}},
ProposerID: "node0",
}
blockHash := block.BlockHash()
// Only 1 valid dual-signed vote — should NOT apply (threshold=2)
pqSig, _ := pqPriv0.Sign(nil, blockHash[:], nil)
finalized := FinalizedBlock{
Block: block,
Votes: []Vote{{
VoterID: "node0",
BlockHash: blockHash,
Height: 1,
EdSig: ed25519.Sign(edPriv0, blockHash[:]),
PQSig: pqSig,
}},
}
data, _ := json.Marshal(finalized)
ckv.handleFinalized(data)
val, _ := ckv.Get("test/key")
assert.Nil(t, val, "insufficient votes should not apply")
assert.Equal(t, uint64(0), ckv.Height())
}
func TestConsensusKV_PQKeyGeneration(t *testing.T) {
// Verify ML-DSA-65 key sizes
priv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(t, err)
assert.Equal(t, mldsa.MLDSA65PublicKeySize, len(priv.PublicKey.Bytes())) // 1,952 bytes
assert.Equal(t, mldsa.MLDSA65PrivateKeySize, len(priv.Bytes())) // 4,032 bytes
// Sign and verify
msg := []byte("test message for post-quantum signing")
sig, err := priv.Sign(nil, msg, nil)
require.NoError(t, err)
assert.Equal(t, mldsa.MLDSA65SignatureSize, len(sig)) // 3,309 bytes
assert.True(t, priv.PublicKey.VerifySignature(msg, sig))
// Tampered message should fail
assert.False(t, priv.PublicKey.VerifySignature([]byte("wrong"), sig))
t.Logf("ML-DSA-65 sizes: pubkey=%d, privkey=%d, sig=%d",
len(priv.PublicKey.Bytes()), len(priv.Bytes()), len(sig))
}
func TestConsensusKV_Metadata(t *testing.T) {
ckv := newTestConsensusKV()
ckv.validators = map[string]*ValidatorKeys{
"node0": nil, "node1": nil, "node2": nil,
}
ckv.threshold = 2
assert.Equal(t, 3, ckv.ValidatorCount())
assert.Equal(t, 2, ckv.Threshold())
}
+144
View File
@@ -0,0 +1,144 @@
package infra
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/luxfi/mpc/pkg/logger"
)
// NatsKV implements KV using NATS JetStream Key-Value store.
// This replaces Consul KV — MPC already depends on NATS for messaging,
// so this eliminates the Consul server entirely.
//
// JetStream KV provides:
// - Persistent key-value storage (replicated across NATS cluster)
// - Watch/subscribe for key changes (peer readiness)
// - TTL support for ephemeral keys
// - History for key versioning
type NatsKV struct {
kv jetstream.KeyValue
bucket string
}
// NewNatsKV creates a NATS JetStream KV store, creating the bucket if needed.
func NewNatsKV(nc *nats.Conn, bucket string) (*NatsKV, error) {
js, err := jetstream.New(nc)
if err != nil {
return nil, fmt.Errorf("nats_kv: failed to create JetStream context: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Try to bind to existing bucket, create if not found
kv, err := js.KeyValue(ctx, bucket)
if err != nil {
// Create the bucket
kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: bucket,
Description: "MPC cluster state (replaces Consul KV)",
History: 5,
TTL: 0, // no global TTL
Storage: jetstream.FileStorage,
Replicas: 1, // increase for HA
})
if err != nil {
return nil, fmt.Errorf("nats_kv: failed to create bucket %q: %w", bucket, err)
}
logger.Info("Created NATS KV bucket", "bucket", bucket)
}
return &NatsKV{kv: kv, bucket: bucket}, nil
}
func (n *NatsKV) Put(key string, value []byte) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// NATS KV uses "." as separator, Consul uses "/". Convert.
natsKey := consulKeyToNats(key)
_, err := n.kv.Put(ctx, natsKey, value)
if err != nil {
return fmt.Errorf("nats_kv: put %q failed: %w", key, err)
}
return nil
}
func (n *NatsKV) Get(key string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
natsKey := consulKeyToNats(key)
entry, err := n.kv.Get(ctx, natsKey)
if err != nil {
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, nil // not found = nil, matching Consul behavior
}
return nil, fmt.Errorf("nats_kv: get %q failed: %w", key, err)
}
return entry.Value(), nil
}
func (n *NatsKV) Delete(key string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
natsKey := consulKeyToNats(key)
err := n.kv.Delete(ctx, natsKey)
if err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
return fmt.Errorf("nats_kv: delete %q failed: %w", key, err)
}
return nil
}
func (n *NatsKV) List(prefix string) ([]*KVPair, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
natsPrefix := consulKeyToNats(prefix)
// List all keys matching prefix
keys, err := n.kv.ListKeys(ctx, jetstream.MetaOnly())
if err != nil {
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return nil, fmt.Errorf("nats_kv: list %q failed: %w", prefix, err)
}
var pairs []*KVPair
for key := range keys.Keys() {
if !strings.HasPrefix(key, natsPrefix) {
continue
}
entry, getErr := n.kv.Get(ctx, key)
if getErr != nil {
continue // skip deleted/expired
}
pairs = append(pairs, &KVPair{
Key: natsKeyToConsul(key),
Value: entry.Value(),
})
}
return pairs, nil
}
// consulKeyToNats converts Consul-style "ready/node0" to NATS-style "ready.node0"
func consulKeyToNats(key string) string {
return strings.ReplaceAll(key, "/", ".")
}
// natsKeyToConsul converts NATS-style "ready.node0" back to "ready/node0"
func natsKeyToConsul(key string) string {
return strings.ReplaceAll(key, ".", "/")
}
+448
View File
@@ -0,0 +1,448 @@
package infra
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/luxfi/mpc/pkg/logger"
"github.com/hanzoai/dbx"
_ "modernc.org/sqlite"
)
// SQLiteMeta implements KV using SQLite (via hanzoai/dbx) for queryable metadata.
// Key shares NEVER touch this store — only wallets, peers, API keys, audit logs.
//
// org_id column reserved for future per-org isolation. Not enforced yet.
//
// Schema is auto-migrated on open (idempotent CREATE TABLE IF NOT EXISTS).
type SQLiteMeta struct {
db *dbx.DB
mu sync.RWMutex
path string
}
// SQLiteMetaConfig configures the SQLite metadata store.
type SQLiteMetaConfig struct {
// Path to the SQLite database file. Use ":memory:" for testing.
Path string
// WAL enables write-ahead logging (recommended for concurrent reads).
WAL bool
}
// NewSQLiteMeta opens (or creates) a SQLite metadata store and runs migrations.
func NewSQLiteMeta(cfg SQLiteMetaConfig) (*SQLiteMeta, error) {
if cfg.Path == "" {
return nil, fmt.Errorf("sqlite_meta: path required")
}
dsn := cfg.Path
if cfg.WAL && dsn != ":memory:" {
dsn += "?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL&_foreign_keys=ON"
} else if dsn != ":memory:" {
dsn += "?_busy_timeout=5000&_foreign_keys=ON"
}
db, err := dbx.MustOpen("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("sqlite_meta: open %q: %w", cfg.Path, err)
}
s := &SQLiteMeta{db: db, path: cfg.Path}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("sqlite_meta: migrate: %w", err)
}
logger.Info("SQLiteMeta opened", "path", cfg.Path, "wal", cfg.WAL)
return s, nil
}
// migrate creates tables idempotently.
func (s *SQLiteMeta) migrate() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS wallets (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL DEFAULT '',
curve TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
public_key TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_wallets_org ON wallets(org_id)`,
`CREATE INDEX IF NOT EXISTS idx_wallets_owner ON wallets(owner)`,
`CREATE TABLE IF NOT EXISTS peers (
node_id TEXT PRIMARY KEY,
org_id TEXT NOT NULL DEFAULT '',
ready INTEGER NOT NULL DEFAULT 0,
last_seen TEXT NOT NULL DEFAULT (datetime('now')),
metadata TEXT NOT NULL DEFAULT '{}'
)`,
`CREATE INDEX IF NOT EXISTS idx_peers_org ON peers(org_id)`,
`CREATE TABLE IF NOT EXISTS api_keys (
key_hash TEXT PRIMARY KEY,
org_id TEXT NOT NULL DEFAULT '',
id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
owner_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_api_keys_org ON api_keys(org_id)`,
`CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner_id)`,
`CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT '',
resource TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_audit_org ON audit_log(org_id)`,
`CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action)`,
`CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at)`,
// Generic KV fallback for keys that don't map to a typed table.
// This ensures 100% KV interface compatibility.
`CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY,
value BLOB NOT NULL
)`,
}
for _, stmt := range stmts {
if _, err := s.db.NewQuery(stmt).Execute(); err != nil {
return fmt.Errorf("exec %q: %w", stmt[:min(len(stmt), 60)], err)
}
}
return nil
}
// --- KV Interface (infra.KV) ---
func (s *SQLiteMeta) Put(key string, value []byte) error {
if isKeyShareKey(key) {
return fmt.Errorf("key share keys must use encrypted backend, not SQLite")
}
s.mu.Lock()
defer s.mu.Unlock()
// Route to typed tables when possible, fall back to generic KV.
switch {
case strings.HasPrefix(key, "api_keys/"):
return s.putAPIKey(key, value)
case strings.HasPrefix(key, "ready/"):
return s.putPeer(key, value)
default:
return s.putGenericKV(key, value)
}
}
func (s *SQLiteMeta) Get(key string) ([]byte, error) {
s.mu.RLock()
defer s.mu.RUnlock()
switch {
case strings.HasPrefix(key, "api_keys/"):
return s.getAPIKey(key)
case strings.HasPrefix(key, "ready/"):
return s.getPeer(key)
default:
return s.getGenericKV(key)
}
}
func (s *SQLiteMeta) Delete(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
switch {
case strings.HasPrefix(key, "api_keys/"):
hash := strings.TrimPrefix(key, "api_keys/")
_, err := s.db.NewQuery("DELETE FROM api_keys WHERE key_hash={:hash}").Bind(dbx.Params{"hash": hash}).Execute()
return err
case strings.HasPrefix(key, "ready/"):
nodeID := strings.TrimPrefix(key, "ready/")
_, err := s.db.NewQuery("DELETE FROM peers WHERE node_id={:id}").Bind(dbx.Params{"id": nodeID}).Execute()
return err
default:
_, err := s.db.NewQuery("DELETE FROM kv WHERE key={:key}").Bind(dbx.Params{"key": key}).Execute()
return err
}
}
func (s *SQLiteMeta) List(prefix string) ([]*KVPair, error) {
s.mu.RLock()
defer s.mu.RUnlock()
switch {
case strings.HasPrefix(prefix, "api_keys/"):
return s.listAPIKeys()
case strings.HasPrefix(prefix, "ready/"):
return s.listPeers()
default:
return s.listGenericKV(prefix)
}
}
// --- Typed Table Operations ---
func (s *SQLiteMeta) putAPIKey(key string, value []byte) error {
hash := strings.TrimPrefix(key, "api_keys/")
// Parse JSON to extract structured fields.
var rec struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerID string `json:"owner_id"`
KeyHash string `json:"key_hash"`
CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"`
}
if err := json.Unmarshal(value, &rec); err != nil {
// Fall back to generic KV if not valid API key JSON.
return s.putGenericKV(key, value)
}
q := s.db.NewQuery(`INSERT INTO api_keys (key_hash, id, name, owner_id, created_at, last_used)
VALUES ({:hash}, {:id}, {:name}, {:owner}, {:created}, {:used})
ON CONFLICT(key_hash) DO UPDATE SET
name=excluded.name, last_used=excluded.last_used`)
q.Bind(dbx.Params{
"hash": hash,
"id": rec.ID,
"name": rec.Name,
"owner": rec.OwnerID,
"created": rec.CreatedAt,
"used": rec.LastUsed,
})
_, err := q.Execute()
return err
}
func (s *SQLiteMeta) getAPIKey(key string) ([]byte, error) {
hash := strings.TrimPrefix(key, "api_keys/")
var row struct {
KeyHash string `db:"key_hash"`
ID string `db:"id"`
Name string `db:"name"`
OwnerID string `db:"owner_id"`
CreatedAt string `db:"created_at"`
LastUsed string `db:"last_used"`
}
err := s.db.NewQuery("SELECT key_hash, id, name, owner_id, created_at, last_used FROM api_keys WHERE key_hash={:hash}").
Bind(dbx.Params{"hash": hash}).
One(&row)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
// Reconstruct the JSON that the APIKeyStore expects.
out := map[string]any{
"id": row.ID,
"name": row.Name,
"owner_id": row.OwnerID,
"key_hash": row.KeyHash,
"created_at": row.CreatedAt,
"last_used": row.LastUsed,
}
return json.Marshal(out)
}
func (s *SQLiteMeta) listAPIKeys() ([]*KVPair, error) {
var rows []struct {
KeyHash string `db:"key_hash"`
ID string `db:"id"`
Name string `db:"name"`
OwnerID string `db:"owner_id"`
CreatedAt string `db:"created_at"`
LastUsed string `db:"last_used"`
}
err := s.db.NewQuery("SELECT key_hash, id, name, owner_id, created_at, last_used FROM api_keys").All(&rows)
if err != nil {
return nil, err
}
pairs := make([]*KVPair, 0, len(rows))
for _, r := range rows {
val, _ := json.Marshal(map[string]any{
"id": r.ID,
"name": r.Name,
"owner_id": r.OwnerID,
"key_hash": r.KeyHash,
"created_at": r.CreatedAt,
"last_used": r.LastUsed,
})
pairs = append(pairs, &KVPair{
Key: "api_keys/" + r.KeyHash,
Value: val,
})
}
return pairs, nil
}
func (s *SQLiteMeta) putPeer(key string, value []byte) error {
nodeID := strings.TrimPrefix(key, "ready/")
ready := 0
if string(value) == "true" {
ready = 1
}
q := s.db.NewQuery(`INSERT INTO peers (node_id, ready, last_seen)
VALUES ({:id}, {:ready}, {:seen})
ON CONFLICT(node_id) DO UPDATE SET
ready=excluded.ready, last_seen=excluded.last_seen`)
q.Bind(dbx.Params{
"id": nodeID,
"ready": ready,
"seen": time.Now().UTC().Format(time.RFC3339),
})
_, err := q.Execute()
return err
}
func (s *SQLiteMeta) getPeer(key string) ([]byte, error) {
nodeID := strings.TrimPrefix(key, "ready/")
var row struct {
NodeID string `db:"node_id"`
Ready int `db:"ready"`
}
err := s.db.NewQuery("SELECT node_id, ready FROM peers WHERE node_id={:id}").
Bind(dbx.Params{"id": nodeID}).
One(&row)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
if row.Ready == 1 {
return []byte("true"), nil
}
return []byte("false"), nil
}
func (s *SQLiteMeta) listPeers() ([]*KVPair, error) {
var rows []struct {
NodeID string `db:"node_id"`
Ready int `db:"ready"`
}
err := s.db.NewQuery("SELECT node_id, ready FROM peers WHERE ready=1").All(&rows)
if err != nil {
return nil, err
}
pairs := make([]*KVPair, 0, len(rows))
for _, r := range rows {
pairs = append(pairs, &KVPair{
Key: "ready/" + r.NodeID,
Value: []byte("true"),
})
}
return pairs, nil
}
// --- Generic KV (fallback for untyped keys) ---
func (s *SQLiteMeta) putGenericKV(key string, value []byte) error {
q := s.db.NewQuery(`INSERT INTO kv (key, value) VALUES ({:key}, {:val})
ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
q.Bind(dbx.Params{"key": key, "val": value})
_, err := q.Execute()
return err
}
func (s *SQLiteMeta) getGenericKV(key string) ([]byte, error) {
var row struct {
Value []byte `db:"value"`
}
err := s.db.NewQuery("SELECT value FROM kv WHERE key={:key}").
Bind(dbx.Params{"key": key}).
One(&row)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return row.Value, nil
}
func (s *SQLiteMeta) listGenericKV(prefix string) ([]*KVPair, error) {
// SQLite LIKE with prefix: "foo/" → "foo/%".
// Escape % and _ to prevent wildcard injection regardless of input source.
escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(prefix)
pattern := escaped + "%"
var rows []struct {
Key string `db:"key"`
Value []byte `db:"value"`
}
err := s.db.NewQuery("SELECT key, value FROM kv WHERE key LIKE {:pattern} ESCAPE '\\'").
Bind(dbx.Params{"pattern": pattern}).
All(&rows)
if err != nil {
return nil, err
}
pairs := make([]*KVPair, 0, len(rows))
for _, r := range rows {
pairs = append(pairs, &KVPair{Key: r.Key, Value: r.Value})
}
return pairs, nil
}
// --- Audit Log ---
// AuditEntry is a structured audit record.
type AuditEntry struct {
OrgID string `json:"org_id"`
Action string `json:"action"`
Actor string `json:"actor"`
Resource string `json:"resource"`
Detail string `json:"detail"`
}
// Audit writes a structured audit log entry.
func (s *SQLiteMeta) Audit(entry AuditEntry) error {
s.mu.Lock()
defer s.mu.Unlock()
q := s.db.NewQuery(`INSERT INTO audit_log (org_id, action, actor, resource, detail)
VALUES ({:org}, {:action}, {:actor}, {:resource}, {:detail})`)
q.Bind(dbx.Params{
"org": entry.OrgID,
"action": entry.Action,
"actor": entry.Actor,
"resource": entry.Resource,
"detail": entry.Detail,
})
_, err := q.Execute()
return err
}
// Close closes the underlying SQLite database.
func (s *SQLiteMeta) Close() error {
return s.db.Close()
}
+230
View File
@@ -0,0 +1,230 @@
package infra
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestSQLiteMeta(t *testing.T) *SQLiteMeta {
t.Helper()
s, err := NewSQLiteMeta(SQLiteMetaConfig{Path: ":memory:"})
require.NoError(t, err)
t.Cleanup(func() { s.Close() })
return s
}
// TestSQLiteMeta_KVInterface exercises metadata-only KV operations.
// The full testKV suite includes threshold_keyinfo keys which SQLiteMeta
// correctly rejects (F5). HybridKV runs the full suite via routing.
func TestSQLiteMeta_KVInterface(t *testing.T) {
s := newTestSQLiteMeta(t)
// Put + Get
require.NoError(t, s.Put("ready/node0", []byte("true")))
val, err := s.Get("ready/node0")
require.NoError(t, err)
assert.Equal(t, []byte("true"), val)
// Get non-existent
val, err = s.Get("ready/nonexistent")
require.NoError(t, err)
assert.Nil(t, val)
// Put more keys
require.NoError(t, s.Put("ready/node1", []byte("true")))
require.NoError(t, s.Put("ready/node2", []byte("true")))
require.NoError(t, s.Put("config/timeout", []byte("30")))
// List with prefix
pairs, err := s.List("ready/")
require.NoError(t, err)
assert.Len(t, pairs, 3)
// Delete
require.NoError(t, s.Delete("ready/node1"))
val, err = s.Get("ready/node1")
require.NoError(t, err)
assert.Nil(t, val)
// List after delete
pairs, err = s.List("ready/")
require.NoError(t, err)
assert.Len(t, pairs, 2)
// Overwrite
require.NoError(t, s.Put("ready/node0", []byte("false")))
val, err = s.Get("ready/node0")
require.NoError(t, err)
assert.Equal(t, []byte("false"), val)
}
// TestSQLiteMeta_RejectsKeyShareKeys verifies that key share keys are refused.
func TestSQLiteMeta_RejectsKeyShareKeys(t *testing.T) {
s := newTestSQLiteMeta(t)
err := s.Put("threshold_keyinfo/wallet-1", []byte(`{"threshold":2}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "key share keys must use encrypted backend")
}
func TestSQLiteMeta_APIKeyRoundTrip(t *testing.T) {
s := newTestSQLiteMeta(t)
key := "api_keys/abc123def456"
val := `{"id":"abc1","name":"test-key","owner_id":"user-1","key_hash":"abc123def456","created_at":"2026-01-01T00:00:00Z","last_used":""}`
require.NoError(t, s.Put(key, []byte(val)))
got, err := s.Get(key)
require.NoError(t, err)
require.NotNil(t, got)
var parsed map[string]any
require.NoError(t, json.Unmarshal(got, &parsed))
assert.Equal(t, "abc1", parsed["id"])
assert.Equal(t, "test-key", parsed["name"])
assert.Equal(t, "user-1", parsed["owner_id"])
assert.Equal(t, "abc123def456", parsed["key_hash"])
}
func TestSQLiteMeta_APIKeyList(t *testing.T) {
s := newTestSQLiteMeta(t)
keys := []struct {
key string
val string
}{
{"api_keys/hash1", `{"id":"h1","name":"k1","owner_id":"u1","key_hash":"hash1","created_at":"2026-01-01T00:00:00Z","last_used":""}`},
{"api_keys/hash2", `{"id":"h2","name":"k2","owner_id":"u1","key_hash":"hash2","created_at":"2026-01-02T00:00:00Z","last_used":""}`},
}
for _, k := range keys {
require.NoError(t, s.Put(k.key, []byte(k.val)))
}
pairs, err := s.List("api_keys/")
require.NoError(t, err)
assert.Len(t, pairs, 2)
}
func TestSQLiteMeta_PeerReadiness(t *testing.T) {
s := newTestSQLiteMeta(t)
// Register 3 peers
require.NoError(t, s.Put("ready/node0", []byte("true")))
require.NoError(t, s.Put("ready/node1", []byte("true")))
require.NoError(t, s.Put("ready/node2", []byte("false")))
// Check individual
val, err := s.Get("ready/node0")
require.NoError(t, err)
assert.Equal(t, "true", string(val))
val, err = s.Get("ready/node2")
require.NoError(t, err)
assert.Equal(t, "false", string(val))
// List only returns ready peers
pairs, err := s.List("ready/")
require.NoError(t, err)
assert.Len(t, pairs, 2) // node0 and node1
// Delete peer
require.NoError(t, s.Delete("ready/node1"))
pairs, err = s.List("ready/")
require.NoError(t, err)
assert.Len(t, pairs, 1)
}
func TestSQLiteMeta_GetNonExistent(t *testing.T) {
s := newTestSQLiteMeta(t)
val, err := s.Get("ready/nonexistent")
require.NoError(t, err)
assert.Nil(t, val)
val, err = s.Get("api_keys/nonexistent")
require.NoError(t, err)
assert.Nil(t, val)
val, err = s.Get("something/else")
require.NoError(t, err)
assert.Nil(t, val)
}
func TestSQLiteMeta_GenericKV(t *testing.T) {
s := newTestSQLiteMeta(t)
// Generic keys that don't match typed prefixes
require.NoError(t, s.Put("config/timeout", []byte("30")))
require.NoError(t, s.Put("config/retries", []byte("3")))
require.NoError(t, s.Put("other/key", []byte("val")))
val, err := s.Get("config/timeout")
require.NoError(t, err)
assert.Equal(t, []byte("30"), val)
pairs, err := s.List("config/")
require.NoError(t, err)
assert.Len(t, pairs, 2)
// Overwrite
require.NoError(t, s.Put("config/timeout", []byte("60")))
val, err = s.Get("config/timeout")
require.NoError(t, err)
assert.Equal(t, []byte("60"), val)
}
func TestSQLiteMeta_Audit(t *testing.T) {
s := newTestSQLiteMeta(t)
require.NoError(t, s.Audit(AuditEntry{
OrgID: "org-1",
Action: "keygen.completed",
Actor: "node0",
Resource: "wallet/w-123",
Detail: `{"curve":"secp256k1","threshold":2}`,
}))
require.NoError(t, s.Audit(AuditEntry{
OrgID: "org-1",
Action: "signing.completed",
Actor: "node0",
Resource: "wallet/w-123",
Detail: `{"tx_id":"tx-456"}`,
}))
// Verify via raw query (audit log is append-only, not exposed via KV).
var count int
err := s.db.NewQuery("SELECT COUNT(*) FROM audit_log WHERE org_id={:org}").
Bind(map[string]any{"org": "org-1"}).
Row(&count)
require.NoError(t, err)
assert.Equal(t, 2, count)
}
func TestSQLiteMeta_DeleteNonExistent(t *testing.T) {
s := newTestSQLiteMeta(t)
// Deleting non-existent keys should not error (matches KV contract).
require.NoError(t, s.Delete("ready/ghost"))
require.NoError(t, s.Delete("api_keys/ghost"))
require.NoError(t, s.Delete("generic/ghost"))
}
func TestSQLiteMeta_EmptyList(t *testing.T) {
s := newTestSQLiteMeta(t)
pairs, err := s.List("nothing/")
require.NoError(t, err)
assert.Len(t, pairs, 0)
pairs, err = s.List("ready/")
require.NoError(t, err)
assert.Len(t, pairs, 0)
pairs, err = s.List("api_keys/")
require.NoError(t, err)
assert.Len(t, pairs, 0)
}
+523
View File
@@ -0,0 +1,523 @@
// Package policy provides approval workflow for MPC transactions.
package policy
import (
"context"
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"fmt"
"sync"
"time"
)
// ============================================================
// APPROVAL WORKFLOW
// ============================================================
// ApprovalWorkflow manages transaction approval process.
type ApprovalWorkflow struct {
engine *PolicyEngine
pending map[string]*ApprovalRequest
mu sync.RWMutex
notifier ApprovalNotifier
}
// ApprovalNotifier sends notifications for approval events.
type ApprovalNotifier interface {
// NotifyPendingApproval notifies signers about a pending approval.
NotifyPendingApproval(ctx context.Context, req *ApprovalRequest, signers []string) error
// NotifyApproved notifies when a transaction is approved.
NotifyApproved(ctx context.Context, req *ApprovalRequest) error
// NotifyRejected notifies when a transaction is rejected.
NotifyRejected(ctx context.Context, req *ApprovalRequest, reason string) error
// NotifyExpired notifies when a transaction approval expires.
NotifyExpired(ctx context.Context, req *ApprovalRequest) error
}
// ApprovalRequest represents a pending approval.
type ApprovalRequest struct {
ID string `json:"id"`
TransactionID string `json:"transaction_id"`
WalletID string `json:"wallet_id"`
Transaction *TransactionRequest `json:"transaction"`
PolicyResult *PolicyResult `json:"policy_result"`
Status ApprovalStatus `json:"status"`
RequiredCount int `json:"required_count"`
CurrentCount int `json:"current_count"`
EligibleSigners []string `json:"eligible_signers"`
Approvals []Approval `json:"approvals"`
Rejections []Rejection `json:"rejections"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ExpiresAt time.Time `json:"expires_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
// ApprovalStatus is the status of an approval request.
type ApprovalStatus string
const (
ApprovalStatusPending ApprovalStatus = "pending"
ApprovalStatusApproved ApprovalStatus = "approved"
ApprovalStatusRejected ApprovalStatus = "rejected"
ApprovalStatusExpired ApprovalStatus = "expired"
ApprovalStatusCancelled ApprovalStatus = "cancelled"
)
// Approval represents a single approval from a signer.
type Approval struct {
SignerID string `json:"signer_id"`
SignerName string `json:"signer_name"`
Signature string `json:"signature"` // Ed25519 signature of approval
Comment string `json:"comment,omitempty"`
ApprovedAt time.Time `json:"approved_at"`
IPAddress string `json:"ip_address,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
}
// Rejection represents a rejection from a signer.
type Rejection struct {
SignerID string `json:"signer_id"`
SignerName string `json:"signer_name"`
Reason string `json:"reason"`
RejectedAt time.Time `json:"rejected_at"`
}
// NewApprovalWorkflow creates a new approval workflow.
func NewApprovalWorkflow(engine *PolicyEngine, notifier ApprovalNotifier) *ApprovalWorkflow {
return &ApprovalWorkflow{
engine: engine,
pending: make(map[string]*ApprovalRequest),
notifier: notifier,
}
}
// SubmitTransaction submits a transaction for approval.
func (w *ApprovalWorkflow) SubmitTransaction(ctx context.Context, tx *TransactionRequest) (*ApprovalRequest, error) {
// Evaluate policy
result, err := w.engine.Evaluate(ctx, tx)
if err != nil {
return nil, fmt.Errorf("policy evaluation failed: %w", err)
}
// If denied, return immediately
if result.Action == ActionDeny {
return nil, fmt.Errorf("transaction denied: %s", result.DenyReason)
}
// If allowed without approval, return success
if result.Action == ActionAllow {
return &ApprovalRequest{
ID: generateID(),
TransactionID: tx.ID,
WalletID: tx.WalletID,
Transaction: tx,
PolicyResult: result,
Status: ApprovalStatusApproved,
CreatedAt: time.Now(),
CompletedAt: timePtr(time.Now()),
}, nil
}
// Create approval request
req := &ApprovalRequest{
ID: generateID(),
TransactionID: tx.ID,
WalletID: tx.WalletID,
Transaction: tx,
PolicyResult: result,
Status: ApprovalStatusPending,
RequiredCount: result.RequiredCount,
CurrentCount: 0,
EligibleSigners: result.RequiredSigners,
Approvals: make([]Approval, 0),
Rejections: make([]Rejection, 0),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour), // Default 24h expiry
}
// Apply delay if required
if result.DelayUntil != nil && result.DelayUntil.After(time.Now()) {
req.ExpiresAt = result.DelayUntil.Add(24 * time.Hour)
}
w.mu.Lock()
w.pending[req.ID] = req
w.mu.Unlock()
// Notify eligible signers
if w.notifier != nil {
w.notifier.NotifyPendingApproval(ctx, req, req.EligibleSigners)
}
return req, nil
}
// Approve adds an approval to a pending request.
func (w *ApprovalWorkflow) Approve(ctx context.Context, requestID string, approval Approval) (*ApprovalRequest, error) {
w.mu.Lock()
defer w.mu.Unlock()
req, ok := w.pending[requestID]
if !ok {
return nil, fmt.Errorf("approval request not found: %s", requestID)
}
// Check if request is still pending
if req.Status != ApprovalStatusPending {
return nil, fmt.Errorf("request is no longer pending: %s", req.Status)
}
// Check if expired
if time.Now().After(req.ExpiresAt) {
req.Status = ApprovalStatusExpired
if w.notifier != nil {
w.notifier.NotifyExpired(ctx, req)
}
return nil, fmt.Errorf("approval request has expired")
}
// Check if signer is eligible
if !contains(req.EligibleSigners, approval.SignerID) {
return nil, fmt.Errorf("signer is not eligible to approve this request")
}
// Check if already approved/rejected
for _, a := range req.Approvals {
if a.SignerID == approval.SignerID {
return nil, fmt.Errorf("signer has already approved this request")
}
}
for _, r := range req.Rejections {
if r.SignerID == approval.SignerID {
return nil, fmt.Errorf("signer has already rejected this request")
}
}
// Verify signature
signer, ok := w.engine.GetSigner(approval.SignerID)
if !ok {
return nil, fmt.Errorf("signer not found: %s", approval.SignerID)
}
if signer.Status != StatusActive {
return nil, fmt.Errorf("signer is not active: %s", signer.Status)
}
// Verify Ed25519 signature of approval if the signer has a public key configured
if signer.PublicKey != "" && approval.Signature != "" {
pubKeyBytes, err := hex.DecodeString(signer.PublicKey)
if err != nil || len(pubKeyBytes) != ed25519.PublicKeySize {
return nil, fmt.Errorf("signer has invalid public key")
}
sigBytes, err := hex.DecodeString(approval.Signature)
if err != nil {
return nil, fmt.Errorf("invalid signature encoding")
}
// Canonical message: "<request_id>:<transaction_id>"
msg := []byte(requestID + ":" + req.TransactionID)
if !ed25519.Verify(ed25519.PublicKey(pubKeyBytes), msg, sigBytes) {
return nil, fmt.Errorf("signature verification failed")
}
} else if signer.PublicKey != "" && approval.Signature == "" {
return nil, fmt.Errorf("signature is required for signer %s", approval.SignerID)
}
// Add approval
approval.SignerName = signer.Name
approval.ApprovedAt = time.Now()
req.Approvals = append(req.Approvals, approval)
req.CurrentCount++
req.UpdatedAt = time.Now()
// Check if we have enough approvals
if req.CurrentCount >= req.RequiredCount {
req.Status = ApprovalStatusApproved
req.CompletedAt = timePtr(time.Now())
if w.notifier != nil {
w.notifier.NotifyApproved(ctx, req)
}
}
return req, nil
}
// Reject adds a rejection to a pending request.
func (w *ApprovalWorkflow) Reject(ctx context.Context, requestID string, rejection Rejection) (*ApprovalRequest, error) {
w.mu.Lock()
defer w.mu.Unlock()
req, ok := w.pending[requestID]
if !ok {
return nil, fmt.Errorf("approval request not found: %s", requestID)
}
// Check if request is still pending
if req.Status != ApprovalStatusPending {
return nil, fmt.Errorf("request is no longer pending: %s", req.Status)
}
// Check if signer is eligible
if !contains(req.EligibleSigners, rejection.SignerID) {
return nil, fmt.Errorf("signer is not eligible to reject this request")
}
// Get signer info
signer, ok := w.engine.GetSigner(rejection.SignerID)
if !ok {
return nil, fmt.Errorf("signer not found: %s", rejection.SignerID)
}
// Add rejection
rejection.SignerName = signer.Name
rejection.RejectedAt = time.Now()
req.Rejections = append(req.Rejections, rejection)
req.UpdatedAt = time.Now()
// Single rejection rejects the entire request
req.Status = ApprovalStatusRejected
req.CompletedAt = timePtr(time.Now())
if w.notifier != nil {
w.notifier.NotifyRejected(ctx, req, rejection.Reason)
}
return req, nil
}
// Cancel cancels a pending approval request.
func (w *ApprovalWorkflow) Cancel(ctx context.Context, requestID string, initiatorID string) error {
w.mu.Lock()
defer w.mu.Unlock()
req, ok := w.pending[requestID]
if !ok {
return fmt.Errorf("approval request not found: %s", requestID)
}
// Only the initiator or an owner can cancel
if req.Transaction.InitiatorID != initiatorID {
signer, ok := w.engine.GetSigner(initiatorID)
if !ok || signer.Role != RoleOwner {
return fmt.Errorf("only the initiator or an owner can cancel this request")
}
}
if req.Status != ApprovalStatusPending {
return fmt.Errorf("request is no longer pending: %s", req.Status)
}
req.Status = ApprovalStatusCancelled
req.CompletedAt = timePtr(time.Now())
req.UpdatedAt = time.Now()
return nil
}
// GetRequest returns an approval request by ID.
func (w *ApprovalWorkflow) GetRequest(requestID string) (*ApprovalRequest, bool) {
w.mu.RLock()
defer w.mu.RUnlock()
req, ok := w.pending[requestID]
return req, ok
}
// ListPending returns all pending approval requests.
func (w *ApprovalWorkflow) ListPending() []*ApprovalRequest {
w.mu.RLock()
defer w.mu.RUnlock()
requests := make([]*ApprovalRequest, 0)
for _, req := range w.pending {
if req.Status == ApprovalStatusPending {
requests = append(requests, req)
}
}
return requests
}
// ListForSigner returns pending requests that a signer can approve.
func (w *ApprovalWorkflow) ListForSigner(signerID string) []*ApprovalRequest {
w.mu.RLock()
defer w.mu.RUnlock()
requests := make([]*ApprovalRequest, 0)
for _, req := range w.pending {
if req.Status == ApprovalStatusPending && contains(req.EligibleSigners, signerID) {
// Check if signer hasn't already approved/rejected
alreadyActed := false
for _, a := range req.Approvals {
if a.SignerID == signerID {
alreadyActed = true
break
}
}
for _, r := range req.Rejections {
if r.SignerID == signerID {
alreadyActed = true
break
}
}
if !alreadyActed {
requests = append(requests, req)
}
}
}
return requests
}
// CleanupExpired removes expired requests.
func (w *ApprovalWorkflow) CleanupExpired(ctx context.Context) int {
w.mu.Lock()
defer w.mu.Unlock()
now := time.Now()
expired := 0
for id, req := range w.pending {
if req.Status == ApprovalStatusPending && now.After(req.ExpiresAt) {
req.Status = ApprovalStatusExpired
req.CompletedAt = timePtr(now)
if w.notifier != nil {
w.notifier.NotifyExpired(ctx, req)
}
expired++
delete(w.pending, id)
}
}
return expired
}
// ============================================================
// AUDIT LOG
// ============================================================
// AuditLog records all policy-related events.
type AuditLog struct {
entries []AuditEntry
mu sync.RWMutex
}
// AuditEntry is a single audit log entry.
type AuditEntry struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Type AuditEventType `json:"type"`
ActorID string `json:"actor_id"`
ActorName string `json:"actor_name"`
ActorIP string `json:"actor_ip,omitempty"`
Resource string `json:"resource"` // wallet_id, signer_id, etc.
ResourceType string `json:"resource_type"` // wallet, signer, transaction, etc.
Action string `json:"action"`
Details map[string]interface{} `json:"details"`
Result string `json:"result"` // success, failure
Error string `json:"error,omitempty"`
}
// AuditEventType is the type of audit event.
type AuditEventType string
const (
AuditSignerAdded AuditEventType = "signer_added"
AuditSignerRemoved AuditEventType = "signer_removed"
AuditSignerUpdated AuditEventType = "signer_updated"
AuditPolicyUpdated AuditEventType = "policy_updated"
AuditRuleAdded AuditEventType = "rule_added"
AuditRuleRemoved AuditEventType = "rule_removed"
AuditTransactionSubmitted AuditEventType = "transaction_submitted"
AuditTransactionApproved AuditEventType = "transaction_approved"
AuditTransactionRejected AuditEventType = "transaction_rejected"
AuditTransactionCancelled AuditEventType = "transaction_cancelled"
AuditTransactionExpired AuditEventType = "transaction_expired"
AuditTransactionSigned AuditEventType = "transaction_signed"
AuditKeyGenerated AuditEventType = "key_generated"
AuditKeyExported AuditEventType = "key_exported"
AuditLoginSuccess AuditEventType = "login_success"
AuditLoginFailure AuditEventType = "login_failure"
)
// NewAuditLog creates a new audit log.
func NewAuditLog() *AuditLog {
return &AuditLog{
entries: make([]AuditEntry, 0),
}
}
// Log records an audit entry.
func (l *AuditLog) Log(entry AuditEntry) {
l.mu.Lock()
defer l.mu.Unlock()
entry.ID = generateID()
entry.Timestamp = time.Now()
l.entries = append(l.entries, entry)
}
// Query returns audit entries matching the filter.
func (l *AuditLog) Query(filter AuditFilter) []AuditEntry {
l.mu.RLock()
defer l.mu.RUnlock()
results := make([]AuditEntry, 0)
for _, entry := range l.entries {
if matchesFilter(&entry, &filter) {
results = append(results, entry)
}
}
return results
}
// AuditFilter filters audit entries.
type AuditFilter struct {
ActorID string
ResourceType string
Resource string
EventType AuditEventType
StartTime time.Time
EndTime time.Time
Limit int
}
func matchesFilter(entry *AuditEntry, filter *AuditFilter) bool {
if filter.ActorID != "" && entry.ActorID != filter.ActorID {
return false
}
if filter.ResourceType != "" && entry.ResourceType != filter.ResourceType {
return false
}
if filter.Resource != "" && entry.Resource != filter.Resource {
return false
}
if filter.EventType != "" && entry.Type != filter.EventType {
return false
}
if !filter.StartTime.IsZero() && entry.Timestamp.Before(filter.StartTime) {
return false
}
if !filter.EndTime.IsZero() && entry.Timestamp.After(filter.EndTime) {
return false
}
return true
}
// Export exports the audit log as JSON.
func (l *AuditLog) Export() ([]byte, error) {
l.mu.RLock()
defer l.mu.RUnlock()
return json.Marshal(l.entries)
}
// ============================================================
// HELPERS
// ============================================================
func generateID() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
func timePtr(t time.Time) *time.Time {
return &t
}
+750
View File
@@ -0,0 +1,750 @@
// Package policy provides transaction governance and access control
// for MPC wallets, similar to Fireblocks and Utila policy engines.
package policy
import (
"context"
"encoding/json"
"fmt"
"math/big"
"regexp"
"strings"
"time"
)
// ============================================================
// CORE TYPES
// ============================================================
// PolicyEngine evaluates transaction policies.
type PolicyEngine struct {
rules []Rule
signers map[string]*Signer
walletPolicies map[string]*WalletPolicy
}
// NewPolicyEngine creates a new policy engine.
func NewPolicyEngine() *PolicyEngine {
return &PolicyEngine{
rules: make([]Rule, 0),
signers: make(map[string]*Signer),
walletPolicies: make(map[string]*WalletPolicy),
}
}
// ============================================================
// SIGNERS - Users who can manage wallets and approve transactions
// ============================================================
// Signer represents a user who can sign/approve transactions.
type Signer struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
PublicKey string `json:"public_key"` // Ed25519 public key for verification
Role SignerRole `json:"role"`
Groups []string `json:"groups"` // Group memberships
Permissions []Permission `json:"permissions"`
SpendLimits map[string]*Limit `json:"spend_limits"` // Per-asset limits
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Status SignerStatus `json:"status"`
}
// SignerRole defines the role of a signer.
type SignerRole string
const (
RoleOwner SignerRole = "owner" // Full control, can add/remove signers
RoleAdmin SignerRole = "admin" // Can manage wallets and approve
RoleApprover SignerRole = "approver" // Can approve transactions
RoleInitiator SignerRole = "initiator" // Can create but not approve
RoleViewer SignerRole = "viewer" // Read-only access
)
// SignerStatus is the status of a signer.
type SignerStatus string
const (
StatusActive SignerStatus = "active"
StatusPending SignerStatus = "pending" // Awaiting approval
StatusSuspended SignerStatus = "suspended"
StatusRevoked SignerStatus = "revoked"
)
// Permission defines what a signer can do.
type Permission string
const (
PermCreateTransaction Permission = "create_transaction"
PermApproveTransaction Permission = "approve_transaction"
PermRejectTransaction Permission = "reject_transaction"
PermViewTransaction Permission = "view_transaction"
PermManageWallet Permission = "manage_wallet"
PermManageSigners Permission = "manage_signers"
PermManagePolicies Permission = "manage_policies"
PermViewAuditLog Permission = "view_audit_log"
PermExportKeys Permission = "export_keys"
)
// Limit defines spending limits.
type Limit struct {
MaxAmount *big.Int `json:"max_amount"` // Max per transaction
DailyLimit *big.Int `json:"daily_limit"` // Max per day
MonthlyLimit *big.Int `json:"monthly_limit"` // Max per month
CooldownPeriod time.Duration `json:"cooldown_period"` // Time between transactions
RequiredSigners int `json:"required_signers"` // Required approvals above this limit
}
// ============================================================
// WALLET POLICIES
// ============================================================
// WalletPolicy defines the policy for a specific wallet.
type WalletPolicy struct {
WalletID string `json:"wallet_id"`
Name string `json:"name"`
Description string `json:"description"`
Rules []Rule `json:"rules"`
Signers []WalletSigner `json:"signers"`
Defaults PolicyDefaults `json:"defaults"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// WalletSigner links a signer to a wallet with specific permissions.
type WalletSigner struct {
SignerID string `json:"signer_id"`
Permissions []Permission `json:"permissions"`
Weight int `json:"weight"` // For weighted threshold
}
// PolicyDefaults are the default settings for a wallet.
type PolicyDefaults struct {
RequiredApprovals int `json:"required_approvals"` // Default approvals needed
TimeoutDuration time.Duration `json:"timeout_duration"` // Auto-reject after timeout
AutoApproveBelow *big.Int `json:"auto_approve_below"` // Auto-approve small amounts
}
// ============================================================
// RULES - Fireblocks/Utila style policy rules
// ============================================================
// Rule defines a policy rule.
type Rule struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Priority int `json:"priority"` // Lower = higher priority
Conditions []Condition `json:"conditions"`
Action RuleAction `json:"action"`
Signers SignerConfig `json:"signers"` // Who must approve if action is REQUIRE_APPROVAL
TimeWindow *TimeWindow `json:"time_window"` // Optional time restrictions
RateLimits []RateLimit `json:"rate_limits"` // Optional rate limiting
Enabled bool `json:"enabled"`
}
// RuleAction is the action to take when a rule matches.
type RuleAction string
const (
ActionAllow RuleAction = "allow" // Auto-approve
ActionDeny RuleAction = "deny" // Auto-reject
ActionRequireApproval RuleAction = "require_approval" // Require specified approvals
ActionNotify RuleAction = "notify" // Notify but allow
ActionDelay RuleAction = "delay" // Delay execution
)
// SignerConfig specifies who must approve.
type SignerConfig struct {
RequiredCount int `json:"required_count"` // Number of approvals needed
FromGroups []string `json:"from_groups"` // Must be from these groups
FromSigners []string `json:"from_signers"` // Must be from these signers
ExcludeSigners []string `json:"exclude_signers"` // Cannot be from these signers
Weighted bool `json:"weighted"` // Use weight-based threshold
WeightThreshold int `json:"weight_threshold"` // Required weight if weighted
}
// Condition is a single condition in a rule.
type Condition struct {
Type ConditionType `json:"type"`
Operator Operator `json:"operator"`
Value interface{} `json:"value"`
}
// ConditionType is the type of condition.
type ConditionType string
const (
ConditionAmount ConditionType = "amount" // Transaction amount
ConditionAsset ConditionType = "asset" // Token/asset type
ConditionChain ConditionType = "chain" // Blockchain network
ConditionDestination ConditionType = "destination" // Recipient address
ConditionSource ConditionType = "source" // Source wallet
ConditionInitiator ConditionType = "initiator" // Who created the tx
ConditionTimeOfDay ConditionType = "time_of_day" // Time-based restrictions
ConditionDayOfWeek ConditionType = "day_of_week" // Day restrictions
ConditionCountry ConditionType = "country" // Geo restrictions
ConditionContractAddress ConditionType = "contract_address" // Smart contract calls
ConditionMethodID ConditionType = "method_id" // Contract method being called
ConditionGasLimit ConditionType = "gas_limit" // Gas/fee limits
ConditionCumulative24h ConditionType = "cumulative_24h" // 24h cumulative amount
ConditionCumulative7d ConditionType = "cumulative_7d" // 7d cumulative amount
ConditionWhitelist ConditionType = "whitelist" // Address whitelist
ConditionBlacklist ConditionType = "blacklist" // Address blacklist
)
// Operator for condition comparison.
type Operator string
const (
OpEquals Operator = "eq"
OpNotEquals Operator = "ne"
OpGreaterThan Operator = "gt"
OpLessThan Operator = "lt"
OpGreaterOrEqual Operator = "gte"
OpLessOrEqual Operator = "lte"
OpIn Operator = "in"
OpNotIn Operator = "not_in"
OpContains Operator = "contains"
OpStartsWith Operator = "starts_with"
OpEndsWith Operator = "ends_with"
OpMatches Operator = "matches" // Regex
OpBetween Operator = "between"
)
// TimeWindow restricts when transactions can be executed.
type TimeWindow struct {
Timezone string `json:"timezone"` // IANA timezone
StartHour int `json:"start_hour"` // 0-23
EndHour int `json:"end_hour"` // 0-23
AllowedDays []string `json:"allowed_days"` // ["monday", "tuesday", ...]
}
// RateLimit defines rate limiting for transactions.
type RateLimit struct {
MaxCount int `json:"max_count"` // Max transactions
MaxAmount *big.Int `json:"max_amount"` // Max total amount
Window time.Duration `json:"window"` // Time window
PerAsset bool `json:"per_asset"` // Rate limit per asset
PerAddress bool `json:"per_address"` // Rate limit per destination
}
// ============================================================
// TRANSACTION REQUEST
// ============================================================
// TransactionRequest represents a transaction to be evaluated.
type TransactionRequest struct {
ID string `json:"id"`
WalletID string `json:"wallet_id"`
InitiatorID string `json:"initiator_id"`
Chain string `json:"chain"`
Asset string `json:"asset"`
Amount *big.Int `json:"amount"`
Destination string `json:"destination"`
ContractAddress string `json:"contract_address,omitempty"`
MethodID string `json:"method_id,omitempty"`
GasLimit uint64 `json:"gas_limit,omitempty"`
Metadata map[string]string `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
}
// PolicyResult is the result of policy evaluation.
type PolicyResult struct {
Allowed bool `json:"allowed"`
Action RuleAction `json:"action"`
MatchedRules []string `json:"matched_rules"`
RequiredSigners []string `json:"required_signers,omitempty"`
RequiredCount int `json:"required_count,omitempty"`
DenyReason string `json:"deny_reason,omitempty"`
Warnings []string `json:"warnings,omitempty"`
DelayUntil *time.Time `json:"delay_until,omitempty"`
}
// ============================================================
// POLICY ENGINE METHODS
// ============================================================
// AddSigner adds a signer to the engine.
func (e *PolicyEngine) AddSigner(signer *Signer) error {
if signer.ID == "" {
return fmt.Errorf("signer ID is required")
}
signer.CreatedAt = time.Now()
signer.UpdatedAt = time.Now()
signer.Status = StatusActive
e.signers[signer.ID] = signer
return nil
}
// GetSigner returns a signer by ID.
func (e *PolicyEngine) GetSigner(id string) (*Signer, bool) {
s, ok := e.signers[id]
return s, ok
}
// ListSigners returns all signers.
func (e *PolicyEngine) ListSigners() []*Signer {
signers := make([]*Signer, 0, len(e.signers))
for _, s := range e.signers {
signers = append(signers, s)
}
return signers
}
// UpdateSigner updates a signer.
func (e *PolicyEngine) UpdateSigner(signer *Signer) error {
if _, ok := e.signers[signer.ID]; !ok {
return fmt.Errorf("signer not found: %s", signer.ID)
}
signer.UpdatedAt = time.Now()
e.signers[signer.ID] = signer
return nil
}
// RemoveSigner removes a signer.
func (e *PolicyEngine) RemoveSigner(id string) error {
if _, ok := e.signers[id]; !ok {
return fmt.Errorf("signer not found: %s", id)
}
delete(e.signers, id)
return nil
}
// SetWalletPolicy sets the policy for a wallet.
func (e *PolicyEngine) SetWalletPolicy(policy *WalletPolicy) error {
if policy.WalletID == "" {
return fmt.Errorf("wallet ID is required")
}
policy.CreatedAt = time.Now()
policy.UpdatedAt = time.Now()
e.walletPolicies[policy.WalletID] = policy
return nil
}
// GetWalletPolicy returns the policy for a wallet.
func (e *PolicyEngine) GetWalletPolicy(walletID string) (*WalletPolicy, bool) {
p, ok := e.walletPolicies[walletID]
return p, ok
}
// AddRule adds a global rule.
func (e *PolicyEngine) AddRule(rule Rule) {
e.rules = append(e.rules, rule)
}
// ============================================================
// POLICY EVALUATION
// ============================================================
// Evaluate evaluates a transaction against all policies.
func (e *PolicyEngine) Evaluate(ctx context.Context, tx *TransactionRequest) (*PolicyResult, error) {
result := &PolicyResult{
Allowed: true,
Action: ActionAllow,
MatchedRules: make([]string, 0),
Warnings: make([]string, 0),
}
// Get wallet-specific policy
walletPolicy, hasWalletPolicy := e.walletPolicies[tx.WalletID]
// Combine global rules with wallet-specific rules
allRules := make([]Rule, len(e.rules))
copy(allRules, e.rules)
if hasWalletPolicy {
allRules = append(allRules, walletPolicy.Rules...)
}
// Sort rules by priority
sortRulesByPriority(allRules)
// Evaluate each rule
for _, rule := range allRules {
if !rule.Enabled {
continue
}
match, err := e.evaluateRule(ctx, &rule, tx)
if err != nil {
return nil, fmt.Errorf("error evaluating rule %s: %w", rule.ID, err)
}
if match {
result.MatchedRules = append(result.MatchedRules, rule.ID)
switch rule.Action {
case ActionDeny:
result.Allowed = false
result.Action = ActionDeny
result.DenyReason = fmt.Sprintf("Denied by rule: %s", rule.Name)
return result, nil
case ActionRequireApproval:
result.Action = ActionRequireApproval
result.RequiredSigners = e.getRequiredSigners(&rule, tx, walletPolicy)
result.RequiredCount = rule.Signers.RequiredCount
case ActionDelay:
if rule.TimeWindow != nil {
delay := calculateDelay(rule.TimeWindow)
result.DelayUntil = &delay
}
case ActionNotify:
result.Warnings = append(result.Warnings, fmt.Sprintf("Notification: %s", rule.Name))
}
}
}
// Apply wallet defaults if no rules required approval
if hasWalletPolicy && result.Action == ActionAllow && walletPolicy.Defaults.RequiredApprovals > 0 {
if walletPolicy.Defaults.AutoApproveBelow == nil || tx.Amount.Cmp(walletPolicy.Defaults.AutoApproveBelow) > 0 {
result.Action = ActionRequireApproval
result.RequiredCount = walletPolicy.Defaults.RequiredApprovals
result.RequiredSigners = e.getWalletSigners(walletPolicy)
}
}
return result, nil
}
func (e *PolicyEngine) evaluateRule(ctx context.Context, rule *Rule, tx *TransactionRequest) (bool, error) {
// Check time window first
if rule.TimeWindow != nil && !isInTimeWindow(rule.TimeWindow) {
return false, nil
}
// All conditions must match (AND logic)
for _, cond := range rule.Conditions {
match, err := e.evaluateCondition(&cond, tx)
if err != nil {
return false, err
}
if !match {
return false, nil
}
}
return true, nil
}
func (e *PolicyEngine) evaluateCondition(cond *Condition, tx *TransactionRequest) (bool, error) {
switch cond.Type {
case ConditionAmount:
return compareAmount(tx.Amount, cond.Operator, cond.Value)
case ConditionAsset:
return compareString(tx.Asset, cond.Operator, cond.Value)
case ConditionChain:
return compareString(tx.Chain, cond.Operator, cond.Value)
case ConditionDestination:
return compareString(tx.Destination, cond.Operator, cond.Value)
case ConditionInitiator:
return compareString(tx.InitiatorID, cond.Operator, cond.Value)
case ConditionContractAddress:
return compareString(tx.ContractAddress, cond.Operator, cond.Value)
case ConditionWhitelist:
return isInWhitelist(tx.Destination, cond.Value)
case ConditionBlacklist:
return isInBlacklist(tx.Destination, cond.Value)
default:
return false, fmt.Errorf("unsupported condition type: %s", cond.Type)
}
}
func (e *PolicyEngine) getRequiredSigners(rule *Rule, tx *TransactionRequest, walletPolicy *WalletPolicy) []string {
var signers []string
if len(rule.Signers.FromSigners) > 0 {
signers = rule.Signers.FromSigners
} else if len(rule.Signers.FromGroups) > 0 {
// Get signers from specified groups
for _, s := range e.signers {
for _, group := range rule.Signers.FromGroups {
if contains(s.Groups, group) {
signers = append(signers, s.ID)
break
}
}
}
} else if walletPolicy != nil {
// Get signers from wallet policy
for _, ws := range walletPolicy.Signers {
if contains(ws.Permissions, PermApproveTransaction) {
signers = append(signers, ws.SignerID)
}
}
}
// Exclude specified signers
if len(rule.Signers.ExcludeSigners) > 0 {
filtered := make([]string, 0)
for _, s := range signers {
if !contains(rule.Signers.ExcludeSigners, s) {
filtered = append(filtered, s)
}
}
signers = filtered
}
// Exclude initiator (can't approve own transactions)
filtered := make([]string, 0)
for _, s := range signers {
if s != tx.InitiatorID {
filtered = append(filtered, s)
}
}
return filtered
}
func (e *PolicyEngine) getWalletSigners(policy *WalletPolicy) []string {
signers := make([]string, 0)
for _, ws := range policy.Signers {
if contains(ws.Permissions, PermApproveTransaction) {
signers = append(signers, ws.SignerID)
}
}
return signers
}
// ============================================================
// HELPER FUNCTIONS
// ============================================================
func sortRulesByPriority(rules []Rule) {
// Simple bubble sort (rules are typically few)
for i := 0; i < len(rules)-1; i++ {
for j := 0; j < len(rules)-i-1; j++ {
if rules[j].Priority > rules[j+1].Priority {
rules[j], rules[j+1] = rules[j+1], rules[j]
}
}
}
}
func compareAmount(amount *big.Int, op Operator, value interface{}) (bool, error) {
var compareVal *big.Int
switch v := value.(type) {
case string:
compareVal = new(big.Int)
compareVal.SetString(v, 10)
case float64:
compareVal = big.NewInt(int64(v))
case int64:
compareVal = big.NewInt(v)
case *big.Int:
compareVal = v
default:
return false, fmt.Errorf("invalid amount value type: %T", value)
}
cmp := amount.Cmp(compareVal)
switch op {
case OpEquals:
return cmp == 0, nil
case OpNotEquals:
return cmp != 0, nil
case OpGreaterThan:
return cmp > 0, nil
case OpLessThan:
return cmp < 0, nil
case OpGreaterOrEqual:
return cmp >= 0, nil
case OpLessOrEqual:
return cmp <= 0, nil
default:
return false, fmt.Errorf("unsupported operator for amount: %s", op)
}
}
func compareString(actual string, op Operator, value interface{}) (bool, error) {
switch op {
case OpEquals:
return actual == fmt.Sprint(value), nil
case OpNotEquals:
return actual != fmt.Sprint(value), nil
case OpContains:
return strings.Contains(actual, fmt.Sprint(value)), nil
case OpStartsWith:
return strings.HasPrefix(actual, fmt.Sprint(value)), nil
case OpEndsWith:
return strings.HasSuffix(actual, fmt.Sprint(value)), nil
case OpMatches:
re, err := regexp.Compile(fmt.Sprint(value))
if err != nil {
return false, err
}
return re.MatchString(actual), nil
case OpIn:
list, ok := value.([]interface{})
if !ok {
return false, fmt.Errorf("in operator requires array value")
}
for _, v := range list {
if actual == fmt.Sprint(v) {
return true, nil
}
}
return false, nil
case OpNotIn:
list, ok := value.([]interface{})
if !ok {
return false, fmt.Errorf("not_in operator requires array value")
}
for _, v := range list {
if actual == fmt.Sprint(v) {
return false, nil
}
}
return true, nil
default:
return false, fmt.Errorf("unsupported operator for string: %s", op)
}
}
func isInWhitelist(address string, value interface{}) (bool, error) {
list, ok := value.([]interface{})
if !ok {
return false, fmt.Errorf("whitelist requires array value")
}
for _, v := range list {
if strings.EqualFold(address, fmt.Sprint(v)) {
return true, nil
}
}
return false, nil
}
func isInBlacklist(address string, value interface{}) (bool, error) {
list, ok := value.([]interface{})
if !ok {
return false, fmt.Errorf("blacklist requires array value")
}
for _, v := range list {
if strings.EqualFold(address, fmt.Sprint(v)) {
return true, nil // In blacklist = condition matches
}
}
return false, nil
}
func isInTimeWindow(tw *TimeWindow) bool {
loc, err := time.LoadLocation(tw.Timezone)
if err != nil {
loc = time.UTC
}
now := time.Now().In(loc)
hour := now.Hour()
// Check hour range
if tw.StartHour <= tw.EndHour {
if hour < tw.StartHour || hour > tw.EndHour {
return false
}
} else {
// Wraps around midnight
if hour < tw.StartHour && hour > tw.EndHour {
return false
}
}
// Check day of week
if len(tw.AllowedDays) > 0 {
dayName := strings.ToLower(now.Weekday().String())
found := false
for _, d := range tw.AllowedDays {
if strings.ToLower(d) == dayName {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func calculateDelay(tw *TimeWindow) time.Time {
loc, err := time.LoadLocation(tw.Timezone)
if err != nil {
loc = time.UTC
}
now := time.Now().In(loc)
// Find next allowed time
for i := 0; i < 8; i++ { // Check up to 8 days ahead
checkTime := now.Add(time.Duration(i) * 24 * time.Hour)
dayName := strings.ToLower(checkTime.Weekday().String())
// Check if day is allowed
dayAllowed := len(tw.AllowedDays) == 0
for _, d := range tw.AllowedDays {
if strings.ToLower(d) == dayName {
dayAllowed = true
break
}
}
if dayAllowed {
// Set to start hour
target := time.Date(checkTime.Year(), checkTime.Month(), checkTime.Day(),
tw.StartHour, 0, 0, 0, loc)
if target.After(now) {
return target
}
}
}
return now // Fallback
}
func contains[T comparable](slice []T, item T) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// ============================================================
// SERIALIZATION
// ============================================================
// MarshalJSON marshals the policy engine state.
func (e *PolicyEngine) MarshalJSON() ([]byte, error) {
state := struct {
Rules []Rule `json:"rules"`
Signers map[string]*Signer `json:"signers"`
WalletPolicies map[string]*WalletPolicy `json:"wallet_policies"`
}{
Rules: e.rules,
Signers: e.signers,
WalletPolicies: e.walletPolicies,
}
return json.Marshal(state)
}
// UnmarshalJSON unmarshals the policy engine state.
func (e *PolicyEngine) UnmarshalJSON(data []byte) error {
var state struct {
Rules []Rule `json:"rules"`
Signers map[string]*Signer `json:"signers"`
WalletPolicies map[string]*WalletPolicy `json:"wallet_policies"`
}
if err := json.Unmarshal(data, &state); err != nil {
return err
}
e.rules = state.Rules
e.signers = state.Signers
e.walletPolicies = state.WalletPolicies
return nil
}
+447
View File
@@ -0,0 +1,447 @@
// Package policy provides TFHE-powered private policy evaluation.
// Rules can be evaluated on encrypted transaction data without revealing
// amounts, addresses, or other sensitive information.
package policy
import (
"context"
"fmt"
"math/big"
)
// ============================================================
// FHE INTERFACE - Abstract FHE operations
// ============================================================
// FHEEngine provides homomorphic encryption operations.
// This interface abstracts the underlying FHE implementation (luxfi/fhe).
type FHEEngine interface {
// Encryption
Encrypt64(value uint64) EncryptedValue
EncryptBool(value bool) EncryptedValue
// Homomorphic comparisons (return encrypted bool)
Lt64(a, b EncryptedValue) EncryptedValue
Gt64(a, b EncryptedValue) EncryptedValue
Lte64(a, b EncryptedValue) EncryptedValue
Gte64(a, b EncryptedValue) EncryptedValue
Eq64(a, b EncryptedValue) EncryptedValue
// Homomorphic arithmetic
Add64(a, b EncryptedValue) EncryptedValue
Sub64(a, b EncryptedValue) EncryptedValue
// Homomorphic boolean
And(a, b EncryptedValue) EncryptedValue
Or(a, b EncryptedValue) EncryptedValue
Not(a EncryptedValue) EncryptedValue
// Serialization
Serialize(v EncryptedValue) []byte
Deserialize(data []byte) (EncryptedValue, error)
}
// EncryptedValue represents an FHE-encrypted value.
type EncryptedValue interface {
// Bytes returns the serialized ciphertext
Bytes() []byte
}
// ============================================================
// PRIVATE POLICY ENGINE (TFHE-POWERED)
// ============================================================
// PrivatePolicyEngine evaluates policies on encrypted data using FHE.
type PrivatePolicyEngine struct {
fhe FHEEngine
rules []PrivateRule
}
// PrivateRule is a policy rule that operates on encrypted data.
type PrivateRule struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Priority int `json:"priority"`
Conditions []PrivateCondition `json:"conditions"`
Action RuleAction `json:"action"`
Enabled bool `json:"enabled"`
}
// PrivateCondition is a condition that can be evaluated on encrypted data.
type PrivateCondition struct {
Type PrivateConditionType `json:"type"`
Operator Operator `json:"operator"`
// EncryptedValue is the threshold/comparison value (encrypted)
EncryptedValue EncryptedValue `json:"-"`
// SerializedValue for persistence
SerializedValue []byte `json:"serialized_value"`
// ClearValue is used for non-sensitive comparisons (optional)
ClearValue interface{} `json:"clear_value,omitempty"`
}
// PrivateConditionType defines types of private conditions.
type PrivateConditionType string
const (
// Amount comparisons on encrypted values
PrivateConditionAmount PrivateConditionType = "private_amount"
// Range checks (is amount within [min, max])
PrivateConditionAmountRange PrivateConditionType = "private_amount_range"
// Cumulative spending limits (encrypted running total)
PrivateConditionCumulative PrivateConditionType = "private_cumulative"
// Address matching on encrypted addresses
PrivateConditionAddress PrivateConditionType = "private_address"
// Encrypted whitelist membership
PrivateConditionWhitelist PrivateConditionType = "private_whitelist"
)
// EncryptedTransaction contains encrypted transaction fields.
type EncryptedTransaction struct {
ID string `json:"id"`
WalletID string `json:"wallet_id"`
InitiatorID string `json:"initiator_id"`
Chain string `json:"chain"` // Clear (non-sensitive)
Asset string `json:"asset"` // Clear (non-sensitive)
EncryptedAmount EncryptedValue `json:"-"` // FHE encrypted
EncryptedDest EncryptedValue `json:"-"` // FHE encrypted
EncryptedSource EncryptedValue `json:"-"` // FHE encrypted
// Serialized forms for persistence
SerializedAmount []byte `json:"encrypted_amount"`
SerializedDest []byte `json:"encrypted_dest"`
SerializedSource []byte `json:"encrypted_source"`
}
// EncryptedPolicyResult is the result of private policy evaluation.
type EncryptedPolicyResult struct {
// EncryptedAllowed is an encrypted boolean (1 = allowed, 0 = denied)
EncryptedAllowed EncryptedValue `json:"-"`
SerializedAllowed []byte `json:"encrypted_allowed"`
// MatchedRules are the rules that were evaluated (clear, for audit)
MatchedRules []string `json:"matched_rules"`
// DecryptedResult is populated after threshold decryption
DecryptedResult *PolicyResult `json:"decrypted_result,omitempty"`
}
// NewPrivatePolicyEngine creates a new FHE-powered policy engine.
func NewPrivatePolicyEngine(fhe FHEEngine) *PrivatePolicyEngine {
return &PrivatePolicyEngine{
fhe: fhe,
rules: make([]PrivateRule, 0),
}
}
// AddRule adds a private rule.
func (e *PrivatePolicyEngine) AddRule(rule PrivateRule) {
e.rules = append(e.rules, rule)
}
// EncryptAmount encrypts an amount for private evaluation.
func (e *PrivatePolicyEngine) EncryptAmount(amount *big.Int) (EncryptedValue, error) {
if e.fhe == nil {
return nil, fmt.Errorf("FHE engine not set")
}
// Convert amount to uint64 (may lose precision for very large amounts)
return e.fhe.Encrypt64(amount.Uint64()), nil
}
// EncryptAddress encrypts an address for private evaluation.
func (e *PrivatePolicyEngine) EncryptAddress(address string) (EncryptedValue, error) {
if e.fhe == nil {
return nil, fmt.Errorf("FHE engine not set")
}
// Hash address to 64-bit value for encryption
hash := hashAddress(address)
return e.fhe.Encrypt64(hash), nil
}
// EvaluatePrivate evaluates policies on encrypted transaction data.
// Returns an encrypted result that must be threshold-decrypted.
func (e *PrivatePolicyEngine) EvaluatePrivate(ctx context.Context, tx *EncryptedTransaction) (*EncryptedPolicyResult, error) {
if e.fhe == nil {
return nil, fmt.Errorf("FHE engine not set")
}
result := &EncryptedPolicyResult{
MatchedRules: make([]string, 0),
}
// Start with "allowed" = true (encrypted 1)
encryptedAllowed := e.fhe.EncryptBool(true)
// Evaluate each rule homomorphically
for _, rule := range e.rules {
if !rule.Enabled {
continue
}
// Evaluate conditions homomorphically
ruleMatches, err := e.evaluatePrivateConditions(rule.Conditions, tx)
if err != nil {
continue // Skip rule on error
}
// If rule matches and action is DENY, set allowed to 0
if rule.Action == ActionDeny {
// Homomorphic: allowed = allowed AND NOT(ruleMatches)
notMatches := e.fhe.Not(ruleMatches)
encryptedAllowed = e.fhe.And(encryptedAllowed, notMatches)
}
// Track matched rules (clear, for audit)
result.MatchedRules = append(result.MatchedRules, rule.ID)
}
result.EncryptedAllowed = encryptedAllowed
result.SerializedAllowed = e.fhe.Serialize(encryptedAllowed)
return result, nil
}
// evaluatePrivateConditions evaluates conditions homomorphically.
func (e *PrivatePolicyEngine) evaluatePrivateConditions(
conditions []PrivateCondition,
tx *EncryptedTransaction,
) (EncryptedValue, error) {
// Start with true
result := e.fhe.EncryptBool(true)
for _, cond := range conditions {
var condResult EncryptedValue
var err error
switch cond.Type {
case PrivateConditionAmount:
condResult, err = e.evaluatePrivateAmountCondition(cond, tx.EncryptedAmount)
case PrivateConditionAmountRange:
condResult, err = e.evaluatePrivateRangeCondition(cond, tx.EncryptedAmount)
default:
continue // Skip unsupported condition types
}
if err != nil {
return nil, err
}
// AND all conditions together
result = e.fhe.And(result, condResult)
}
return result, nil
}
// evaluatePrivateAmountCondition evaluates amount comparison homomorphically.
func (e *PrivatePolicyEngine) evaluatePrivateAmountCondition(
cond PrivateCondition,
encryptedAmount EncryptedValue,
) (EncryptedValue, error) {
threshold := cond.EncryptedValue
switch cond.Operator {
case OpGreaterThan:
return e.fhe.Gt64(encryptedAmount, threshold), nil
case OpLessThan:
return e.fhe.Lt64(encryptedAmount, threshold), nil
case OpGreaterOrEqual:
return e.fhe.Gte64(encryptedAmount, threshold), nil
case OpLessOrEqual:
return e.fhe.Lte64(encryptedAmount, threshold), nil
case OpEquals:
return e.fhe.Eq64(encryptedAmount, threshold), nil
default:
return nil, fmt.Errorf("unsupported operator for private amount: %s", cond.Operator)
}
}
// evaluatePrivateRangeCondition checks if amount is in [min, max] range.
func (e *PrivatePolicyEngine) evaluatePrivateRangeCondition(
cond PrivateCondition,
encryptedAmount EncryptedValue,
) (EncryptedValue, error) {
// Range is encoded in ClearValue for simplicity
rangeVal, ok := cond.ClearValue.([]interface{})
if !ok || len(rangeVal) != 2 {
return nil, fmt.Errorf("invalid range value")
}
minVal := uint64(rangeVal[0].(float64))
maxVal := uint64(rangeVal[1].(float64))
// Encrypt bounds
minCt := e.fhe.Encrypt64(minVal)
maxCt := e.fhe.Encrypt64(maxVal)
// Check: amount >= min AND amount <= max
gteMin := e.fhe.Gte64(encryptedAmount, minCt)
lteMax := e.fhe.Lte64(encryptedAmount, maxCt)
return e.fhe.And(gteMin, lteMax), nil
}
// ============================================================
// PRIVATE WHITELIST
// ============================================================
// PrivateWhitelist manages an encrypted whitelist of addresses.
type PrivateWhitelist struct {
fhe FHEEngine
entries []EncryptedValue
}
// NewPrivateWhitelist creates a new private whitelist.
func NewPrivateWhitelist(fhe FHEEngine) *PrivateWhitelist {
return &PrivateWhitelist{
fhe: fhe,
entries: make([]EncryptedValue, 0),
}
}
// AddAddress adds an address to the whitelist (encrypted).
func (w *PrivateWhitelist) AddAddress(address string) error {
hash := hashAddress(address)
encrypted := w.fhe.Encrypt64(hash)
w.entries = append(w.entries, encrypted)
return nil
}
// Contains checks if an encrypted address is in the whitelist.
// Returns an encrypted boolean.
func (w *PrivateWhitelist) Contains(encryptedAddress EncryptedValue) EncryptedValue {
// Start with false
result := w.fhe.EncryptBool(false)
// Check against each entry: result = result OR (addr == entry)
for _, entry := range w.entries {
matches := w.fhe.Eq64(encryptedAddress, entry)
result = w.fhe.Or(result, matches)
}
return result
}
// ============================================================
// PRIVATE SPENDING TRACKER
// ============================================================
// PrivateSpendingTracker tracks cumulative spending using encrypted state.
type PrivateSpendingTracker struct {
fhe FHEEngine
dailyTotals map[string]EncryptedValue
monthlyTotals map[string]EncryptedValue
}
// NewPrivateSpendingTracker creates a new private spending tracker.
func NewPrivateSpendingTracker(fhe FHEEngine) *PrivateSpendingTracker {
return &PrivateSpendingTracker{
fhe: fhe,
dailyTotals: make(map[string]EncryptedValue),
monthlyTotals: make(map[string]EncryptedValue),
}
}
// AddSpend adds an encrypted spend amount to the running totals.
func (t *PrivateSpendingTracker) AddSpend(walletID, asset string, encryptedAmount EncryptedValue) {
key := walletID + ":" + asset
// Update daily total
if _, ok := t.dailyTotals[key]; !ok {
t.dailyTotals[key] = t.fhe.Encrypt64(0)
}
t.dailyTotals[key] = t.fhe.Add64(t.dailyTotals[key], encryptedAmount)
// Update monthly total
if _, ok := t.monthlyTotals[key]; !ok {
t.monthlyTotals[key] = t.fhe.Encrypt64(0)
}
t.monthlyTotals[key] = t.fhe.Add64(t.monthlyTotals[key], encryptedAmount)
}
// CheckDailyLimit checks if adding amount would exceed the daily limit.
// Returns encrypted bool (true = within limit).
func (t *PrivateSpendingTracker) CheckDailyLimit(
walletID, asset string,
encryptedAmount EncryptedValue,
encryptedLimit EncryptedValue,
) EncryptedValue {
key := walletID + ":" + asset
// Get current total or zero
currentTotal := t.dailyTotals[key]
if currentTotal == nil {
currentTotal = t.fhe.Encrypt64(0)
}
// newTotal = currentTotal + amount
newTotal := t.fhe.Add64(currentTotal, encryptedAmount)
// Check: newTotal <= limit
return t.fhe.Lte64(newTotal, encryptedLimit)
}
// CheckMonthlyLimit checks if adding amount would exceed the monthly limit.
func (t *PrivateSpendingTracker) CheckMonthlyLimit(
walletID, asset string,
encryptedAmount EncryptedValue,
encryptedLimit EncryptedValue,
) EncryptedValue {
key := walletID + ":" + asset
currentTotal := t.monthlyTotals[key]
if currentTotal == nil {
currentTotal = t.fhe.Encrypt64(0)
}
newTotal := t.fhe.Add64(currentTotal, encryptedAmount)
return t.fhe.Lte64(newTotal, encryptedLimit)
}
// ResetDaily resets daily totals (call at midnight).
func (t *PrivateSpendingTracker) ResetDaily() {
t.dailyTotals = make(map[string]EncryptedValue)
}
// ResetMonthly resets monthly totals (call at month start).
func (t *PrivateSpendingTracker) ResetMonthly() {
t.monthlyTotals = make(map[string]EncryptedValue)
}
// ============================================================
// THRESHOLD DECRYPTION
// ============================================================
// ThresholdDecryptResult performs threshold decryption of an encrypted policy result.
// This requires participation from t-of-n MPC nodes.
func ThresholdDecryptResult(
ctx context.Context,
result *EncryptedPolicyResult,
decryptionShares [][]byte,
threshold int,
) (*PolicyResult, error) {
if len(decryptionShares) < threshold {
return nil, fmt.Errorf("insufficient decryption shares: got %d, need %d",
len(decryptionShares), threshold)
}
// Combine shares to decrypt
// This would use the FHE threshold decryption protocol
// For now, return a placeholder
return &PolicyResult{
Allowed: true, // Would be decrypted value
Action: ActionAllow,
MatchedRules: result.MatchedRules,
}, nil
}
// ============================================================
// HELPER FUNCTIONS
// ============================================================
// hashAddress hashes an address to a 64-bit value for FHE operations.
func hashAddress(address string) uint64 {
// Simple hash for demo - use proper cryptographic hash in production
var hash uint64
for i, c := range address {
hash ^= uint64(c) << (uint(i*7) % 64)
}
return hash
}