Compare commits
19 Commits
6f270ef0c4
...
feat-shami
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ab47ec563 | ||
|
|
aff87c13ca | ||
|
|
9f9b6820c2 | ||
|
|
6017ef29ca | ||
|
|
eb16da3a20 | ||
|
|
2fda0484fc | ||
|
|
f8c621b20e | ||
|
|
3b090cd3ce | ||
|
|
99e2b841e9 | ||
|
|
b2b159b16f | ||
|
|
ab767fe158 | ||
|
|
f080a8615f | ||
|
|
514a4cb2d1 | ||
|
|
0b331d90bf | ||
|
|
f981ddeb79 | ||
|
|
8517b981f2 | ||
|
|
af13465c03 | ||
|
|
d7950beb09 | ||
|
|
0cb0de759b |
@@ -4,6 +4,7 @@ package arbiter.operator;
|
|||||||
|
|
||||||
import "operator/auth.proto";
|
import "operator/auth.proto";
|
||||||
import "operator/evm.proto";
|
import "operator/evm.proto";
|
||||||
|
import "operator/governance.proto";
|
||||||
import "operator/sdk_client.proto";
|
import "operator/sdk_client.proto";
|
||||||
import "operator/vault/vault.proto";
|
import "operator/vault/vault.proto";
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ message OperatorRequest {
|
|||||||
vault.Request vault = 2;
|
vault.Request vault = 2;
|
||||||
evm.Request evm = 3;
|
evm.Request evm = 3;
|
||||||
sdk_client.Request sdk_client = 4;
|
sdk_client.Request sdk_client = 4;
|
||||||
|
governance.Request governance = 5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,5 +26,6 @@ message OperatorResponse {
|
|||||||
vault.Response vault = 2;
|
vault.Response vault = 2;
|
||||||
evm.Response evm = 3;
|
evm.Response evm = 3;
|
||||||
sdk_client.Response sdk_client = 4;
|
sdk_client.Response sdk_client = 4;
|
||||||
|
governance.Response governance = 5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
136
protobufs/operator/governance.proto
Normal file
136
protobufs/operator/governance.proto
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package arbiter.operator.governance;
|
||||||
|
|
||||||
|
message Request {
|
||||||
|
oneof payload {
|
||||||
|
CreateProposalRequest create = 1;
|
||||||
|
CastVoteRequest vote = 2;
|
||||||
|
QueryPendingRequest query = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateProposalRequest {
|
||||||
|
oneof kind {
|
||||||
|
ApproveSdkClientPayload approve_sdk_client = 1;
|
||||||
|
GrantWalletAccessPayload grant_wallet_access = 3;
|
||||||
|
ApproveServerUpdatePayload approve_server_update = 4;
|
||||||
|
ReplaceOperatorPayload replace_operator = 5;
|
||||||
|
UpdateShamirParametersPayload update_shamir_parameters = 6;
|
||||||
|
ApprovePersistentGrantPayload approve_persistent_grant = 7;
|
||||||
|
ApproveOneOffTransactionPayload approve_one_off_transaction = 8;
|
||||||
|
}
|
||||||
|
optional uint32 ttl_secs = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReplaceOperatorPayload {
|
||||||
|
int32 old_operator_id = 1;
|
||||||
|
bytes new_pubkey = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateShamirParametersPayload {
|
||||||
|
uint32 new_n = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ApproveServerUpdatePayload {}
|
||||||
|
|
||||||
|
message ApproveSdkClientPayload {
|
||||||
|
int32 client_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GrantWalletAccessPayload {
|
||||||
|
int32 wallet_id = 1;
|
||||||
|
int32 client_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CastVoteRequest {
|
||||||
|
int32 proposal_id = 1;
|
||||||
|
bool approve = 2;
|
||||||
|
bytes signature = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message QueryPendingRequest {}
|
||||||
|
|
||||||
|
message Response {
|
||||||
|
oneof payload {
|
||||||
|
CreateProposalResponse created = 1;
|
||||||
|
VoteResponse voted = 2;
|
||||||
|
QueryPendingResponse pending = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateProposalResponse {
|
||||||
|
int32 proposal_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message VoteResponse {
|
||||||
|
VoteOutcome outcome = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VoteOutcome {
|
||||||
|
VOTE_OUTCOME_UNSPECIFIED = 0;
|
||||||
|
VOTE_OUTCOME_PENDING = 1;
|
||||||
|
VOTE_OUTCOME_APPROVED = 2;
|
||||||
|
VOTE_OUTCOME_REJECTED = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ProposalSummary {
|
||||||
|
int32 id = 1;
|
||||||
|
string kind = 2;
|
||||||
|
int32 initiator_id = 3;
|
||||||
|
int64 expires_at = 4;
|
||||||
|
int64 approve_count = 5;
|
||||||
|
int64 reject_count = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message QueryPendingResponse {
|
||||||
|
repeated ProposalSummary proposals = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TransactionRateLimitProto {
|
||||||
|
uint32 count = 1;
|
||||||
|
int64 window_secs = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message VolumeLimitProto {
|
||||||
|
bytes max_volume = 1;
|
||||||
|
int64 window_secs = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EtherTransferSpecProto {
|
||||||
|
repeated bytes targets = 1;
|
||||||
|
VolumeLimitProto limit = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TokenTransferSpecProto {
|
||||||
|
bytes token_contract = 1;
|
||||||
|
optional bytes target = 2;
|
||||||
|
repeated VolumeLimitProto volume_limits = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ApproveOneOffTransactionPayload {
|
||||||
|
int32 client_id = 1;
|
||||||
|
bytes wallet_address = 2;
|
||||||
|
uint64 chain_id = 3;
|
||||||
|
uint64 nonce = 4;
|
||||||
|
uint64 gas_limit = 5;
|
||||||
|
bytes max_fee_per_gas = 6;
|
||||||
|
bytes max_priority_fee_per_gas = 7;
|
||||||
|
bytes to = 8;
|
||||||
|
bytes value = 9;
|
||||||
|
bytes input = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ApprovePersistentGrantPayload {
|
||||||
|
int32 wallet_access_id = 1;
|
||||||
|
uint64 chain_id = 2;
|
||||||
|
optional int64 valid_from_secs = 3;
|
||||||
|
optional int64 valid_until_secs = 4;
|
||||||
|
optional bytes max_gas_fee_per_gas = 5;
|
||||||
|
optional bytes max_priority_fee_per_gas = 6;
|
||||||
|
optional TransactionRateLimitProto rate_limit = 7;
|
||||||
|
oneof specific {
|
||||||
|
EtherTransferSpecProto ether_transfer = 8;
|
||||||
|
TokenTransferSpecProto token_transfer = 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,18 @@ message BootstrapEncryptedKey {
|
|||||||
|
|
||||||
message DeclareCommittee {
|
message DeclareCommittee {
|
||||||
uint32 count = 1;
|
uint32 count = 1;
|
||||||
|
uint32 recovery_count = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ContributePassphrase {
|
message ContributePassphrase {
|
||||||
bytes passphrase = 1;
|
bytes passphrase = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ContributeRecoveryPassphrase {
|
||||||
|
int32 recovery_operator_id = 1;
|
||||||
|
bytes passphrase = 2;
|
||||||
|
}
|
||||||
|
|
||||||
enum BootstrapResult {
|
enum BootstrapResult {
|
||||||
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
|
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
|
||||||
BOOTSTRAP_RESULT_SUCCESS = 1;
|
BOOTSTRAP_RESULT_SUCCESS = 1;
|
||||||
@@ -29,6 +35,7 @@ message Request {
|
|||||||
BootstrapEncryptedKey encrypted_key = 2;
|
BootstrapEncryptedKey encrypted_key = 2;
|
||||||
DeclareCommittee declare_committee = 3;
|
DeclareCommittee declare_committee = 3;
|
||||||
ContributePassphrase contribute_passphrase = 4;
|
ContributePassphrase contribute_passphrase = 4;
|
||||||
|
ContributeRecoveryPassphrase contribute_recovery_passphrase = 5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
protobufs/operator/vault/rekey.proto
Normal file
30
protobufs/operator/vault/rekey.proto
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package arbiter.operator.vault.rekey;
|
||||||
|
|
||||||
|
message ContributePassphrase {
|
||||||
|
bytes passphrase = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ContributeRecoveryPassphrase {
|
||||||
|
int32 recovery_operator_id = 1;
|
||||||
|
bytes passphrase = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RekeyResult {
|
||||||
|
REKEY_RESULT_UNSPECIFIED = 0;
|
||||||
|
REKEY_RESULT_SUCCESS = 1;
|
||||||
|
REKEY_RESULT_AWAITING_CONTRIBUTIONS = 2;
|
||||||
|
REKEY_RESULT_NOT_IN_PROGRESS = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Request {
|
||||||
|
oneof payload {
|
||||||
|
ContributePassphrase contribute_passphrase = 1;
|
||||||
|
ContributeRecoveryPassphrase contribute_recovery_passphrase = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message Response {
|
||||||
|
RekeyResult result = 1;
|
||||||
|
}
|
||||||
@@ -19,6 +19,11 @@ message ContributePassphrase {
|
|||||||
bytes passphrase = 1;
|
bytes passphrase = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ContributeRecoveryPassphrase {
|
||||||
|
int32 recovery_operator_id = 1;
|
||||||
|
bytes passphrase = 2;
|
||||||
|
}
|
||||||
|
|
||||||
enum UnsealResult {
|
enum UnsealResult {
|
||||||
UNSEAL_RESULT_UNSPECIFIED = 0;
|
UNSEAL_RESULT_UNSPECIFIED = 0;
|
||||||
UNSEAL_RESULT_SUCCESS = 1;
|
UNSEAL_RESULT_SUCCESS = 1;
|
||||||
@@ -32,6 +37,7 @@ message Request {
|
|||||||
UnsealStart start = 1;
|
UnsealStart start = 1;
|
||||||
UnsealEncryptedKey encrypted_key = 2;
|
UnsealEncryptedKey encrypted_key = 2;
|
||||||
ContributePassphrase contribute_passphrase = 3;
|
ContributePassphrase contribute_passphrase = 3;
|
||||||
|
ContributeRecoveryPassphrase contribute_recovery_passphrase = 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package arbiter.operator.vault;
|
|||||||
import "google/protobuf/empty.proto";
|
import "google/protobuf/empty.proto";
|
||||||
import "shared/vault.proto";
|
import "shared/vault.proto";
|
||||||
import "operator/vault/bootstrap.proto";
|
import "operator/vault/bootstrap.proto";
|
||||||
|
import "operator/vault/rekey.proto";
|
||||||
import "operator/vault/unseal.proto";
|
import "operator/vault/unseal.proto";
|
||||||
|
|
||||||
message Request {
|
message Request {
|
||||||
@@ -12,6 +13,7 @@ message Request {
|
|||||||
google.protobuf.Empty query_state = 1;
|
google.protobuf.Empty query_state = 1;
|
||||||
unseal.Request unseal = 2;
|
unseal.Request unseal = 2;
|
||||||
bootstrap.Request bootstrap = 3;
|
bootstrap.Request bootstrap = 3;
|
||||||
|
rekey.Request rekey = 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,5 +22,6 @@ message Response {
|
|||||||
arbiter.shared.VaultState state = 1;
|
arbiter.shared.VaultState state = 1;
|
||||||
unseal.Response unseal = 2;
|
unseal.Response unseal = 2;
|
||||||
bootstrap.Response bootstrap = 3;
|
bootstrap.Response bootstrap = 3;
|
||||||
|
rekey.Response rekey = 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
server/Cargo.lock
generated
1
server/Cargo.lock
generated
@@ -769,6 +769,7 @@ dependencies = [
|
|||||||
"mutants",
|
"mutants",
|
||||||
"pem",
|
"pem",
|
||||||
"proptest",
|
"proptest",
|
||||||
|
"prost",
|
||||||
"prost-types",
|
"prost-types",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
|
|||||||
@@ -26,3 +26,5 @@ trait-assoc-item-kinds-order = [
|
|||||||
"type",
|
"type",
|
||||||
"fn",
|
"fn",
|
||||||
] # community tested standard
|
] # community tested standard
|
||||||
|
|
||||||
|
too-many-lines-threshold = 150
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use rand::RngExt;
|
|||||||
|
|
||||||
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
|
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
|
||||||
pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator";
|
pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator";
|
||||||
|
pub static GOVERNANCE_CONTEXT: &[u8] = b"arbiter_governance_vote";
|
||||||
|
|
||||||
const NONCE_SIZE: usize = 32;
|
const NONCE_SIZE: usize = 32;
|
||||||
|
|
||||||
@@ -90,6 +91,11 @@ impl PublicKey {
|
|||||||
self.0
|
self.0
|
||||||
.verify_with_context(&challenge, context, &signature.0)
|
.verify_with_context(&challenge, context, &signature.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn verify_message(&self, message: &[u8], context: &[u8], signature: &Signature) -> bool {
|
||||||
|
self.0.verify_with_context(message, context, &signature.0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Signature {
|
impl Signature {
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ pub mod proto {
|
|||||||
tonic::include_proto!("arbiter.operator.evm");
|
tonic::include_proto!("arbiter.operator.evm");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod governance {
|
||||||
|
tonic::include_proto!("arbiter.operator.governance");
|
||||||
|
}
|
||||||
|
|
||||||
pub mod sdk_client {
|
pub mod sdk_client {
|
||||||
tonic::include_proto!("arbiter.operator.sdk_client");
|
tonic::include_proto!("arbiter.operator.sdk_client");
|
||||||
}
|
}
|
||||||
@@ -34,6 +38,10 @@ pub mod proto {
|
|||||||
tonic::include_proto!("arbiter.operator.vault.bootstrap");
|
tonic::include_proto!("arbiter.operator.vault.bootstrap");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod rekey {
|
||||||
|
tonic::include_proto!("arbiter.operator.vault.rekey");
|
||||||
|
}
|
||||||
|
|
||||||
pub mod unseal {
|
pub mod unseal {
|
||||||
tonic::include_proto!("arbiter.operator.vault.unseal");
|
tonic::include_proto!("arbiter.operator.vault.unseal");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ pem = "3.0.6"
|
|||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
hmac.workspace = true
|
hmac.workspace = true
|
||||||
alloy.workspace = true
|
alloy.workspace = true
|
||||||
|
prost.workspace = true
|
||||||
prost-types.workspace = true
|
prost-types.workspace = true
|
||||||
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
|
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
|
|||||||
@@ -216,3 +216,73 @@ create table if not exists integrity_envelope (
|
|||||||
) STRICT;
|
) STRICT;
|
||||||
|
|
||||||
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);
|
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);
|
||||||
|
|
||||||
|
create table if not exists proposal (
|
||||||
|
id integer not null primary key,
|
||||||
|
kind text not null,
|
||||||
|
payload blob not null,
|
||||||
|
initiator_id integer not null references operator_identity(id) on delete restrict,
|
||||||
|
created_at integer not null default(unixepoch('now')),
|
||||||
|
expires_at integer not null,
|
||||||
|
status text not null default 'pending'
|
||||||
|
check (status in ('pending', 'approved', 'rejected', 'expired'))
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
create table if not exists proposal_vote (
|
||||||
|
id integer not null primary key,
|
||||||
|
proposal_id integer not null references proposal(id) on delete cascade,
|
||||||
|
operator_id integer not null references operator_identity(id) on delete restrict,
|
||||||
|
approve integer not null check (approve in (0, 1)),
|
||||||
|
signature blob not null,
|
||||||
|
voted_at integer not null default(unixepoch('now')),
|
||||||
|
unique (proposal_id, operator_id)
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
|
||||||
|
create table if not exists proposal_result (
|
||||||
|
proposal_id integer not null primary key references proposal(id) on delete cascade,
|
||||||
|
data blob not null,
|
||||||
|
created_at integer not null default(unixepoch('now'))
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
-- ===============================
|
||||||
|
-- Recovery Operators (§3.4/§3.5/§3.6)
|
||||||
|
-- ===============================
|
||||||
|
|
||||||
|
-- Encrypted Shamir shares for recovery operators (mirrors the `operator` table).
|
||||||
|
create table if not exists recovery_operator (
|
||||||
|
id integer not null primary key references recovery_operator_identity(id) on delete restrict,
|
||||||
|
share blob not null,
|
||||||
|
share_nonce blob not null,
|
||||||
|
share_salt blob not null,
|
||||||
|
created_at integer not null default(unixepoch('now')),
|
||||||
|
updated_at integer not null default(unixepoch('now'))
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
create table if not exists recovery_operator_identity (
|
||||||
|
id integer not null primary key,
|
||||||
|
public_key blob not null unique,
|
||||||
|
created_at integer not null default(unixepoch('now')),
|
||||||
|
updated_at integer not null default(unixepoch('now'))
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
-- One active wakeup request at a time. A request is pending when cancelled_at IS NULL
|
||||||
|
-- and requested_at + 14 days > now. It becomes active (recovery live) after 14 days.
|
||||||
|
create table if not exists recovery_wakeup_request (
|
||||||
|
id integer not null primary key,
|
||||||
|
requested_by integer not null references operator_identity(id) on delete restrict,
|
||||||
|
requested_at integer not null default(unixepoch('now')),
|
||||||
|
cancelled_by integer references operator_identity(id) on delete restrict,
|
||||||
|
cancelled_at integer
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
-- Votes cast by recovery operators; only allowed on replace_operator proposals.
|
||||||
|
create table if not exists recovery_proposal_vote (
|
||||||
|
id integer not null primary key,
|
||||||
|
proposal_id integer not null references proposal(id) on delete cascade,
|
||||||
|
recovery_operator_id integer not null references recovery_operator_identity(id) on delete restrict,
|
||||||
|
approve integer not null check (approve in (0, 1)),
|
||||||
|
signature blob not null,
|
||||||
|
voted_at integer not null default(unixepoch('now')),
|
||||||
|
unique (proposal_id, recovery_operator_id)
|
||||||
|
) STRICT;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
actors::{
|
actors::{
|
||||||
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
|
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
|
||||||
operator_registry::OperatorRegistry, vault::Vault,
|
operator_registry::OperatorRegistry, proposal_manager::ProposalManager, vault::Vault,
|
||||||
vault_coordinator::VaultCoordinator,
|
vault_coordinator::VaultCoordinator,
|
||||||
},
|
},
|
||||||
db,
|
db,
|
||||||
@@ -15,6 +15,7 @@ pub mod bootstrap;
|
|||||||
pub mod evm;
|
pub mod evm;
|
||||||
pub mod flow_coordinator;
|
pub mod flow_coordinator;
|
||||||
pub mod operator_registry;
|
pub mod operator_registry;
|
||||||
|
pub mod proposal_manager;
|
||||||
pub mod vault;
|
pub mod vault;
|
||||||
pub mod vault_coordinator;
|
pub mod vault_coordinator;
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ pub struct GlobalActors {
|
|||||||
pub flow_coordinator: ActorRef<FlowCoordinator>,
|
pub flow_coordinator: ActorRef<FlowCoordinator>,
|
||||||
pub operator_registry: ActorRef<OperatorRegistry>,
|
pub operator_registry: ActorRef<OperatorRegistry>,
|
||||||
pub evm: ActorRef<EvmActor>,
|
pub evm: ActorRef<EvmActor>,
|
||||||
|
pub proposal_manager: ActorRef<ProposalManager>,
|
||||||
pub events: ActorRef<MessageBus>,
|
pub events: ActorRef<MessageBus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,19 +50,27 @@ impl GlobalActors {
|
|||||||
let message_bus = Self::spawn_message_bus();
|
let message_bus = Self::spawn_message_bus();
|
||||||
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
|
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
|
||||||
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
|
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
|
||||||
|
let evm = EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone()));
|
||||||
|
let vault_coordinator = VaultCoordinator::spawn(VaultCoordinator::new(
|
||||||
|
db.clone(),
|
||||||
|
key_holder.clone(),
|
||||||
|
));
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
|
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
|
||||||
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone())),
|
proposal_manager: ProposalManager::spawn(ProposalManager::new(
|
||||||
vault_coordinator: VaultCoordinator::spawn(VaultCoordinator::new(
|
|
||||||
db,
|
db,
|
||||||
key_holder.clone(),
|
key_holder.clone(),
|
||||||
|
evm.clone(),
|
||||||
|
vault_coordinator.clone(),
|
||||||
)),
|
)),
|
||||||
vault: key_holder,
|
vault: key_holder,
|
||||||
|
vault_coordinator,
|
||||||
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
|
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
|
||||||
operator_registry.clone(),
|
operator_registry.clone(),
|
||||||
)),
|
)),
|
||||||
operator_registry,
|
operator_registry,
|
||||||
events: message_bus,
|
events: message_bus,
|
||||||
|
evm,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1011
server/crates/arbiter-server/src/actors/proposal_manager.rs
Normal file
1011
server/crates/arbiter-server/src/actors/proposal_manager.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -275,6 +275,59 @@ impl Vault {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-encrypts the root key with `new_seal_key` and records a new root_key_history row.
|
||||||
|
/// Called after a Shamir re-key so the old seal key is no longer sufficient to unseal.
|
||||||
|
#[message]
|
||||||
|
pub async fn rekey_root_key(&mut self, mut new_seal_key: KeyCell) -> Result<(), Error> {
|
||||||
|
let Unsealed {
|
||||||
|
root_key,
|
||||||
|
root_key_history_id,
|
||||||
|
} = Self::expect_unsealed(&mut self.state)?;
|
||||||
|
|
||||||
|
let new_nonce = Nonce::default();
|
||||||
|
let new_salt = v1::generate_salt();
|
||||||
|
|
||||||
|
let new_ciphertext: Vec<u8> = root_key.0.read_inline(|rk| {
|
||||||
|
new_seal_key
|
||||||
|
.encrypt(&new_nonce, v1::ROOT_KEY_TAG, rk.as_slice())
|
||||||
|
.map_err(|err| {
|
||||||
|
error!(?err, "Fatal rekey error");
|
||||||
|
Error::Encryption(err)
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let data_encryption_nonce = Nonce::default();
|
||||||
|
|
||||||
|
let mut conn = self.db.get().await?;
|
||||||
|
let new_root_key_history_id: i32 = conn
|
||||||
|
.transaction(async |conn| {
|
||||||
|
let new_id = insert_into(schema::root_key_history::table)
|
||||||
|
.values(&models::NewRootKeyHistory {
|
||||||
|
ciphertext: new_ciphertext,
|
||||||
|
tag: v1::ROOT_KEY_TAG.to_vec(),
|
||||||
|
root_key_encryption_nonce: new_nonce.to_vec(),
|
||||||
|
data_encryption_nonce: data_encryption_nonce.to_vec(),
|
||||||
|
schema_version: 1,
|
||||||
|
salt: new_salt.to_vec(),
|
||||||
|
})
|
||||||
|
.returning(schema::root_key_history::id)
|
||||||
|
.get_result::<i32>(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
update(schema::arbiter_settings::table)
|
||||||
|
.set(schema::arbiter_settings::root_key_id.eq(new_id))
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Result::<_, diesel::result::Error>::Ok(new_id)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
*root_key_history_id = RootKeyHistoryId::from_raw(new_root_key_history_id);
|
||||||
|
info!("Vault root key rekeyed successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn seal(&mut self) -> Result<(), Error> {
|
pub async fn seal(&mut self) -> Result<(), Error> {
|
||||||
let Unsealed {
|
let Unsealed {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use rand_core::{OsRng, RngCore as _};
|
|||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
actors::vault::{Bootstrap, TryUnseal, Vault},
|
actors::vault::{Bootstrap, RekeyRootKey, TryUnseal, Vault},
|
||||||
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir},
|
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir, shamir::shamir_threshold},
|
||||||
db::{self, models, schema},
|
db::{self, models, schema},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,6 +19,8 @@ pub enum Error {
|
|||||||
AlreadyBootstrapping,
|
AlreadyBootstrapping,
|
||||||
#[error("Already coordinating an unseal")]
|
#[error("Already coordinating an unseal")]
|
||||||
AlreadyUnsealing,
|
AlreadyUnsealing,
|
||||||
|
#[error("Rekey not in progress")]
|
||||||
|
NotRekeying,
|
||||||
#[error("Bootstrap not in progress")]
|
#[error("Bootstrap not in progress")]
|
||||||
NotBootstrapping,
|
NotBootstrapping,
|
||||||
#[error("Unseal not in progress")]
|
#[error("Unseal not in progress")]
|
||||||
@@ -39,6 +41,8 @@ pub enum Error {
|
|||||||
Encryption,
|
Encryption,
|
||||||
#[error("Vault error")]
|
#[error("Vault error")]
|
||||||
VaultError,
|
VaultError,
|
||||||
|
#[error("Two-operator vaults require at least one recovery share")]
|
||||||
|
TwoOperatorsRequireRecovery,
|
||||||
#[error("Broken database")]
|
#[error("Broken database")]
|
||||||
BrokenDatabase,
|
BrokenDatabase,
|
||||||
}
|
}
|
||||||
@@ -49,11 +53,23 @@ enum CoordinatorState {
|
|||||||
Idle,
|
Idle,
|
||||||
Bootstrapping {
|
Bootstrapping {
|
||||||
declared_count: usize,
|
declared_count: usize,
|
||||||
|
recovery_count: usize,
|
||||||
passphrases: HashMap<i32, Vec<u8>>,
|
passphrases: HashMap<i32, Vec<u8>>,
|
||||||
|
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||||
},
|
},
|
||||||
Unsealing {
|
Unsealing {
|
||||||
threshold: usize,
|
threshold: usize,
|
||||||
|
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||||
|
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||||
|
},
|
||||||
|
/// Shamir re-key after `replace_operator` or `update_shamir_parameters` is approved (§3.3).
|
||||||
|
/// Collects new passphrases from all current operators, then generates a fresh seal key,
|
||||||
|
/// re-splits it, and re-encrypts the vault root key.
|
||||||
|
Rekeying {
|
||||||
|
ordinary_count: usize,
|
||||||
|
recovery_count: usize,
|
||||||
passphrases: HashMap<i32, Vec<u8>>,
|
passphrases: HashMap<i32, Vec<u8>>,
|
||||||
|
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,51 +92,84 @@ impl VaultCoordinator {
|
|||||||
|
|
||||||
const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
|
const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
|
||||||
|
|
||||||
const fn shamir_threshold(n: usize) -> usize {
|
fn encrypt_share(
|
||||||
match n {
|
passphrase_bytes: Vec<u8>,
|
||||||
0 => panic!("No operators"),
|
share: &[u8],
|
||||||
1 => 1,
|
) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), Error> {
|
||||||
2 => 2,
|
let mut share_salt = vec![0u8; 32];
|
||||||
n => n / 2 + 1,
|
OsRng.fill_bytes(&mut share_salt);
|
||||||
}
|
|
||||||
|
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
|
||||||
|
let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt);
|
||||||
|
|
||||||
|
let nonce = Nonce::default();
|
||||||
|
let encrypted_share = share_seal_key
|
||||||
|
.encrypt(&nonce, SHARE_AAD, share)
|
||||||
|
.map_err(|_| Error::Encryption)?;
|
||||||
|
|
||||||
|
Ok((encrypted_share, nonce.to_vec(), share_salt))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decrypt_share(
|
||||||
|
passphrase_bytes: Vec<u8>,
|
||||||
|
encrypted_share: Vec<u8>,
|
||||||
|
share_nonce_bytes: &[u8],
|
||||||
|
share_salt: &[u8],
|
||||||
|
operator_id: i32,
|
||||||
|
) -> Result<Vec<u8>, Error> {
|
||||||
|
let nonce = Nonce::try_from(share_nonce_bytes).map_err(|()| {
|
||||||
|
error!(operator_id, "Invalid nonce in DB");
|
||||||
|
Error::BrokenDatabase
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
|
||||||
|
let mut share_seal_key = derive_key(&mut passphrase_cell, share_salt);
|
||||||
|
|
||||||
|
let mut share_buffer = SafeCell::new(encrypted_share);
|
||||||
|
share_seal_key
|
||||||
|
.decrypt_in_place(&nonce, SHARE_AAD, &mut share_buffer)
|
||||||
|
.map_err(|_| Error::InvalidPassphrase)?;
|
||||||
|
|
||||||
|
Ok(share_buffer.read().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §3.4: Split the seal key across ordinary + recovery operators.
|
||||||
|
/// Threshold = `shamir_threshold(ordinary_count)`; total shares = ordinary + recovery.
|
||||||
|
/// When `ordinary_count` == 1 (threshold = 1), vsss-rs does not support a proper split,
|
||||||
|
/// so each share is the seal key itself — any single participant can reconstruct.
|
||||||
async fn finalize_bootstrap(
|
async fn finalize_bootstrap(
|
||||||
db: db::DatabasePool,
|
db: db::DatabasePool,
|
||||||
vault: ActorRef<Vault>,
|
vault: ActorRef<Vault>,
|
||||||
passphrases: HashMap<i32, Vec<u8>>,
|
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||||
|
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let total = passphrases.len();
|
let ordinary_count = ordinary_passphrases.len();
|
||||||
let threshold = shamir_threshold(total);
|
let recovery_count = recovery_passphrases.len();
|
||||||
|
let total = ordinary_count + recovery_count;
|
||||||
|
let threshold = shamir_threshold(ordinary_count);
|
||||||
|
|
||||||
// Generate random 32-byte seal key
|
|
||||||
let mut seal_key_bytes = [0u8; 32];
|
let mut seal_key_bytes = [0u8; 32];
|
||||||
OsRng.fill_bytes(&mut seal_key_bytes);
|
OsRng.fill_bytes(&mut seal_key_bytes);
|
||||||
|
|
||||||
// Split seal key into shares using Shamir (OsRng from rand_core 0.6, compatible with vsss-rs)
|
// threshold == 1 means any single share reconstructs the key (degenerate split).
|
||||||
let shares = shamir::split_key(threshold, total, &seal_key_bytes, OsRng)
|
// vsss-rs requires threshold >= 2, so we store the key directly in this case.
|
||||||
.map_err(|e| Error::Shamir(e.to_string()))?;
|
let shares: Vec<Vec<u8>> = if threshold >= 2 {
|
||||||
|
shamir::split_key(threshold, total, &seal_key_bytes, OsRng)
|
||||||
|
.map_err(|e| Error::Shamir(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
std::iter::repeat_with(|| seal_key_bytes.to_vec()).take(total).collect()
|
||||||
|
};
|
||||||
|
|
||||||
let seal_key = KeyCell::from(seal_key_bytes);
|
let seal_key = KeyCell::from(seal_key_bytes);
|
||||||
|
|
||||||
let mut conn = db.get().await?;
|
let mut conn = db.get().await?;
|
||||||
|
let mut shares_iter = shares.into_iter();
|
||||||
|
|
||||||
for ((operator_id_raw, passphrase_bytes), share) in passphrases.into_iter().zip(shares) {
|
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
|
||||||
// Generate a fresh share_salt for this operator
|
let share = shares_iter
|
||||||
let mut share_salt = vec![0u8; 32];
|
.next()
|
||||||
OsRng.fill_bytes(&mut share_salt);
|
.expect("split_key returned enough shares");
|
||||||
|
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
|
||||||
// Derive share encryption key from passphrase + salt
|
|
||||||
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
|
|
||||||
let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt);
|
|
||||||
|
|
||||||
// Encrypt this operator's share
|
|
||||||
let nonce = Nonce::default();
|
|
||||||
let encrypted_share = share_seal_key
|
|
||||||
.encrypt(&nonce, SHARE_AAD, &share)
|
|
||||||
.map_err(|_| Error::Encryption)?;
|
|
||||||
|
|
||||||
let nonce_bytes = nonce.to_vec();
|
|
||||||
|
|
||||||
diesel::replace_into(schema::operator::table)
|
diesel::replace_into(schema::operator::table)
|
||||||
.values((
|
.values((
|
||||||
@@ -135,10 +184,26 @@ async fn finalize_bootstrap(
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
vault
|
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
|
||||||
.ask(Bootstrap { seal_key })
|
let share = shares_iter
|
||||||
.await
|
.next()
|
||||||
.map_err(|err| {
|
.expect("split_key returned enough shares");
|
||||||
|
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
|
||||||
|
|
||||||
|
diesel::replace_into(schema::recovery_operator::table)
|
||||||
|
.values((
|
||||||
|
schema::recovery_operator::id.eq(recovery_id_raw),
|
||||||
|
schema::recovery_operator::share.eq(&encrypted_share),
|
||||||
|
schema::recovery_operator::share_nonce.eq(&nonce_bytes),
|
||||||
|
schema::recovery_operator::share_salt.eq(&share_salt),
|
||||||
|
schema::recovery_operator::created_at.eq(models::SqliteTimestamp::now()),
|
||||||
|
schema::recovery_operator::updated_at.eq(models::SqliteTimestamp::now()),
|
||||||
|
))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.ask(Bootstrap { seal_key }).await.map_err(|err| {
|
||||||
error!(?err, "Vault bootstrap failed");
|
error!(?err, "Vault bootstrap failed");
|
||||||
Error::VaultError
|
Error::VaultError
|
||||||
})?;
|
})?;
|
||||||
@@ -146,15 +211,25 @@ async fn finalize_bootstrap(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §3.5: Unseal using any threshold-sized mix of ordinary + recovery shares.
|
||||||
async fn finalize_unseal(
|
async fn finalize_unseal(
|
||||||
db: db::DatabasePool,
|
db: db::DatabasePool,
|
||||||
vault: ActorRef<Vault>,
|
vault: ActorRef<Vault>,
|
||||||
passphrases: HashMap<i32, Vec<u8>>,
|
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||||
|
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let mut conn = db.get().await?;
|
let mut conn = db.get().await?;
|
||||||
|
|
||||||
|
// Determine whether shares were stored as raw keys (threshold=1) or vsss-rs splits (threshold>=2).
|
||||||
|
let ordinary_operator_count: i64 = schema::operator::table
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await?;
|
||||||
|
let threshold = shamir_threshold(ordinary_operator_count as usize);
|
||||||
|
|
||||||
let mut shares: Vec<Vec<u8>> = Vec::new();
|
let mut shares: Vec<Vec<u8>> = Vec::new();
|
||||||
|
|
||||||
for (operator_id_raw, passphrase_bytes) in passphrases {
|
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
|
||||||
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
|
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
|
||||||
schema::operator::table
|
schema::operator::table
|
||||||
.filter(schema::operator::id.eq(Some(operator_id_raw)))
|
.filter(schema::operator::id.eq(Some(operator_id_raw)))
|
||||||
@@ -167,33 +242,134 @@ async fn finalize_unseal(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| Error::OperatorNotFound)?;
|
.map_err(|_| Error::OperatorNotFound)?;
|
||||||
|
|
||||||
let nonce = Nonce::try_from(share_nonce_bytes.as_slice()).map_err(|()| {
|
shares.push(decrypt_share(
|
||||||
error!(operator_id = operator_id_raw, "Invalid nonce in DB");
|
passphrase_bytes,
|
||||||
Error::BrokenDatabase
|
encrypted_share,
|
||||||
})?;
|
&share_nonce_bytes,
|
||||||
|
&share_salt,
|
||||||
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
|
operator_id_raw,
|
||||||
let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt);
|
)?);
|
||||||
|
|
||||||
let mut share_buffer = SafeCell::new(encrypted_share);
|
|
||||||
share_seal_key
|
|
||||||
.decrypt_in_place(&nonce, SHARE_AAD, &mut share_buffer)
|
|
||||||
.map_err(|_| Error::InvalidPassphrase)?;
|
|
||||||
|
|
||||||
let decrypted_share = share_buffer.read().clone();
|
|
||||||
shares.push(decrypted_share);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let seal_key_bytes =
|
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
|
||||||
shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))?;
|
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
|
||||||
|
schema::recovery_operator::table
|
||||||
|
.find(recovery_id_raw)
|
||||||
|
.select((
|
||||||
|
schema::recovery_operator::share,
|
||||||
|
schema::recovery_operator::share_nonce,
|
||||||
|
schema::recovery_operator::share_salt,
|
||||||
|
))
|
||||||
|
.first(&mut conn)
|
||||||
|
.await
|
||||||
|
.map_err(|_| Error::OperatorNotFound)?;
|
||||||
|
|
||||||
|
shares.push(decrypt_share(
|
||||||
|
passphrase_bytes,
|
||||||
|
encrypted_share,
|
||||||
|
&share_nonce_bytes,
|
||||||
|
&share_salt,
|
||||||
|
recovery_id_raw,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When threshold==1, shares are raw 32-byte seal keys (vsss-rs cannot split 1-of-N).
|
||||||
|
// Any single decrypted share is the key itself.
|
||||||
|
let seal_key_bytes: [u8; 32] = if threshold <= 1 {
|
||||||
|
let raw = shares
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| Error::Shamir("No shares available".into()))?;
|
||||||
|
raw.try_into()
|
||||||
|
.map_err(|_| Error::Shamir("Invalid share length".into()))?
|
||||||
|
} else {
|
||||||
|
shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))?
|
||||||
|
};
|
||||||
|
|
||||||
let seal_key = KeyCell::from(seal_key_bytes);
|
let seal_key = KeyCell::from(seal_key_bytes);
|
||||||
|
|
||||||
|
vault.ask(TryUnseal { seal_key }).await.map_err(|err| {
|
||||||
|
error!(?err, "Vault unseal failed");
|
||||||
|
Error::VaultError
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §3.3: Generate a fresh seal key, split across current operators, re-encrypt the vault root key.
|
||||||
|
/// Called after `replace_operator` or `update_shamir_parameters` is approved and all contributors submit.
|
||||||
|
async fn finalize_rekey(
|
||||||
|
db: db::DatabasePool,
|
||||||
|
vault: ActorRef<Vault>,
|
||||||
|
ordinary_passphrases: HashMap<i32, Vec<u8>>,
|
||||||
|
recovery_passphrases: HashMap<i32, Vec<u8>>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let ordinary_count = ordinary_passphrases.len();
|
||||||
|
let recovery_count = recovery_passphrases.len();
|
||||||
|
let total = ordinary_count + recovery_count;
|
||||||
|
let threshold = shamir_threshold(ordinary_count);
|
||||||
|
|
||||||
|
let mut new_seal_key_bytes = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut new_seal_key_bytes);
|
||||||
|
|
||||||
|
let shares: Vec<Vec<u8>> = if threshold >= 2 {
|
||||||
|
shamir::split_key(threshold, total, &new_seal_key_bytes, OsRng)
|
||||||
|
.map_err(|e| Error::Shamir(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
std::iter::repeat_with(|| new_seal_key_bytes.to_vec())
|
||||||
|
.take(total)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut conn = db.get().await?;
|
||||||
|
let mut shares_iter = shares.into_iter();
|
||||||
|
|
||||||
|
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
|
||||||
|
let share = shares_iter
|
||||||
|
.next()
|
||||||
|
.expect("split_key returned enough shares");
|
||||||
|
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
|
||||||
|
|
||||||
|
diesel::replace_into(schema::operator::table)
|
||||||
|
.values((
|
||||||
|
schema::operator::id.eq(Some(operator_id_raw)),
|
||||||
|
schema::operator::share.eq(&encrypted_share),
|
||||||
|
schema::operator::share_nonce.eq(&nonce_bytes),
|
||||||
|
schema::operator::share_salt.eq(&share_salt),
|
||||||
|
schema::operator::created_at.eq(models::SqliteTimestamp::now()),
|
||||||
|
schema::operator::updated_at.eq(models::SqliteTimestamp::now()),
|
||||||
|
))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
|
||||||
|
let share = shares_iter
|
||||||
|
.next()
|
||||||
|
.expect("split_key returned enough shares");
|
||||||
|
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
|
||||||
|
|
||||||
|
diesel::replace_into(schema::recovery_operator::table)
|
||||||
|
.values((
|
||||||
|
schema::recovery_operator::id.eq(recovery_id_raw),
|
||||||
|
schema::recovery_operator::share.eq(&encrypted_share),
|
||||||
|
schema::recovery_operator::share_nonce.eq(&nonce_bytes),
|
||||||
|
schema::recovery_operator::share_salt.eq(&share_salt),
|
||||||
|
schema::recovery_operator::created_at.eq(models::SqliteTimestamp::now()),
|
||||||
|
schema::recovery_operator::updated_at.eq(models::SqliteTimestamp::now()),
|
||||||
|
))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
let new_seal_key = KeyCell::from(new_seal_key_bytes);
|
||||||
vault
|
vault
|
||||||
.ask(TryUnseal { seal_key })
|
.ask(RekeyRootKey { new_seal_key })
|
||||||
.await
|
.await
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
error!(?err, "Vault unseal failed");
|
error!(?err, "Vault rekey failed");
|
||||||
Error::VaultError
|
Error::VaultError
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -209,20 +385,26 @@ impl VaultCoordinator {
|
|||||||
&mut self,
|
&mut self,
|
||||||
operator_id: i32,
|
operator_id: i32,
|
||||||
declared_count: usize,
|
declared_count: usize,
|
||||||
|
recovery_count: usize,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let _ = operator_id; // fixme!: any authenticated operator may announce the committee size. the first call wins
|
let _ = operator_id; // fixme!: any authenticated operator may announce the committee size. the first call wins
|
||||||
if !matches!(self.state, CoordinatorState::Idle) {
|
if !matches!(self.state, CoordinatorState::Idle) {
|
||||||
return Err(Error::AlreadyBootstrapping);
|
return Err(Error::AlreadyBootstrapping);
|
||||||
}
|
}
|
||||||
|
if declared_count == 2 && recovery_count == 0 {
|
||||||
|
return Err(Error::TwoOperatorsRequireRecovery);
|
||||||
|
}
|
||||||
self.state = CoordinatorState::Bootstrapping {
|
self.state = CoordinatorState::Bootstrapping {
|
||||||
declared_count,
|
declared_count,
|
||||||
|
recovery_count,
|
||||||
passphrases: HashMap::new(),
|
passphrases: HashMap::new(),
|
||||||
|
recovery_passphrases: HashMap::new(),
|
||||||
};
|
};
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 2 of multi-operator bootstrap: contribute a passphrase.
|
/// Phase 2 of multi-operator bootstrap: ordinary operator contributes a passphrase.
|
||||||
/// Returns Ok(true) when all operators contributed and bootstrap finalized.
|
/// Returns Ok(true) when all ordinary + recovery operators contributed and bootstrap finalized.
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn contribute_bootstrap(
|
pub async fn contribute_bootstrap(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -231,7 +413,9 @@ impl VaultCoordinator {
|
|||||||
) -> Result<bool, Error> {
|
) -> Result<bool, Error> {
|
||||||
let CoordinatorState::Bootstrapping {
|
let CoordinatorState::Bootstrapping {
|
||||||
declared_count,
|
declared_count,
|
||||||
|
recovery_count,
|
||||||
passphrases,
|
passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
} = &mut self.state
|
} = &mut self.state
|
||||||
else {
|
else {
|
||||||
return Err(Error::NotBootstrapping);
|
return Err(Error::NotBootstrapping);
|
||||||
@@ -241,25 +425,81 @@ impl VaultCoordinator {
|
|||||||
return Err(Error::DuplicateContribution);
|
return Err(Error::DuplicateContribution);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract bytes immediately so state stays Sync
|
|
||||||
let passphrase_bytes = passphrase.read().to_vec();
|
let passphrase_bytes = passphrase.read().to_vec();
|
||||||
passphrases.insert(operator_id, passphrase_bytes);
|
passphrases.insert(operator_id, passphrase_bytes);
|
||||||
|
|
||||||
if passphrases.len() < *declared_count {
|
if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let CoordinatorState::Bootstrapping { passphrases, .. } =
|
let CoordinatorState::Bootstrapping {
|
||||||
std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
|
..
|
||||||
|
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||||
else {
|
else {
|
||||||
unreachable!()
|
unreachable!()
|
||||||
};
|
};
|
||||||
|
|
||||||
finalize_bootstrap(self.db.clone(), self.vault.clone(), passphrases).await?;
|
finalize_bootstrap(
|
||||||
|
self.db.clone(),
|
||||||
|
self.vault.clone(),
|
||||||
|
passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Contribute a passphrase for vault unseal.
|
/// Phase 2 of multi-operator bootstrap: recovery operator contributes a passphrase.
|
||||||
|
/// Returns Ok(true) when all contributors are in and bootstrap finalized.
|
||||||
|
#[message]
|
||||||
|
pub async fn contribute_recovery_bootstrap(
|
||||||
|
&mut self,
|
||||||
|
recovery_operator_id: i32,
|
||||||
|
mut passphrase: SafeCell<Vec<u8>>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
let CoordinatorState::Bootstrapping {
|
||||||
|
declared_count,
|
||||||
|
recovery_count,
|
||||||
|
passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
|
} = &mut self.state
|
||||||
|
else {
|
||||||
|
return Err(Error::NotBootstrapping);
|
||||||
|
};
|
||||||
|
|
||||||
|
if recovery_passphrases.contains_key(&recovery_operator_id) {
|
||||||
|
return Err(Error::DuplicateContribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
let passphrase_bytes = passphrase.read().to_vec();
|
||||||
|
recovery_passphrases.insert(recovery_operator_id, passphrase_bytes);
|
||||||
|
|
||||||
|
if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let CoordinatorState::Bootstrapping {
|
||||||
|
passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
|
..
|
||||||
|
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
|
||||||
|
finalize_bootstrap(
|
||||||
|
self.db.clone(),
|
||||||
|
self.vault.clone(),
|
||||||
|
passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contribute a passphrase for vault unseal (ordinary operator).
|
||||||
/// Returns Ok(true) when threshold reached and vault is unsealed.
|
/// Returns Ok(true) when threshold reached and vault is unsealed.
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn contribute_unseal(
|
pub async fn contribute_unseal(
|
||||||
@@ -267,46 +507,215 @@ impl VaultCoordinator {
|
|||||||
operator_id: i32,
|
operator_id: i32,
|
||||||
mut passphrase: SafeCell<Vec<u8>>,
|
mut passphrase: SafeCell<Vec<u8>>,
|
||||||
) -> Result<bool, Error> {
|
) -> Result<bool, Error> {
|
||||||
if matches!(self.state, CoordinatorState::Idle) {
|
self.ensure_unsealing_state().await?;
|
||||||
let mut conn = self.db.get().await?;
|
|
||||||
let count: i64 = schema::operator::table
|
|
||||||
.count()
|
|
||||||
.get_result(&mut conn)
|
|
||||||
.await?;
|
|
||||||
let threshold = shamir_threshold(usize::try_from(count).unwrap_or_default());
|
|
||||||
|
|
||||||
self.state = CoordinatorState::Unsealing {
|
|
||||||
threshold,
|
|
||||||
passphrases: HashMap::new(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let CoordinatorState::Unsealing {
|
let CoordinatorState::Unsealing {
|
||||||
threshold,
|
threshold,
|
||||||
passphrases,
|
ordinary_passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
} = &mut self.state
|
} = &mut self.state
|
||||||
else {
|
else {
|
||||||
return Err(Error::NotUnsealing);
|
return Err(Error::NotUnsealing);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if ordinary_passphrases.contains_key(&operator_id) {
|
||||||
|
return Err(Error::DuplicateContribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
let passphrase_bytes = passphrase.read().to_vec();
|
||||||
|
ordinary_passphrases.insert(operator_id, passphrase_bytes);
|
||||||
|
|
||||||
|
if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.do_finalize_unseal().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contribute a passphrase for vault unseal (recovery operator, §3.5).
|
||||||
|
/// Recovery operators may contribute during unseal when recovery is active.
|
||||||
|
/// Returns Ok(true) when threshold reached and vault is unsealed.
|
||||||
|
#[message]
|
||||||
|
pub async fn contribute_recovery_unseal(
|
||||||
|
&mut self,
|
||||||
|
recovery_operator_id: i32,
|
||||||
|
mut passphrase: SafeCell<Vec<u8>>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
self.ensure_unsealing_state().await?;
|
||||||
|
|
||||||
|
let CoordinatorState::Unsealing {
|
||||||
|
threshold,
|
||||||
|
ordinary_passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
|
} = &mut self.state
|
||||||
|
else {
|
||||||
|
return Err(Error::NotUnsealing);
|
||||||
|
};
|
||||||
|
|
||||||
|
if recovery_passphrases.contains_key(&recovery_operator_id) {
|
||||||
|
return Err(Error::DuplicateContribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
let passphrase_bytes = passphrase.read().to_vec();
|
||||||
|
recovery_passphrases.insert(recovery_operator_id, passphrase_bytes);
|
||||||
|
|
||||||
|
if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.do_finalize_unseal().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VaultCoordinator {
|
||||||
|
/// Initializes `CoordinatorState::Unsealing` on first call if still `Idle`.
|
||||||
|
/// Threshold is based on ordinary operator count only (§3.4).
|
||||||
|
async fn ensure_unsealing_state(&mut self) -> Result<(), Error> {
|
||||||
|
if matches!(self.state, CoordinatorState::Idle) {
|
||||||
|
let mut conn = self.db.get().await?;
|
||||||
|
let ordinary_count: i64 = schema::operator::table
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await?;
|
||||||
|
let threshold = shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default());
|
||||||
|
self.state = CoordinatorState::Unsealing {
|
||||||
|
threshold,
|
||||||
|
ordinary_passphrases: HashMap::new(),
|
||||||
|
recovery_passphrases: HashMap::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves state back to Idle and calls finalize_unseal.
|
||||||
|
async fn do_finalize_unseal(&mut self) -> Result<bool, Error> {
|
||||||
|
let CoordinatorState::Unsealing {
|
||||||
|
ordinary_passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
|
..
|
||||||
|
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
|
||||||
|
finalize_unseal(
|
||||||
|
self.db.clone(),
|
||||||
|
self.vault.clone(),
|
||||||
|
ordinary_passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_finalize_rekey(&mut self) -> Result<bool, Error> {
|
||||||
|
let CoordinatorState::Rekeying {
|
||||||
|
passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
|
..
|
||||||
|
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
|
||||||
|
finalize_rekey(
|
||||||
|
self.db.clone(),
|
||||||
|
self.vault.clone(),
|
||||||
|
passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[messages]
|
||||||
|
impl VaultCoordinator {
|
||||||
|
/// Begin Shamir re-key after a key-rotation proposal is approved (§3.3).
|
||||||
|
/// Queries the current operator and recovery operator counts from the DB,
|
||||||
|
/// then transitions to Rekeying state awaiting contributions from all of them.
|
||||||
|
#[message]
|
||||||
|
pub async fn start_rekey(&mut self) -> Result<(), Error> {
|
||||||
|
if !matches!(self.state, CoordinatorState::Idle) {
|
||||||
|
return Err(Error::AlreadyBootstrapping);
|
||||||
|
}
|
||||||
|
let mut conn = self.db.get().await?;
|
||||||
|
let ordinary_count: i64 = schema::operator_identity::table
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await?;
|
||||||
|
let recovery_count: i64 = schema::recovery_operator_identity::table
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await?;
|
||||||
|
self.state = CoordinatorState::Rekeying {
|
||||||
|
ordinary_count: ordinary_count as usize,
|
||||||
|
recovery_count: recovery_count as usize,
|
||||||
|
passphrases: HashMap::new(),
|
||||||
|
recovery_passphrases: HashMap::new(),
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contribute an ordinary operator passphrase for the re-key.
|
||||||
|
/// Returns Ok(true) when all contributors have submitted and the re-key is complete.
|
||||||
|
#[message]
|
||||||
|
pub async fn contribute_rekey(
|
||||||
|
&mut self,
|
||||||
|
operator_id: i32,
|
||||||
|
mut passphrase: SafeCell<Vec<u8>>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
let CoordinatorState::Rekeying {
|
||||||
|
ordinary_count,
|
||||||
|
recovery_count,
|
||||||
|
passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
|
} = &mut self.state
|
||||||
|
else {
|
||||||
|
return Err(Error::NotRekeying);
|
||||||
|
};
|
||||||
|
|
||||||
if passphrases.contains_key(&operator_id) {
|
if passphrases.contains_key(&operator_id) {
|
||||||
return Err(Error::DuplicateContribution);
|
return Err(Error::DuplicateContribution);
|
||||||
}
|
}
|
||||||
|
|
||||||
let passphrase_bytes = passphrase.read().to_vec();
|
passphrases.insert(operator_id, passphrase.read().to_vec());
|
||||||
passphrases.insert(operator_id, passphrase_bytes);
|
|
||||||
|
|
||||||
if passphrases.len() < *threshold {
|
if passphrases.len() < *ordinary_count || recovery_passphrases.len() < *recovery_count {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let CoordinatorState::Unsealing { passphrases, .. } =
|
self.do_finalize_rekey().await
|
||||||
std::mem::replace(&mut self.state, CoordinatorState::Idle)
|
}
|
||||||
|
|
||||||
|
/// Contribute a recovery operator passphrase for the re-key.
|
||||||
|
/// Returns Ok(true) when all contributors have submitted and the re-key is complete.
|
||||||
|
#[message]
|
||||||
|
pub async fn contribute_recovery_rekey(
|
||||||
|
&mut self,
|
||||||
|
recovery_operator_id: i32,
|
||||||
|
mut passphrase: SafeCell<Vec<u8>>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
let CoordinatorState::Rekeying {
|
||||||
|
ordinary_count,
|
||||||
|
recovery_count,
|
||||||
|
passphrases,
|
||||||
|
recovery_passphrases,
|
||||||
|
} = &mut self.state
|
||||||
else {
|
else {
|
||||||
unreachable!()
|
return Err(Error::NotRekeying);
|
||||||
};
|
};
|
||||||
|
|
||||||
finalize_unseal(self.db.clone(), self.vault.clone(), passphrases).await?;
|
if recovery_passphrases.contains_key(&recovery_operator_id) {
|
||||||
Ok(true)
|
return Err(Error::DuplicateContribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
recovery_passphrases.insert(recovery_operator_id, passphrase.read().to_vec());
|
||||||
|
|
||||||
|
if passphrases.len() < *ordinary_count || recovery_passphrases.len() < *recovery_count {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.do_finalize_rekey().await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ pub fn split_key(
|
|||||||
.map_err(|e| ShamirError::Split(format!("{e:?}")))
|
.map_err(|e| ShamirError::Split(format!("{e:?}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the minimum number of shares required to reconstruct the secret
|
||||||
|
/// for a committee of `n` operators.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn shamir_threshold(n: usize) -> usize {
|
||||||
|
match n {
|
||||||
|
0 => panic!("No operators"),
|
||||||
|
1 => 1,
|
||||||
|
2 => 2,
|
||||||
|
n => n / 2 + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Reconstruct the secret from `threshold` or more shares.
|
/// Reconstruct the secret from `threshold` or more shares.
|
||||||
pub fn combine_shares(shares: &[Vec<u8>]) -> Result<[u8; 32], ShamirError> {
|
pub fn combine_shares(shares: &[Vec<u8>]) -> Result<[u8; 32], ShamirError> {
|
||||||
let bytes = Gf256::combine_array(shares)
|
let bytes = Gf256::combine_array(shares)
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ use restructed::Models;
|
|||||||
pub mod types {
|
pub mod types {
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use diesel::{
|
use diesel::{
|
||||||
|
backend::Backend,
|
||||||
deserialize::{FromSql, FromSqlRow},
|
deserialize::{FromSql, FromSqlRow},
|
||||||
expression::AsExpression,
|
expression::AsExpression,
|
||||||
serialize::{IsNull, ToSql},
|
serialize::{IsNull, ToSql},
|
||||||
sql_types::Integer,
|
sql_types::{Integer, Text},
|
||||||
sqlite::{Sqlite, SqliteType},
|
sqlite::{Sqlite, SqliteType},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ pub mod types {
|
|||||||
|
|
||||||
impl FromSql<Integer, Sqlite> for SqliteTimestamp {
|
impl FromSql<Integer, Sqlite> for SqliteTimestamp {
|
||||||
fn from_sql(
|
fn from_sql(
|
||||||
mut bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
|
mut bytes: <Sqlite as Backend>::RawValue<'_>,
|
||||||
) -> diesel::deserialize::Result<Self> {
|
) -> diesel::deserialize::Result<Self> {
|
||||||
let Some(SqliteType::Long) = bytes.value_type() else {
|
let Some(SqliteType::Long) = bytes.value_type() else {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -141,6 +142,45 @@ pub mod types {
|
|||||||
declare_id!(TlsHistoryId);
|
declare_id!(TlsHistoryId);
|
||||||
declare_id!(EvmWalletId);
|
declare_id!(EvmWalletId);
|
||||||
declare_id!(ClientId);
|
declare_id!(ClientId);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, AsExpression, FromSqlRow)]
|
||||||
|
#[diesel(sql_type = Text)]
|
||||||
|
pub enum ProposalStatus {
|
||||||
|
Pending,
|
||||||
|
Approved,
|
||||||
|
Rejected,
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToSql<Text, Sqlite> for ProposalStatus {
|
||||||
|
fn to_sql<'b>(
|
||||||
|
&'b self,
|
||||||
|
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
|
||||||
|
) -> diesel::serialize::Result {
|
||||||
|
let s: &str = match self {
|
||||||
|
Self::Pending => "pending",
|
||||||
|
Self::Approved => "approved",
|
||||||
|
Self::Rejected => "rejected",
|
||||||
|
Self::Expired => "expired",
|
||||||
|
};
|
||||||
|
<str as ToSql<Text, Sqlite>>::to_sql(s, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql<Text, Sqlite> for ProposalStatus {
|
||||||
|
fn from_sql(
|
||||||
|
bytes: <Sqlite as Backend>::RawValue<'_>,
|
||||||
|
) -> diesel::deserialize::Result<Self> {
|
||||||
|
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
|
||||||
|
match s.as_str() {
|
||||||
|
"pending" => Ok(Self::Pending),
|
||||||
|
"approved" => Ok(Self::Approved),
|
||||||
|
"rejected" => Ok(Self::Rejected),
|
||||||
|
"expired" => Ok(Self::Expired),
|
||||||
|
other => Err(format!("Unknown proposal status: {other}").into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
|
|
||||||
@@ -438,3 +478,68 @@ pub struct IntegrityEnvelope {
|
|||||||
pub signed_at: SqliteTimestamp,
|
pub signed_at: SqliteTimestamp,
|
||||||
pub created_at: SqliteTimestamp,
|
pub created_at: SqliteTimestamp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Queryable, Selectable, Identifiable)]
|
||||||
|
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
|
||||||
|
pub struct Proposal {
|
||||||
|
pub id: i32,
|
||||||
|
pub kind: String,
|
||||||
|
pub payload: Vec<u8>,
|
||||||
|
pub initiator_id: i32,
|
||||||
|
pub created_at: SqliteTimestamp,
|
||||||
|
pub expires_at: SqliteTimestamp,
|
||||||
|
pub status: ProposalStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
|
||||||
|
pub struct NewProposal {
|
||||||
|
pub kind: String,
|
||||||
|
pub payload: Vec<u8>,
|
||||||
|
pub initiator_id: i32,
|
||||||
|
// status defaults to 'pending' at the DB layer
|
||||||
|
pub expires_at: SqliteTimestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Queryable, Selectable, Identifiable)]
|
||||||
|
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
|
||||||
|
pub struct ProposalVote {
|
||||||
|
pub id: i32,
|
||||||
|
pub proposal_id: i32,
|
||||||
|
pub operator_id: i32,
|
||||||
|
pub approve: bool,
|
||||||
|
pub signature: Vec<u8>,
|
||||||
|
pub voted_at: SqliteTimestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
|
||||||
|
pub struct NewProposalVote {
|
||||||
|
pub proposal_id: i32,
|
||||||
|
pub operator_id: i32,
|
||||||
|
pub approve: bool,
|
||||||
|
pub signature: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = schema::proposal_result, check_for_backend(Sqlite))]
|
||||||
|
pub struct NewProposalResult {
|
||||||
|
pub proposal_id: i32,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = schema::recovery_proposal_vote, check_for_backend(Sqlite))]
|
||||||
|
pub struct NewRecoveryProposalVote {
|
||||||
|
pub proposal_id: i32,
|
||||||
|
pub recovery_operator_id: i32,
|
||||||
|
pub approve: bool,
|
||||||
|
pub signature: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = schema::recovery_wakeup_request, check_for_backend(Sqlite))]
|
||||||
|
pub struct NewRecoveryWakeupRequest {
|
||||||
|
pub requested_by: i32,
|
||||||
|
}
|
||||||
@@ -172,6 +172,78 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
proposal (id) {
|
||||||
|
id -> Integer,
|
||||||
|
kind -> Text,
|
||||||
|
payload -> Binary,
|
||||||
|
initiator_id -> Integer,
|
||||||
|
created_at -> Integer,
|
||||||
|
expires_at -> Integer,
|
||||||
|
status -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
proposal_result (proposal_id) {
|
||||||
|
proposal_id -> Integer,
|
||||||
|
data -> Binary,
|
||||||
|
created_at -> Integer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
recovery_operator (id) {
|
||||||
|
id -> Integer,
|
||||||
|
share -> Binary,
|
||||||
|
share_nonce -> Binary,
|
||||||
|
share_salt -> Binary,
|
||||||
|
created_at -> Integer,
|
||||||
|
updated_at -> Integer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
recovery_operator_identity (id) {
|
||||||
|
id -> Integer,
|
||||||
|
public_key -> Binary,
|
||||||
|
created_at -> Integer,
|
||||||
|
updated_at -> Integer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
recovery_wakeup_request (id) {
|
||||||
|
id -> Integer,
|
||||||
|
requested_by -> Integer,
|
||||||
|
requested_at -> Integer,
|
||||||
|
cancelled_by -> Nullable<Integer>,
|
||||||
|
cancelled_at -> Nullable<Integer>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
recovery_proposal_vote (id) {
|
||||||
|
id -> Integer,
|
||||||
|
proposal_id -> Integer,
|
||||||
|
recovery_operator_id -> Integer,
|
||||||
|
approve -> Bool,
|
||||||
|
signature -> Binary,
|
||||||
|
voted_at -> Integer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
proposal_vote (id) {
|
||||||
|
id -> Integer,
|
||||||
|
proposal_id -> Integer,
|
||||||
|
operator_id -> Integer,
|
||||||
|
approve -> Bool,
|
||||||
|
signature -> Binary,
|
||||||
|
voted_at -> Integer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
program_client (id) {
|
program_client (id) {
|
||||||
id -> Integer,
|
id -> Integer,
|
||||||
@@ -225,9 +297,22 @@ diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id));
|
|||||||
diesel::joinable!(evm_wallet_access -> program_client (client_id));
|
diesel::joinable!(evm_wallet_access -> program_client (client_id));
|
||||||
diesel::joinable!(operator -> operator_identity (id));
|
diesel::joinable!(operator -> operator_identity (id));
|
||||||
diesel::joinable!(program_client -> client_metadata (metadata_id));
|
diesel::joinable!(program_client -> client_metadata (metadata_id));
|
||||||
|
diesel::joinable!(proposal -> operator_identity (initiator_id));
|
||||||
|
diesel::joinable!(proposal_result -> proposal (proposal_id));
|
||||||
|
diesel::joinable!(proposal_vote -> proposal (proposal_id));
|
||||||
|
diesel::joinable!(proposal_vote -> operator_identity (operator_id));
|
||||||
|
diesel::joinable!(recovery_operator -> recovery_operator_identity (id));
|
||||||
|
diesel::joinable!(recovery_proposal_vote -> proposal (proposal_id));
|
||||||
|
diesel::joinable!(recovery_proposal_vote -> recovery_operator_identity (recovery_operator_id));
|
||||||
|
diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by));
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
aead_encrypted,
|
aead_encrypted,
|
||||||
|
proposal_result,
|
||||||
|
recovery_operator,
|
||||||
|
recovery_operator_identity,
|
||||||
|
recovery_wakeup_request,
|
||||||
|
recovery_proposal_vote,
|
||||||
arbiter_settings,
|
arbiter_settings,
|
||||||
client_metadata,
|
client_metadata,
|
||||||
client_metadata_history,
|
client_metadata_history,
|
||||||
@@ -245,6 +330,8 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
operator,
|
operator,
|
||||||
operator_identity,
|
operator_identity,
|
||||||
program_client,
|
program_client,
|
||||||
|
proposal,
|
||||||
|
proposal_vote,
|
||||||
root_key_history,
|
root_key_history,
|
||||||
tls_history,
|
tls_history,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use tracing::{error, info, warn};
|
|||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod evm;
|
mod evm;
|
||||||
|
mod governance;
|
||||||
mod inbound;
|
mod inbound;
|
||||||
mod outbound;
|
mod outbound;
|
||||||
mod sdk_client;
|
mod sdk_client;
|
||||||
@@ -115,6 +116,7 @@ async fn dispatch_inner(
|
|||||||
warn!("Unsupported post-auth operator auth request");
|
warn!("Unsupported post-auth operator auth request");
|
||||||
Err(Status::invalid_argument("Unsupported operator request"))
|
Err(Status::invalid_argument("Unsupported operator request"))
|
||||||
}
|
}
|
||||||
|
OperatorRequestPayload::Governance(req) => governance::dispatch(actor, req).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
155
server/crates/arbiter-server/src/grpc/operator/governance.rs
Normal file
155
server/crates/arbiter-server/src/grpc/operator/governance.rs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
use crate::{
|
||||||
|
actors::proposal_manager::{Error as ProposalError, ProposalKind, VoteOutcome},
|
||||||
|
peers::operator::{
|
||||||
|
OperatorSession,
|
||||||
|
session::handlers::{HandleCastVote, HandleCreateProposal, HandleQueryPending},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use arbiter_proto::proto::operator::{
|
||||||
|
governance::{
|
||||||
|
self as proto_gov, CreateProposalRequest, QueryPendingRequest, QueryPendingResponse,
|
||||||
|
VoteOutcome as ProtoVoteOutcome, create_proposal_request::Kind as ProtoKind,
|
||||||
|
request::Payload as GovRequestPayload, response::Payload as GovResponsePayload,
|
||||||
|
},
|
||||||
|
operator_response::Payload as OperatorResponsePayload,
|
||||||
|
};
|
||||||
|
use kameo::actor::ActorRef;
|
||||||
|
use tonic::Status;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
const fn wrap(payload: GovResponsePayload) -> OperatorResponsePayload {
|
||||||
|
OperatorResponsePayload::Governance(proto_gov::Response {
|
||||||
|
payload: Some(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn dispatch(
|
||||||
|
actor: &ActorRef<OperatorSession>,
|
||||||
|
req: proto_gov::Request,
|
||||||
|
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||||
|
let Some(payload) = req.payload else {
|
||||||
|
return Err(Status::invalid_argument(
|
||||||
|
"Missing governance request payload",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
match payload {
|
||||||
|
GovRequestPayload::Create(req) => handle_create(actor, req).await,
|
||||||
|
GovRequestPayload::Vote(req) => handle_vote(actor, req).await,
|
||||||
|
GovRequestPayload::Query(QueryPendingRequest {}) => handle_query(actor).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_create(
|
||||||
|
actor: &ActorRef<OperatorSession>,
|
||||||
|
req: CreateProposalRequest,
|
||||||
|
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||||
|
let kind = match req.kind {
|
||||||
|
Some(ProtoKind::ApproveSdkClient(p)) => ProposalKind::ApproveSdkClient {
|
||||||
|
client_id: p.client_id,
|
||||||
|
},
|
||||||
|
Some(ProtoKind::GrantWalletAccess(p)) => ProposalKind::GrantWalletAccess {
|
||||||
|
wallet_id: p.wallet_id,
|
||||||
|
client_id: p.client_id,
|
||||||
|
},
|
||||||
|
Some(ProtoKind::ApproveServerUpdate(_)) => ProposalKind::ApproveServerUpdate,
|
||||||
|
Some(ProtoKind::ReplaceOperator(p)) => ProposalKind::ReplaceOperator {
|
||||||
|
old_operator_id: p.old_operator_id,
|
||||||
|
new_pubkey: p.new_pubkey,
|
||||||
|
},
|
||||||
|
Some(ProtoKind::UpdateShamirParameters(p)) => ProposalKind::UpdateShamirParameters {
|
||||||
|
#[expect(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::as_conversions,
|
||||||
|
reason = "new_n is always a small operator count"
|
||||||
|
)]
|
||||||
|
new_n: p.new_n as u8,
|
||||||
|
},
|
||||||
|
Some(ProtoKind::ApprovePersistentGrant(p)) => {
|
||||||
|
use prost::Message as _;
|
||||||
|
ProposalKind::ApprovePersistentGrant {
|
||||||
|
payload_bytes: p.encode_to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ProtoKind::ApproveOneOffTransaction(p)) => {
|
||||||
|
use prost::Message as _;
|
||||||
|
ProposalKind::ApproveOneOffTransaction {
|
||||||
|
payload_bytes: p.encode_to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => return Err(Status::invalid_argument("Missing proposal kind")),
|
||||||
|
};
|
||||||
|
let ttl_secs = req.ttl_secs.map(i64::from);
|
||||||
|
|
||||||
|
let proposal_id = actor
|
||||||
|
.ask(HandleCreateProposal { kind, ttl_secs })
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!(?e, "create_proposal failed");
|
||||||
|
Status::internal("Failed to create proposal")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Some(wrap(GovResponsePayload::Created(
|
||||||
|
proto_gov::CreateProposalResponse { proposal_id },
|
||||||
|
))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_vote(
|
||||||
|
actor: &ActorRef<OperatorSession>,
|
||||||
|
req: proto_gov::CastVoteRequest,
|
||||||
|
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||||
|
let result = actor
|
||||||
|
.ask(HandleCastVote {
|
||||||
|
proposal_id: req.proposal_id,
|
||||||
|
approve: req.approve,
|
||||||
|
signature: req.signature,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let outcome = match result {
|
||||||
|
Ok(VoteOutcome::Pending) => ProtoVoteOutcome::Pending,
|
||||||
|
Ok(VoteOutcome::QuorumApproved) => ProtoVoteOutcome::Approved,
|
||||||
|
Ok(VoteOutcome::QuorumRejected) => ProtoVoteOutcome::Rejected,
|
||||||
|
Err(kameo::error::SendError::HandlerError(ProposalError::AlreadyVoted)) => {
|
||||||
|
return Err(Status::invalid_argument("Already voted on this proposal"));
|
||||||
|
}
|
||||||
|
Err(kameo::error::SendError::HandlerError(ProposalError::InvalidSignature)) => {
|
||||||
|
return Err(Status::invalid_argument("Invalid vote signature"));
|
||||||
|
}
|
||||||
|
Err(kameo::error::SendError::HandlerError(ProposalError::ProposalNotFound)) => {
|
||||||
|
return Err(Status::not_found("Proposal not found"));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(?e, "cast_vote failed");
|
||||||
|
return Err(Status::internal("Failed to cast vote"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(wrap(GovResponsePayload::Voted(
|
||||||
|
proto_gov::VoteResponse {
|
||||||
|
outcome: outcome.into(),
|
||||||
|
},
|
||||||
|
))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_query(
|
||||||
|
actor: &ActorRef<OperatorSession>,
|
||||||
|
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||||
|
let summaries = actor.ask(HandleQueryPending {}).await.unwrap_or_default();
|
||||||
|
|
||||||
|
let proposals = summaries
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| proto_gov::ProposalSummary {
|
||||||
|
id: s.id,
|
||||||
|
kind: s.kind,
|
||||||
|
initiator_id: s.initiator_id,
|
||||||
|
expires_at: s.expires_at.0.timestamp(),
|
||||||
|
approve_count: s.approve_count,
|
||||||
|
reject_count: s.reject_count,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Some(wrap(GovResponsePayload::Pending(
|
||||||
|
QueryPendingResponse { proposals },
|
||||||
|
))))
|
||||||
|
}
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
actors::vault::VaultState,
|
actors::vault::VaultState,
|
||||||
peers::operator::{OperatorSession, session::handlers::HandleQueryVaultState},
|
peers::operator::{
|
||||||
|
OperatorSession,
|
||||||
|
session::handlers::{
|
||||||
|
HandleContributeRecoveryRekeyPassphrase, HandleContributeRekeyPassphrase,
|
||||||
|
HandleQueryVaultState,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use arbiter_proto::{
|
use arbiter_proto::{
|
||||||
proto::operator::{
|
proto::operator::{
|
||||||
operator_response::Payload as OperatorResponsePayload,
|
operator_response::Payload as OperatorResponsePayload,
|
||||||
vault::{
|
vault::{
|
||||||
self as proto_vault, request::Payload as VaultRequestPayload,
|
self as proto_vault,
|
||||||
|
rekey::{self as proto_rekey, RekeyResult as ProtoRekeyResult},
|
||||||
|
request::Payload as VaultRequestPayload,
|
||||||
response::Payload as VaultResponsePayload,
|
response::Payload as VaultResponsePayload,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -33,6 +41,7 @@ pub(super) async fn dispatch(
|
|||||||
|
|
||||||
match payload {
|
match payload {
|
||||||
VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await,
|
VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await,
|
||||||
|
VaultRequestPayload::Rekey(req) => handle_rekey(actor, req).await,
|
||||||
VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => {
|
VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => {
|
||||||
Err(Status::permission_denied(
|
Err(Status::permission_denied(
|
||||||
"Vault is already unsealed; unseal/bootstrap not permitted in session",
|
"Vault is already unsealed; unseal/bootstrap not permitted in session",
|
||||||
@@ -41,6 +50,51 @@ pub(super) async fn dispatch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_rekey(
|
||||||
|
actor: &ActorRef<OperatorSession>,
|
||||||
|
req: proto_rekey::Request,
|
||||||
|
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||||
|
use arbiter_proto::proto::operator::vault::rekey::request::Payload as RekeyPayload;
|
||||||
|
|
||||||
|
let payload = req
|
||||||
|
.payload
|
||||||
|
.ok_or_else(|| Status::invalid_argument("Missing rekey payload"))?;
|
||||||
|
|
||||||
|
let done: bool = match payload {
|
||||||
|
RekeyPayload::ContributePassphrase(cp) => actor
|
||||||
|
.ask(HandleContributeRekeyPassphrase {
|
||||||
|
passphrase: cp.passphrase,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!(?e, "rekey passphrase contribution failed");
|
||||||
|
Status::internal("Rekey contribution failed")
|
||||||
|
})?,
|
||||||
|
RekeyPayload::ContributeRecoveryPassphrase(crp) => actor
|
||||||
|
.ask(HandleContributeRecoveryRekeyPassphrase {
|
||||||
|
recovery_operator_id: crp.recovery_operator_id,
|
||||||
|
passphrase: crp.passphrase,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!(?e, "rekey recovery passphrase contribution failed");
|
||||||
|
Status::internal("Rekey recovery contribution failed")
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let proto_result = if done {
|
||||||
|
ProtoRekeyResult::Success
|
||||||
|
} else {
|
||||||
|
ProtoRekeyResult::AwaitingContributions
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(wrap_vault_response(VaultResponsePayload::Rekey(
|
||||||
|
proto_rekey::Response {
|
||||||
|
result: proto_result.into(),
|
||||||
|
},
|
||||||
|
))))
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_query_vault_state(
|
async fn handle_query_vault_state(
|
||||||
actor: &ActorRef<OperatorSession>,
|
actor: &ActorRef<OperatorSession>,
|
||||||
) -> Result<Option<OperatorResponsePayload>, Status> {
|
) -> Result<Option<OperatorResponsePayload>, Status> {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::{
|
|||||||
grpc::{Convert, TryConvert},
|
grpc::{Convert, TryConvert},
|
||||||
peers::operator::vault_gate::{
|
peers::operator::vault_gate::{
|
||||||
self as vault_gate, HandleBootstrapEncryptedKey, HandleContributeBootstrapPassphrase,
|
self as vault_gate, HandleBootstrapEncryptedKey, HandleContributeBootstrapPassphrase,
|
||||||
|
HandleContributeRecoveryBootstrapPassphrase, HandleContributeRecoveryUnsealPassphrase,
|
||||||
HandleContributeUnsealPassphrase, HandleDeclareCommittee, HandleHandshake,
|
HandleContributeUnsealPassphrase, HandleDeclareCommittee, HandleHandshake,
|
||||||
HandleUnsealEncryptedKey,
|
HandleUnsealEncryptedKey,
|
||||||
},
|
},
|
||||||
@@ -52,6 +53,9 @@ impl TryConvert for VaultRequestPayload {
|
|||||||
Self::QueryState(()) => Ok(vault_gate::Inbound::HandleVaultState),
|
Self::QueryState(()) => Ok(vault_gate::Inbound::HandleVaultState),
|
||||||
Self::Unseal(req) => req.try_convert(),
|
Self::Unseal(req) => req.try_convert(),
|
||||||
Self::Bootstrap(req) => req.try_convert(),
|
Self::Bootstrap(req) => req.try_convert(),
|
||||||
|
Self::Rekey(_) => Err(Status::permission_denied(
|
||||||
|
"Rekey requires an authenticated session",
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +86,14 @@ impl TryConvert for UnsealRequestPayload {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Self::ContributeRecoveryPassphrase(crp) => Ok(
|
||||||
|
vault_gate::Inbound::HandleContributeRecoveryUnsealPassphrase(
|
||||||
|
HandleContributeRecoveryUnsealPassphrase {
|
||||||
|
recovery_operator_id: crp.recovery_operator_id,
|
||||||
|
passphrase: crp.passphrase,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,6 +144,7 @@ impl TryConvert for BootstrapRequestPayload {
|
|||||||
Self::DeclareCommittee(dc) => Ok(
|
Self::DeclareCommittee(dc) => Ok(
|
||||||
vault_gate::Inbound::HandleDeclareCommittee(HandleDeclareCommittee {
|
vault_gate::Inbound::HandleDeclareCommittee(HandleDeclareCommittee {
|
||||||
count: dc.count as usize,
|
count: dc.count as usize,
|
||||||
|
recovery_count: dc.recovery_count as usize,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Self::ContributePassphrase(cp) => Ok(
|
Self::ContributePassphrase(cp) => Ok(
|
||||||
@@ -141,6 +154,14 @@ impl TryConvert for BootstrapRequestPayload {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Self::ContributeRecoveryPassphrase(crp) => Ok(
|
||||||
|
vault_gate::Inbound::HandleContributeRecoveryBootstrapPassphrase(
|
||||||
|
HandleContributeRecoveryBootstrapPassphrase {
|
||||||
|
recovery_operator_id: crp.recovery_operator_id,
|
||||||
|
passphrase: crp.passphrase,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,19 @@ impl TryConvert for vault_gate::Outbound {
|
|||||||
};
|
};
|
||||||
Ok(wrap_bootstrap_response(proto_result))
|
Ok(wrap_bootstrap_response(proto_result))
|
||||||
}
|
}
|
||||||
|
Self::HandleContributeRecoveryBootstrapPassphrase(result) => {
|
||||||
|
let proto_result = match result {
|
||||||
|
Ok(true) => ProtoBootstrapResult::Success,
|
||||||
|
Ok(false) => ProtoBootstrapResult::AwaitingContributions,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(?err, "contribute recovery bootstrap passphrase failed");
|
||||||
|
return Err(Status::internal(
|
||||||
|
"Failed to contribute recovery bootstrap passphrase",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(wrap_bootstrap_response(proto_result))
|
||||||
|
}
|
||||||
Self::HandleContributeUnsealPassphrase(result) => {
|
Self::HandleContributeUnsealPassphrase(result) => {
|
||||||
let proto_result = match result {
|
let proto_result = match result {
|
||||||
Ok(true) => ProtoUnsealResult::Success,
|
Ok(true) => ProtoUnsealResult::Success,
|
||||||
@@ -144,6 +157,21 @@ impl TryConvert for vault_gate::Outbound {
|
|||||||
proto_result.into(),
|
proto_result.into(),
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
Self::HandleContributeRecoveryUnsealPassphrase(result) => {
|
||||||
|
let proto_result = match result {
|
||||||
|
Ok(true) => ProtoUnsealResult::Success,
|
||||||
|
Ok(false) => ProtoUnsealResult::AwaitingContributions,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(?err, "contribute recovery unseal passphrase failed");
|
||||||
|
return Err(Status::internal(
|
||||||
|
"Failed to contribute recovery unseal passphrase",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(wrap_unseal_response(UnsealResponsePayload::Result(
|
||||||
|
proto_result.into(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ where
|
|||||||
|
|
||||||
Ok(OperatorSession::spawn(OperatorSession::new(
|
Ok(OperatorSession::spawn(OperatorSession::new(
|
||||||
props.clone(),
|
props.clone(),
|
||||||
|
creds.clone(),
|
||||||
oob_sender,
|
oob_sender,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,3 +279,102 @@ impl OperatorSession {
|
|||||||
Ok(clients)
|
Ok(clients)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[messages]
|
||||||
|
impl OperatorSession {
|
||||||
|
#[message]
|
||||||
|
pub(crate) async fn handle_create_proposal(
|
||||||
|
&mut self,
|
||||||
|
kind: crate::actors::proposal_manager::ProposalKind,
|
||||||
|
ttl_secs: Option<i64>,
|
||||||
|
) -> Result<i32, Error> {
|
||||||
|
use crate::actors::proposal_manager::CreateProposal;
|
||||||
|
let initiator_id = self.credentials.id;
|
||||||
|
self.props
|
||||||
|
.actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CreateProposal { kind, initiator_id, ttl_secs })
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!(?e, "create_proposal failed");
|
||||||
|
Error::internal("Failed to create proposal")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[message]
|
||||||
|
pub(crate) async fn handle_cast_vote(
|
||||||
|
&mut self,
|
||||||
|
proposal_id: i32,
|
||||||
|
approve: bool,
|
||||||
|
signature: Vec<u8>,
|
||||||
|
) -> Result<crate::actors::proposal_manager::VoteOutcome, crate::actors::proposal_manager::Error> {
|
||||||
|
use crate::actors::proposal_manager::CastVote;
|
||||||
|
let operator_id = self.credentials.id;
|
||||||
|
self.props
|
||||||
|
.actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(CastVote { proposal_id, operator_id, approve, signature })
|
||||||
|
.await
|
||||||
|
.map_err(|err| match err {
|
||||||
|
SendError::HandlerError(e) => e,
|
||||||
|
_ => crate::actors::proposal_manager::Error::ExecutionFailed("actor unavailable".to_owned()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[message]
|
||||||
|
pub(crate) async fn handle_query_pending(
|
||||||
|
&mut self,
|
||||||
|
) -> Vec<crate::actors::proposal_manager::ProposalSummary> {
|
||||||
|
use crate::actors::proposal_manager::QueryPending;
|
||||||
|
let operator_id = self.credentials.id;
|
||||||
|
self.props
|
||||||
|
.actors
|
||||||
|
.proposal_manager
|
||||||
|
.ask(QueryPending { operator_id })
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[messages]
|
||||||
|
impl OperatorSession {
|
||||||
|
#[message]
|
||||||
|
pub(crate) async fn handle_contribute_rekey_passphrase(
|
||||||
|
&mut self,
|
||||||
|
passphrase: Vec<u8>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
use crate::actors::vault_coordinator::ContributeRekey;
|
||||||
|
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||||
|
|
||||||
|
let operator_id = self.credentials.id;
|
||||||
|
self.props
|
||||||
|
.actors
|
||||||
|
.vault_coordinator
|
||||||
|
.ask(ContributeRekey {
|
||||||
|
operator_id,
|
||||||
|
passphrase: SafeCell::new(passphrase),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[message]
|
||||||
|
pub(crate) async fn handle_contribute_recovery_rekey_passphrase(
|
||||||
|
&mut self,
|
||||||
|
recovery_operator_id: i32,
|
||||||
|
passphrase: Vec<u8>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
use crate::actors::vault_coordinator::ContributeRecoveryRekey;
|
||||||
|
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
||||||
|
|
||||||
|
self.props
|
||||||
|
.actors
|
||||||
|
.vault_coordinator
|
||||||
|
.ask(ContributeRecoveryRekey {
|
||||||
|
recovery_operator_id,
|
||||||
|
passphrase: SafeCell::new(passphrase),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::{OutOfBand, OperatorConnection};
|
use super::{Credentials, OutOfBand, OperatorConnection};
|
||||||
use crate::{
|
use crate::{
|
||||||
actors::{
|
actors::{
|
||||||
flow_coordinator::client_connect_approval::ClientApprovalController,
|
flow_coordinator::client_connect_approval::ClientApprovalController,
|
||||||
@@ -51,6 +51,7 @@ pub struct PendingClientApproval {
|
|||||||
|
|
||||||
pub struct OperatorSession {
|
pub struct OperatorSession {
|
||||||
props: OperatorConnection,
|
props: OperatorConnection,
|
||||||
|
credentials: Credentials,
|
||||||
sender: Box<dyn Sender<OutOfBand>>,
|
sender: Box<dyn Sender<OutOfBand>>,
|
||||||
|
|
||||||
pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>,
|
pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>,
|
||||||
@@ -59,9 +60,10 @@ pub struct OperatorSession {
|
|||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
|
|
||||||
impl OperatorSession {
|
impl OperatorSession {
|
||||||
pub(crate) fn new(props: OperatorConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self {
|
pub(crate) fn new(props: OperatorConnection, credentials: Credentials, sender: Box<dyn Sender<OutOfBand>>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
props,
|
props,
|
||||||
|
credentials,
|
||||||
sender,
|
sender,
|
||||||
pending_client_approvals: HashMap::default(),
|
pending_client_approvals: HashMap::default(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ use crate::{
|
|||||||
actors::{
|
actors::{
|
||||||
GlobalActors,
|
GlobalActors,
|
||||||
vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events},
|
vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events},
|
||||||
vault_coordinator::{ContributeBootstrap, ContributeUnseal, StartBootstrap},
|
vault_coordinator::{
|
||||||
|
ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal,
|
||||||
|
ContributeUnseal, StartBootstrap,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
crypto::{KeyCell, integrity::{self}},
|
crypto::{KeyCell, integrity::{self}},
|
||||||
db::DatabasePool,
|
db::DatabasePool,
|
||||||
@@ -234,12 +237,17 @@ impl VaultGate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn handle_declare_committee(&mut self, count: usize) -> Result<(), Error> {
|
pub async fn handle_declare_committee(
|
||||||
|
&mut self,
|
||||||
|
count: usize,
|
||||||
|
recovery_count: usize,
|
||||||
|
) -> Result<(), Error> {
|
||||||
self.actors
|
self.actors
|
||||||
.vault_coordinator
|
.vault_coordinator
|
||||||
.ask(StartBootstrap {
|
.ask(StartBootstrap {
|
||||||
operator_id: self.auth_creds.id,
|
operator_id: self.auth_creds.id,
|
||||||
declared_count: count,
|
declared_count: count,
|
||||||
|
recovery_count,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
||||||
@@ -261,6 +269,23 @@ impl VaultGate {
|
|||||||
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[message]
|
||||||
|
pub async fn handle_contribute_recovery_bootstrap_passphrase(
|
||||||
|
&mut self,
|
||||||
|
recovery_operator_id: i32,
|
||||||
|
passphrase: Vec<u8>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
let passphrase_cell = SafeCell::new(passphrase);
|
||||||
|
self.actors
|
||||||
|
.vault_coordinator
|
||||||
|
.ask(ContributeRecoveryBootstrap {
|
||||||
|
recovery_operator_id,
|
||||||
|
passphrase: passphrase_cell,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
||||||
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn handle_contribute_unseal_passphrase(
|
pub async fn handle_contribute_unseal_passphrase(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -276,6 +301,23 @@ impl VaultGate {
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[message]
|
||||||
|
pub async fn handle_contribute_recovery_unseal_passphrase(
|
||||||
|
&mut self,
|
||||||
|
recovery_operator_id: i32,
|
||||||
|
passphrase: Vec<u8>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
let passphrase_cell = SafeCell::new(passphrase);
|
||||||
|
self.actors
|
||||||
|
.vault_coordinator
|
||||||
|
.ask(ContributeRecoveryUnseal {
|
||||||
|
recovery_operator_id,
|
||||||
|
passphrase: passphrase_cell,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Message<events::Bootstrapped> for VaultGate {
|
impl Message<events::Bootstrapped> for VaultGate {
|
||||||
|
|||||||
1120
server/crates/arbiter-server/tests/governance.rs
Normal file
1120
server/crates/arbiter-server/tests/governance.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,19 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
|
|||||||
use arbiter_server::{
|
use arbiter_server::{
|
||||||
actors::{
|
actors::{
|
||||||
GlobalActors,
|
GlobalActors,
|
||||||
vault::{Error, Vault},
|
vault::{Error, GetState, Vault, VaultState},
|
||||||
|
vault_coordinator::{
|
||||||
|
ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal,
|
||||||
|
Error as CoordinatorError, StartBootstrap, VaultCoordinator,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}},
|
crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}},
|
||||||
db::{self, models, schema},
|
db::{self, models, schema},
|
||||||
};
|
};
|
||||||
|
|
||||||
use diesel::{QueryDsl, SelectableHelper};
|
use diesel::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into};
|
||||||
use diesel_async::RunQueryDsl;
|
use diesel_async::RunQueryDsl;
|
||||||
|
use kameo::actor::Spawn as _;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[test_log::test]
|
#[test_log::test]
|
||||||
@@ -139,3 +144,127 @@ async fn test_unseal_wrong_then_correct_password() {
|
|||||||
let mut decrypted = actor.decrypt(aead_id).await.unwrap();
|
let mut decrypted = actor.decrypt(aead_id).await.unwrap();
|
||||||
assert_eq!(*decrypted.read(), plaintext);
|
assert_eq!(*decrypted.read(), plaintext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[test_log::test]
|
||||||
|
async fn two_operator_vault_requires_recovery_share() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let bus = GlobalActors::spawn_message_bus();
|
||||||
|
let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap());
|
||||||
|
let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db, vault_ref));
|
||||||
|
|
||||||
|
let err = coordinator
|
||||||
|
.ask(StartBootstrap {
|
||||||
|
operator_id: 1,
|
||||||
|
declared_count: 2,
|
||||||
|
recovery_count: 0,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
kameo::error::SendError::HandlerError(CoordinatorError::TwoOperatorsRequireRecovery)
|
||||||
|
),
|
||||||
|
"expected TwoOperatorsRequireRecovery, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §3.4: Bootstrap with 1 ordinary + 1 recovery operator produces a valid 1-of-2 Shamir split.
|
||||||
|
/// Both ordinary and recovery shares are stored; the vault can be unsealed with either one.
|
||||||
|
#[tokio::test]
|
||||||
|
#[test_log::test]
|
||||||
|
async fn recovery_share_stored_and_used_for_unseal() {
|
||||||
|
let db = db::create_test_pool().await;
|
||||||
|
let bus = GlobalActors::spawn_message_bus();
|
||||||
|
let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap());
|
||||||
|
let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref.clone()));
|
||||||
|
|
||||||
|
// Register one ordinary operator and one recovery operator in the DB
|
||||||
|
let ordinary_id: i32 = {
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
insert_into(schema::operator_identity::table)
|
||||||
|
.values(schema::operator_identity::public_key.eq(vec![1u8; 32]))
|
||||||
|
.returning(schema::operator_identity::id)
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let recovery_id: i32 = {
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
insert_into(schema::recovery_operator_identity::table)
|
||||||
|
.values(schema::recovery_operator_identity::public_key.eq(vec![2u8; 32]))
|
||||||
|
.returning(schema::recovery_operator_identity::id)
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Declare committee: 1 ordinary + 1 recovery
|
||||||
|
coordinator
|
||||||
|
.ask(StartBootstrap {
|
||||||
|
operator_id: ordinary_id,
|
||||||
|
declared_count: 1,
|
||||||
|
recovery_count: 1,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Recovery operator contributes first — bootstrap should not finalize yet
|
||||||
|
let done = coordinator
|
||||||
|
.ask(ContributeRecoveryBootstrap {
|
||||||
|
recovery_operator_id: recovery_id,
|
||||||
|
passphrase: SafeCell::new(b"recovery-pass".to_vec()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!done, "should not finalize with only recovery passphrase");
|
||||||
|
|
||||||
|
// Ordinary operator contributes — now bootstrap finalizes
|
||||||
|
let done = coordinator
|
||||||
|
.ask(ContributeBootstrap {
|
||||||
|
operator_id: ordinary_id,
|
||||||
|
passphrase: SafeCell::new(b"ordinary-pass".to_vec()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(done, "should finalize once all contributors are in");
|
||||||
|
|
||||||
|
// After bootstrap, vault is Unsealed (seal key still in memory).
|
||||||
|
let state = vault_ref.ask(GetState {}).await.unwrap();
|
||||||
|
assert_eq!(state, VaultState::Unsealed);
|
||||||
|
|
||||||
|
// Verify recovery_operator row was created
|
||||||
|
let recovery_share_count: i64 = {
|
||||||
|
let mut conn = db.get().await.unwrap();
|
||||||
|
schema::recovery_operator::table
|
||||||
|
.count()
|
||||||
|
.get_result(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert_eq!(recovery_share_count, 1);
|
||||||
|
|
||||||
|
// Simulate restart: drop vault and coordinator, create fresh vault (comes up Sealed).
|
||||||
|
drop(coordinator);
|
||||||
|
drop(vault_ref);
|
||||||
|
let bus2 = GlobalActors::spawn_message_bus();
|
||||||
|
let vault_ref2 = Vault::spawn(Vault::new(db.clone(), bus2).await.unwrap());
|
||||||
|
let state = vault_ref2.ask(GetState {}).await.unwrap();
|
||||||
|
assert_eq!(state, VaultState::Sealed);
|
||||||
|
|
||||||
|
// §3.5: Unseal using ONLY the recovery operator share (threshold = shamir_threshold(1) = 1).
|
||||||
|
let coordinator2 = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref2.clone()));
|
||||||
|
let done = coordinator2
|
||||||
|
.ask(ContributeRecoveryUnseal {
|
||||||
|
recovery_operator_id: recovery_id,
|
||||||
|
passphrase: SafeCell::new(b"recovery-pass".to_vec()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(done, "recovery share alone should satisfy threshold");
|
||||||
|
|
||||||
|
let state = vault_ref2.ask(GetState {}).await.unwrap();
|
||||||
|
assert_eq!(state, VaultState::Unsealed);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user