mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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:
@@ -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
|
||||
},
|
||||
},
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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(""))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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, ".", "/")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user