Compare commits

..

25 Commits

Author SHA1 Message Date
Clippy Bot
23827c613e test(client-auth): update metadata test to reflect frozen-metadata behavior
Some checks failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-06-28 12:06:06 +02:00
Clippy Bot
8381b68a52 Merge branch 'main' into zeroized-bootstrap-token
Some checks failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline failed
2026-06-27 13:25:27 +02:00
Clippy Bot
022003ac5e fix: lints
Some checks failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline failed
2026-06-25 20:09:18 +02:00
CleverWild
00ddf99d77 fix!: protect evm_wallet integrity and bind key ciphertext to wallet address
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
Three independent failures allowed an offline attacker with DB write access
to sign transactions using a different wallet's private key:
1. evm_wallet had no HMAC envelope — aead_encrypted_id could be swapped silently.
2. AEAD used a static tag as AAD — any valid ciphertext decrypted as any wallet key.
3. No post-decryption check that the derived address matched the requested wallet.

Fix: sign_entity covers (address, aead_encrypted_id) in a single transaction;
CreateNew/Decrypt take caller-provided AAD (wallet address bytes); after decryption
signer.address() is verified against the requested wallet address.
2026-06-22 17:53:34 +02:00
CleverWild
b7ab5c79b7 fix(client-auth): freeze client metadata after initial operator approval
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-06-22 16:07:58 +02:00
CleverWild
b8e092b9d7 fix(operator): bind sign-transaction to operator-approved client set
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-22 15:58:40 +02:00
CleverWild
9c9dc1fbb5 fix(integrity): return AttestationStatus::Unavailable for key version mismatch
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-22 15:50:43 +02:00
CleverWild
77bf19f99e fix(wallet): delete access entries by entry ID instead of wallet ID
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-22 14:55:32 +02:00
CleverWild
f0456157d5 security(bootstrap): use SafeCell for token storage instead of zeroize
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-19 22:53:28 +02:00
CleverWild
c12c12d73e fix(UA): signing endpoint accepts arbitrary client_id
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-06-19 22:21:48 +02:00
CleverWild
670448292a security: feat unseal and bootstrap handshake brute-force protection
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-06-18 21:16:38 +02:00
CleverWild
85f27d8f09 fix(bootstrap): token persists on disk with weak file permissions #59
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-06-18 20:59:24 +02:00
CleverWild
32ceb27d77 security(server): use SysRng directly for bootstrap token generation
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline failed
Replace make_rng()/StdRng with UnwrapErr(SysRng) to eliminate the PRNG
intermediate layer and make OS entropy derivation explicit and unambiguous.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 19:30:31 +02:00
CleverWild
dc03923c24 security(memory): zeroization of bootstrap token
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline failed
2026-06-18 17:16:06 +02:00
CleverWild
b843105533 fix(user-agent): zombie sessions #74
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
2026-06-18 16:29:43 +02:00
a8e4a710f1 Merge pull request 'security(server): bind grant revocation state (revoked_at) to integrity hash' (#83) from security-hash-revoke_at into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
ci/woodpecker/push/useragent-analyze Pipeline failed
Reviewed-on: #83
2026-06-11 09:44:28 +00:00
CleverWild
d99c87c473 fix: lints
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-09 21:07:01 +02:00
CleverWild
303120c9ac Merge branch 'main' into security-hash-revoke_at
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-09 20:58:20 +02:00
CleverWild
32f317384d security(evm): remove client-controlled wallet_access_id from grant revocation
Some checks failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-09 19:36:44 +02:00
CleverWild
4bb2c062dc feat(evm): add wallet_access_id to grant deletion requests and revocation logic
Some checks failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-09 19:16:21 +02:00
CleverWild
b0a3f37cea refactor(evm): implement revoke_grant method for grant revocation 2026-06-09 19:11:39 +02:00
CleverWild
58a72da46c Merge branch 'security-hash-revoke_at' of ssh://git.markettakers.org:22222/MarketTakers/arbiter into security-hash-revoke_at
Some checks failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-09 19:10:57 +02:00
CleverWild
e287459b10 revert(server): bind grant revocation state (revoked_at) to integrity hash 2026-06-09 18:45:30 +02:00
CleverWild
3c482da917 fix(smlang::statemachine): macro invocation requires inner types to be public
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-06-08 18:00:52 +02:00
CleverWild
5a34463228 security(server): bind grant revocation state (revoked_at) to integrity hash
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-08 12:09:54 +02:00
63 changed files with 1182 additions and 4852 deletions

View File

@@ -22,5 +22,3 @@ run = '''
dart pub global activate protoc_plugin && \ dart pub global activate protoc_plugin && \
protoc --dart_out=grpc:useragent/lib/proto --proto_path=protobufs/ $(find protobufs -name '*.proto' | sort) protoc --dart_out=grpc:useragent/lib/proto --proto_path=protobufs/ $(find protobufs -name '*.proto' | sort)
''' '''
[tasks.generate_schema]

View File

@@ -4,28 +4,25 @@ 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";
message OperatorRequest { message OperatorRequest {
int32 id = 16; int32 id = 16;
oneof payload { oneof payload {
auth.Request auth = 1; auth.Request auth = 1;
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;
} }
} }
message OperatorResponse { message OperatorResponse {
optional int32 id = 16; optional int32 id = 16;
oneof payload { oneof payload {
auth.Response auth = 1; auth.Response auth = 1;
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;
} }
} }

View File

@@ -1,136 +0,0 @@
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;
}
}

View File

@@ -8,35 +8,15 @@ message BootstrapEncryptedKey {
bytes associated_data = 3; bytes associated_data = 3;
} }
message DeclareCommittee {
uint32 count = 1;
uint32 recovery_count = 2;
}
message ContributePassphrase {
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;
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2; BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
BOOTSTRAP_RESULT_INVALID_KEY = 3; BOOTSTRAP_RESULT_INVALID_KEY = 3;
BOOTSTRAP_RESULT_AWAITING_CONTRIBUTIONS = 4;
} }
message Request { message Request {
oneof payload { BootstrapEncryptedKey encrypted_key = 2;
BootstrapEncryptedKey encrypted_key = 2;
DeclareCommittee declare_committee = 3;
ContributePassphrase contribute_passphrase = 4;
ContributeRecoveryPassphrase contribute_recovery_passphrase = 5;
}
} }
message Response { message Response {

View File

@@ -1,30 +0,0 @@
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;
}

View File

@@ -15,29 +15,18 @@ message UnsealEncryptedKey {
bytes associated_data = 3; bytes associated_data = 3;
} }
message ContributePassphrase {
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;
UNSEAL_RESULT_INVALID_KEY = 2; UNSEAL_RESULT_INVALID_KEY = 2;
UNSEAL_RESULT_UNBOOTSTRAPPED = 3; UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
UNSEAL_RESULT_AWAITING_CONTRIBUTIONS = 4; UNSEAL_RESULT_LOCKED_OUT = 4;
} }
message Request { message Request {
oneof payload { oneof payload {
UnsealStart start = 1; UnsealStart start = 1;
UnsealEncryptedKey encrypted_key = 2; UnsealEncryptedKey encrypted_key = 2;
ContributePassphrase contribute_passphrase = 3;
ContributeRecoveryPassphrase contribute_recovery_passphrase = 4;
} }
} }

View File

@@ -5,23 +5,20 @@ 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 {
oneof payload { oneof payload {
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;
} }
} }
message Response { message Response {
oneof payload { oneof payload {
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;
} }
} }

View File

@@ -5,8 +5,7 @@ package arbiter.shared;
enum VaultState { enum VaultState {
VAULT_STATE_UNSPECIFIED = 0; VAULT_STATE_UNSPECIFIED = 0;
VAULT_STATE_UNBOOTSTRAPPED = 1; VAULT_STATE_UNBOOTSTRAPPED = 1;
VAULT_STATE_BOOSTRAPPING = 2; VAULT_STATE_SEALED = 2;
VAULT_STATE_SEALED = 3; VAULT_STATE_UNSEALED = 3;
VAULT_STATE_UNSEALED = 4; VAULT_STATE_ERROR = 4;
VAULT_STATE_ERROR = 5;
} }

228
server/Cargo.lock generated
View File

@@ -24,7 +24,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [ dependencies = [
"crypto-common 0.1.7", "crypto-common 0.1.7",
"generic-array 0.14.7", "generic-array",
] ]
[[package]] [[package]]
@@ -769,10 +769,9 @@ 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.10.1",
"rcgen", "rcgen",
"restructed", "restructed",
"rstest", "rstest",
@@ -788,7 +787,6 @@ dependencies = [
"tonic", "tonic",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"vsss-rs",
"x25519-dalek 2.0.1", "x25519-dalek 2.0.1",
] ]
@@ -1286,7 +1284,7 @@ version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [ dependencies = [
"generic-array 0.14.7", "generic-array",
] ]
[[package]] [[package]]
@@ -1615,22 +1613,8 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [ dependencies = [
"generic-array 0.14.7", "generic-array",
"rand_core 0.6.4", "rand_core 0.6.4",
"serdect 0.2.0",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-bigint"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96272c2ff28b807e09250b180ad1fb7889a3258f7455759b5c3c58b719467130"
dependencies = [
"num-traits",
"rand_core 0.6.4",
"serdect 0.3.0",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@@ -1641,7 +1625,7 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array 0.14.7", "generic-array",
"rand_core 0.6.4", "rand_core 0.6.4",
"typenum", "typenum",
] ]
@@ -1944,7 +1928,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
dependencies = [ dependencies = [
"generic-array 0.14.7", "generic-array",
] ]
[[package]] [[package]]
@@ -2024,7 +2008,7 @@ dependencies = [
"digest 0.10.7", "digest 0.10.7",
"elliptic-curve", "elliptic-curve",
"rfc6979", "rfc6979",
"serdect 0.2.0", "serdect",
"signature 2.2.0", "signature 2.2.0",
"spki 0.7.3", "spki 0.7.3",
] ]
@@ -2057,32 +2041,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [ dependencies = [
"base16ct", "base16ct",
"crypto-bigint 0.5.5", "crypto-bigint",
"digest 0.10.7", "digest 0.10.7",
"ff", "ff",
"generic-array 0.14.7", "generic-array",
"group", "group",
"hkdf",
"pkcs8 0.10.2", "pkcs8 0.10.2",
"rand_core 0.6.4", "rand_core 0.6.4",
"sec1", "sec1",
"serdect 0.2.0", "serdect",
"subtle", "subtle",
"tap",
"zeroize",
]
[[package]]
name = "elliptic-curve-tools"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1de2b6fae800f08032a6ea32995b52925b1d451bff9d445c8ab2932323277faf"
dependencies = [
"elliptic-curve",
"heapless",
"hex",
"multiexp",
"serde",
"zeroize", "zeroize",
] ]
@@ -2156,7 +2124,6 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [ dependencies = [
"bitvec",
"rand_core 0.6.4", "rand_core 0.6.4",
"subtle", "subtle",
] ]
@@ -2357,17 +2324,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "generic-array"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab9e9188e97a93276e1fe7b56401b851e2b45a46d045ca658100c1303ada649"
dependencies = [
"rustversion",
"serde_core",
"typenum",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -2451,15 +2407,6 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "hash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -2500,16 +2447,6 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "heapless"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
dependencies = [
"hash32",
"stable_deref_trait",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -2537,15 +2474,6 @@ dependencies = [
"arrayvec", "arrayvec",
] ]
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac 0.12.1",
]
[[package]] [[package]]
name = "hmac" name = "hmac"
version = "0.12.1" version = "0.12.1"
@@ -2616,7 +2544,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
dependencies = [ dependencies = [
"ctutils", "ctutils",
"serde",
"typenum", "typenum",
"zeroize", "zeroize",
] ]
@@ -2882,7 +2809,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [ dependencies = [
"generic-array 0.14.7", "generic-array",
] ]
[[package]] [[package]]
@@ -3021,7 +2948,7 @@ dependencies = [
"ecdsa", "ecdsa",
"elliptic-curve", "elliptic-curve",
"once_cell", "once_cell",
"serdect 0.2.0", "serdect",
"sha2 0.10.9", "sha2 0.10.9",
"signature 2.2.0", "signature 2.2.0",
] ]
@@ -3029,7 +2956,7 @@ dependencies = [
[[package]] [[package]]
name = "kameo" name = "kameo"
version = "0.20.0" version = "0.20.0"
source = "git+https://github.com/hdbg/kameo.git?rev=3e18ba2#3e18ba24023d0422034e60ff2ea1ecd49e8c3c93" source = "git+https://github.com/hdbg/kameo.git?rev=805b417#805b41783fe90b54827ecad142b422c7a9b69b9a"
dependencies = [ dependencies = [
"downcast-rs", "downcast-rs",
"dyn-clone", "dyn-clone",
@@ -3043,7 +2970,7 @@ dependencies = [
[[package]] [[package]]
name = "kameo_actors" name = "kameo_actors"
version = "0.5.0" version = "0.5.0"
source = "git+https://github.com/hdbg/kameo.git?rev=3e18ba2#3e18ba24023d0422034e60ff2ea1ecd49e8c3c93" source = "git+https://github.com/hdbg/kameo.git?rev=805b417#805b41783fe90b54827ecad142b422c7a9b69b9a"
dependencies = [ dependencies = [
"futures", "futures",
"glob", "glob",
@@ -3055,8 +2982,9 @@ dependencies = [
[[package]] [[package]]
name = "kameo_macros" name = "kameo_macros"
version = "0.20.0" version = "0.20.0"
source = "git+https://github.com/hdbg/kameo.git?rev=3e18ba2#3e18ba24023d0422034e60ff2ea1ecd49e8c3c93" source = "git+https://github.com/hdbg/kameo.git?rev=805b417#805b41783fe90b54827ecad142b422c7a9b69b9a"
dependencies = [ dependencies = [
"darling 0.23.0",
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -3363,20 +3291,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "multiexp"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ec2ce93a6f06ac6cae04c1da3f2a6a24fcfc1f0eb0b4e0f3d302f0df45326cb"
dependencies = [
"ff",
"group",
"rand_core 0.6.4",
"rustversion",
"std-shims",
"zeroize",
]
[[package]] [[package]]
name = "multimap" name = "multimap"
version = "0.10.1" version = "0.10.1"
@@ -3408,20 +3322,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.6" version = "0.4.6"
@@ -3430,19 +3330,6 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [ dependencies = [
"num-integer", "num-integer",
"num-traits", "num-traits",
"rand 0.8.6",
"serde",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
"rand 0.8.6",
"serde",
] ]
[[package]] [[package]]
@@ -3460,29 +3347,6 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
"serde",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -4591,9 +4455,9 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [ dependencies = [
"base16ct", "base16ct",
"der 0.7.10", "der 0.7.10",
"generic-array 0.14.7", "generic-array",
"pkcs8 0.10.2", "pkcs8 0.10.2",
"serdect 0.2.0", "serdect",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@@ -4759,16 +4623,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serdect"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53"
dependencies = [
"base16ct",
"serde",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -4934,12 +4788,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "spin"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
[[package]] [[package]]
name = "spki" name = "spki"
version = "0.7.3" version = "0.7.3"
@@ -4984,17 +4832,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "std-shims"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "227c4f8561598188d0df96dbe749824576174bba278b5b6bb2eacff1066067d0"
dependencies = [
"hashbrown 0.16.1",
"rustversion",
"spin",
]
[[package]] [[package]]
name = "string_morph" name = "string_morph"
version = "0.1.0" version = "0.1.0"
@@ -5738,27 +5575,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsss-rs"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ec751bdcc8bda099e269b24cc6b4ad14f9ce8b0490c1599174070e792ecd70c"
dependencies = [
"crypto-bigint 0.5.5",
"crypto-bigint 0.6.1",
"elliptic-curve",
"elliptic-curve-tools",
"generic-array 1.4.1",
"hex",
"hybrid-array",
"num",
"rand_core 0.6.4",
"serde",
"sha3 0.10.9",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "wait-timeout" name = "wait-timeout"
version = "0.2.1" version = "0.2.1"
@@ -6446,18 +6262,18 @@ dependencies = [
[[package]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.8.2" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [ dependencies = [
"zeroize_derive", "zeroize_derive",
] ]
[[package]] [[package]]
name = "zeroize_derive" name = "zeroize_derive"
version = "1.4.3" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View File

@@ -12,8 +12,8 @@ base64 = "0.22.1"
chrono = { version = "0.4.44", features = ["serde"] } chrono = { version = "0.4.44", features = ["serde"] }
futures = "0.3.32" futures = "0.3.32"
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] } k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "3e18ba2"} kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "3e18ba2"} kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
hmac = "0.13.0" hmac = "0.13.0"
miette = { version = "7.6.0", features = ["fancy", "serde"] } miette = { version = "7.6.0", features = ["fancy", "serde"] }
ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] } ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] }
@@ -21,6 +21,7 @@ mutants = "0.0.4"
prost = "0.14.3" prost = "0.14.3"
prost-types = { version = "0.14.3", features = ["chrono"] } prost-types = { version = "0.14.3", features = ["chrono"] }
rand = "0.10.1" rand = "0.10.1"
rand_core = "0.10.1"
rcgen = { version = "0.14.7", features = [ "aws_lc_rs", "pem", "x509-parser", "zeroize" ], default-features = false } rcgen = { version = "0.14.7", features = [ "aws_lc_rs", "pem", "x509-parser", "zeroize" ], default-features = false }
rstest = "0.26.1" rstest = "0.26.1"
rustls = { version = "0.23.40", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false } rustls = { version = "0.23.40", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
@@ -76,6 +77,7 @@ needless_pass_by_ref_mut = "allow"
pub_underscore_fields = "allow" pub_underscore_fields = "allow"
redundant_pub_crate = "allow" redundant_pub_crate = "allow"
uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {}) uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {})
too-many-lines = "allow" # this is a very common pattern in server code, and it's not always possible to break it down into smaller modules without hurting readability
# restriction lints # restriction lints
alloc_instead_of_core = "warn" alloc_instead_of_core = "warn"
@@ -106,6 +108,7 @@ indexing_slicing = "warn"
infinite_loop = "warn" infinite_loop = "warn"
inline_asm_x86_att_syntax = "warn" inline_asm_x86_att_syntax = "warn"
inline_asm_x86_intel_syntax = "warn" inline_asm_x86_intel_syntax = "warn"
integer_division = "warn"
large_include_file = "warn" large_include_file = "warn"
lossy_float_literal = "warn" lossy_float_literal = "warn"
map_with_unused_argument_over_ranges = "warn" map_with_unused_argument_over_ranges = "warn"

View File

@@ -26,5 +26,3 @@ trait-assoc-item-kinds-order = [
"type", "type",
"fn", "fn",
] # community tested standard ] # community tested standard
too-many-lines-threshold = 150

View File

@@ -100,7 +100,7 @@ async fn send_auth_challenge_solution(
key: &SigningKey, key: &SigningKey,
challenge: AuthChallenge, challenge: AuthChallenge,
) -> Result<(), AuthError> { ) -> Result<(), AuthError> {
let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos as i64); let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos.cast_signed());
let challenge = authn::AuthChallenge { let challenge = authn::AuthChallenge {
nonce: *challenge nonce: *challenge
.random .random

View File

@@ -8,7 +8,6 @@ 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;
@@ -91,11 +90,6 @@ 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 {

View File

@@ -22,7 +22,7 @@ pub trait SafeCellHandle<T> {
fn read(&mut self) -> Self::CellRead<'_>; fn read(&mut self) -> Self::CellRead<'_>;
fn write(&mut self) -> Self::CellWrite<'_>; fn write(&mut self) -> Self::CellWrite<'_>;
fn new_inline_default<F>(f: F) -> Self fn new_inline<F>(f: F) -> Self
where where
Self: Sized, Self: Sized,
T: Default, T: Default,
@@ -36,14 +36,6 @@ pub trait SafeCellHandle<T> {
cell cell
} }
fn new_inline<F>(f: Box<F>) -> Self
where
Self: Sized,
F: for<'a> FnOnce() -> T,
{
Self::new(f())
}
#[inline(always)] #[inline(always)]
fn read_inline<F, R>(&mut self, f: F) -> R fn read_inline<F, R>(&mut self, f: F) -> R
where where

View File

@@ -23,10 +23,6 @@ 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");
} }
@@ -38,10 +34,6 @@ 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");
} }

View File

@@ -31,6 +31,7 @@ diesel_migrations = { version = "2.3.2", features = ["sqlite"] }
async-trait.workspace = true async-trait.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
rand.workspace = true rand.workspace = true
rand_core.workspace = true
rcgen.workspace = true rcgen.workspace = true
chrono.workspace = true chrono.workspace = true
kameo.workspace = true kameo.workspace = true
@@ -42,7 +43,6 @@ 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"
@@ -51,8 +51,6 @@ subtle = "2.6.1"
x25519-dalek.workspace = true x25519-dalek.workspace = true
k256.workspace = true k256.workspace = true
kameo_actors.workspace = true kameo_actors.workspace = true
vsss-rs = "5.4.0"
rand_core = "0.6"
[dev-dependencies] [dev-dependencies]
proptest = "1.11.0" proptest = "1.11.0"

View File

@@ -43,25 +43,13 @@ create table if not exists arbiter_settings (
insert into arbiter_settings (id) values (1) on conflict do nothing; insert into arbiter_settings (id) values (1) on conflict do nothing;
-- ensure singleton row exists -- ensure singleton row exists
create table if not exists operator_identity ( create table if not exists operator_client (
id integer not null primary key, id integer not null primary key,
public_key blob not null, public_key blob not null,
created_at integer not null default(unixepoch ('now')), created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now')) updated_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create unique index if not exists uniq_operator_identity_public_key on operator_identity (public_key); create unique index if not exists uniq_operator_client_public_key on operator_client (public_key);
create table if not exists operator (
id integer primary key references operator_identity(id) on delete restrict, -- same id as operator_identity
share blob not null,
share_nonce blob not null,
share_salt blob not null default (randomblob(32)),
created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now'))
) STRICT;
create table if not exists client_metadata ( create table if not exists client_metadata (
id integer not null primary key, id integer not null primary key,
@@ -216,73 +204,3 @@ 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;

View File

@@ -1,29 +1,48 @@
use crate::db::{self, DatabasePool, schema}; use crate::db::{self, DatabasePool, schema};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_proto::{BOOTSTRAP_PATH, home_path}; use arbiter_proto::{BOOTSTRAP_PATH, home_path};
use diesel::QueryDsl; use diesel::QueryDsl;
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::{Actor, messages}; use kameo::{Actor, messages};
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng}; use rand::{RngExt, distr::Alphanumeric, rngs::SysRng};
use rand_core::UnwrapErr;
use std::path::{Path, PathBuf};
use subtle::ConstantTimeEq as _; use subtle::ConstantTimeEq as _;
use thiserror::Error; use thiserror::Error;
use tracing::warn;
const TOKEN_LENGTH: usize = 64; const TOKEN_LENGTH: usize = 64;
pub async fn generate_token() -> Result<String, std::io::Error> { async fn write_token_file(path: &Path, content: &str) -> Result<(), std::io::Error> {
let rng: StdRng = make_rng(); tokio::fs::write(path, content.as_bytes()).await?;
let token = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold( #[cfg(unix)]
String::default(), {
|mut accum, char| { use std::os::unix::fs::PermissionsExt as _;
accum += char.to_string().as_str(); tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?;
accum }
},
);
tokio::fs::write(home_path()?.join(BOOTSTRAP_PATH), token.as_str()).await?; Ok(())
}
Ok(token) async fn generate_token(path: &Path) -> Result<SafeCell<[u8; TOKEN_LENGTH]>, std::io::Error> {
let mut cell = SafeCell::new([0u8; TOKEN_LENGTH]);
{
let mut buf = cell.write();
for (slot, b) in buf
.iter_mut()
.zip(UnwrapErr(SysRng).sample_iter(Alphanumeric))
{
*slot = b;
}
}
let token_str = cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned());
write_token_file(path, &token_str).await?;
Ok(cell)
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]
@@ -40,7 +59,8 @@ pub enum Error {
#[derive(Actor)] #[derive(Actor)]
pub struct Bootstrapper { pub struct Bootstrapper {
token: Option<String>, token: Option<SafeCell<[u8; TOKEN_LENGTH]>>,
token_path: Option<PathBuf>,
} }
impl Bootstrapper { impl Bootstrapper {
@@ -48,40 +68,43 @@ impl Bootstrapper {
let row_count: i64 = { let row_count: i64 = {
let mut conn = db.get().await?; let mut conn = db.get().await?;
schema::operator::table schema::operator_client::table
.count() .count()
.get_result(&mut conn) .get_result(&mut conn)
.await? .await?
}; };
let token = if row_count == 0 { let (token, token_path) = if row_count == 0 {
let token = generate_token().await?; let path = home_path()?.join(BOOTSTRAP_PATH);
Some(token) let token = generate_token(&path).await?;
(Some(token), Some(path))
} else { } else {
None (None, None)
}; };
Ok(Self { token }) Ok(Self { token, token_path })
}
}
impl Bootstrapper {
fn is_correct_token(&mut self, token: &[u8]) -> bool {
self.token.as_mut().is_some_and(|expected| {
expected.read_inline(|exp| bool::from(exp.as_ref().ct_eq(token)))
})
} }
} }
#[messages] #[messages]
impl Bootstrapper { impl Bootstrapper {
#[message] #[message]
pub fn is_correct_token(&self, token: String) -> bool { pub async fn consume_token(&mut self, token: Vec<u8>) -> bool {
self.token.as_ref().is_some_and(|expected| { if self.is_correct_token(&token) {
let expected_bytes = expected.as_bytes();
let token_bytes = token.as_bytes();
let choice = expected_bytes.ct_eq(token_bytes);
bool::from(choice)
})
}
#[message]
pub fn consume_token(&mut self, token: String) -> bool {
if self.is_correct_token(token) {
self.token = None; self.token = None;
if let Some(path) = self.token_path.take()
&& let Err(e) = tokio::fs::remove_file(&path).await
{
warn!(error = ?e, path = ?path, "Failed to delete bootstrap token file after consumption");
}
true true
} else { } else {
false false
@@ -92,7 +115,9 @@ impl Bootstrapper {
#[messages] #[messages]
impl Bootstrapper { impl Bootstrapper {
#[message] #[message]
pub fn get_token(&self) -> Option<String> { pub fn get_token(&mut self) -> Option<String> {
self.token.clone() self.token
.as_mut()
.map(|cell| cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned()))
} }
} }

View File

@@ -1,9 +1,9 @@
use crate::{ use crate::{
actors::vault::{CreateNew, Decrypt, Vault}, actors::vault::{CreateNew, Decrypt, Vault},
crypto::integrity, crypto::integrity::{self, Integrable},
db::{ db::{
DatabaseError, DatabasePool, DatabaseError, DatabasePool,
models::{self, EvmWalletId}, models::{self},
schema, schema,
}, },
evm::{ evm::{
@@ -25,14 +25,35 @@ use diesel::{
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::{Actor, actor::ActorRef, messages}; use kameo::{Actor, actor::ActorRef, messages};
use rand::{SeedableRng, rng, rngs::StdRng}; use rand::{SeedableRng, rng, rngs::StdRng};
use tracing::error;
pub use crate::evm::safe_signer; pub use crate::evm::safe_signer;
/// Integrity guard that binds a wallet's encrypted key ID to its Ethereum address.
/// Both fields are included in the HMAC — swapping `aead_encrypted_id` in the DB
/// invalidates the envelope MAC, and the AEAD ciphertext is also bound to `address`
/// as AAD, so decryption fails too.
#[derive(arbiter_macros::Hashable)]
struct EvmWalletIntegrity {
aead_encrypted_id: i32,
address: Address,
}
impl Integrable for EvmWalletIntegrity {
const KIND: &'static str = "evm_wallet";
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum SignTransactionError { pub enum SignTransactionError {
#[error("Wallet not found")] #[error("Wallet not found")]
WalletNotFound, WalletNotFound,
#[error("Decrypted key does not match requested wallet address")]
KeyAddressMismatch,
#[error("Internal signing error")]
Internal,
#[error("Database error: {0}")] #[error("Database error: {0}")]
Database(#[from] DatabaseError), Database(#[from] DatabaseError),
@@ -64,6 +85,12 @@ pub enum Error {
Integrity(#[from] integrity::Error), Integrity(#[from] integrity::Error),
} }
impl From<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
Self::Database(DatabaseError::from(e))
}
}
#[derive(Actor)] #[derive(Actor)]
pub struct EvmActor { pub struct EvmActor {
pub vault: ActorRef<Vault>, pub vault: ActorRef<Vault>,
@@ -97,26 +124,45 @@ impl EvmActor {
let aead_id: i32 = self let aead_id: i32 = self
.vault .vault
.ask(CreateNew { plaintext }) .ask(CreateNew {
plaintext,
aad: address.as_slice().to_vec(),
})
.await .await
.map_err(|_| Error::VaultSend)?; .map_err(|_| Error::VaultSend)?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet_id = insert_into(schema::evm_wallet::table) let wallet_id = conn
.values(&models::NewEvmWallet { .exclusive_transaction(async |conn| {
address: address.as_slice().to_vec(), let wallet_id: i32 = insert_into(schema::evm_wallet::table)
aead_encrypted_id: aead_id, .values(&models::NewEvmWallet {
address: address.as_slice().to_vec(),
aead_encrypted_id: aead_id,
})
.returning(schema::evm_wallet::id)
.get_result(conn)
.await
.map_err(DatabaseError::from)
.map_err(Error::Database)?;
integrity::sign_entity(
conn,
&self.vault,
&EvmWalletIntegrity { address, aead_encrypted_id: aead_id },
wallet_id,
)
.await
.map_err(Error::Integrity)?;
Ok::<i32, Error>(wallet_id)
}) })
.returning(schema::evm_wallet::id) .await?;
.get_result(&mut conn)
.await
.map_err(DatabaseError::from)?;
Ok((wallet_id, address)) Ok((wallet_id, address))
} }
#[message] #[message]
pub async fn list_wallets(&self) -> Result<Vec<(EvmWalletId, Address)>, Error> { pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
.select(models::EvmWallet::as_select()) .select(models::EvmWallet::as_select())
@@ -160,23 +206,14 @@ impl EvmActor {
} }
#[message] #[message]
pub async fn operator_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> { pub async fn useragent_delete_grant(
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; &mut self,
grant_id: i32,
let affected = diesel::update(schema::evm_basic_grant::table) ) -> Result<(), Error> {
.filter(schema::evm_basic_grant::id.eq(grant_id)) self.engine
.set(schema::evm_basic_grant::revoked_at.eq(models::SqliteTimestamp::now())) .revoke_grant(grant_id)
.execute(&mut conn)
.await .await
.map_err(DatabaseError::from)?; .map_err(Error::from)
if affected == 0 {
return Err(Error::Database(DatabaseError::from(
diesel::result::Error::NotFound,
)));
}
Ok(())
} }
#[message] #[message]
@@ -250,16 +287,51 @@ impl EvmActor {
.ok_or(SignTransactionError::WalletNotFound)?; .ok_or(SignTransactionError::WalletNotFound)?;
drop(conn); drop(conn);
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let attestation = integrity::verify_entity(
&mut conn,
&self.vault,
&EvmWalletIntegrity {
address: wallet_address,
aead_encrypted_id: wallet.aead_encrypted_id,
},
wallet.id,
)
.await
.map_err(|e| {
error!(?e, wallet_id = wallet.id, "EVM wallet integrity check failed");
SignTransactionError::Internal
})?;
drop(conn);
if attestation != integrity::AttestationStatus::Attested {
error!(
wallet_id = wallet.id,
"EVM wallet integrity unavailable; refusing to sign"
);
return Err(SignTransactionError::Internal);
}
let raw_key: SafeCell<Vec<u8>> = self let raw_key: SafeCell<Vec<u8>> = self
.vault .vault
.ask(Decrypt { .ask(Decrypt {
aead_id: wallet.aead_encrypted_id, aead_id: wallet.aead_encrypted_id,
aad: wallet.address.clone(),
}) })
.await .await
.map_err(|_| SignTransactionError::VaultSend)?; .map_err(|_| SignTransactionError::VaultSend)?;
let signer = safe_signer::SafeSigner::from_cell(raw_key)?; let signer = safe_signer::SafeSigner::from_cell(raw_key)?;
if signer.address() != wallet_address {
error!(
expected = %wallet_address,
actual = %signer.address(),
"Decrypted private key address does not match requested wallet"
);
return Err(SignTransactionError::KeyAddressMismatch);
}
self.engine self.engine
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution) .evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.await?; .await?;

View File

@@ -11,7 +11,9 @@ use kameo::{
prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef}, prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef},
reply::ReplySender, reply::ReplySender,
}; };
use std::ops::ControlFlow; use std::{ops::ControlFlow, time::Duration};
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(30);
pub struct Args { pub struct Args {
pub client: ClientProfile, pub client: ClientProfile,
@@ -64,6 +66,14 @@ impl Actor for ClientApprovalController {
.await; .await;
} }
let weak = actor_ref.downgrade();
tokio::spawn(async move {
tokio::time::sleep(APPROVAL_TIMEOUT).await;
if let Some(r) = weak.upgrade() {
let _ = r.tell(OnApprovalTimeout {}).await;
}
});
Ok(this) Ok(this)
} }
@@ -104,4 +114,14 @@ impl ClientApprovalController {
ctx.stop(); ctx.stop();
} }
} }
/// Fired after `APPROVAL_TIMEOUT` elapses. Any operator that hasn't responded
/// by then is treated as a denial to prevent zombie sessions from blocking the flow.
#[message(ctx)]
pub fn on_approval_timeout(&mut self, ctx: &mut Context<Self, ()>) {
if self.pending > 0 {
self.send_reply(Ok(false));
ctx.stop();
}
}
} }

View File

@@ -20,6 +20,8 @@ pub mod client_connect_approval;
pub struct FlowCoordinator { pub struct FlowCoordinator {
pub clients: HashMap<ActorId, ActorRef<ClientSession>>, pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
/// Maps DB `client_id` → `ActorId` for fast connected-client lookup.
client_ids: HashMap<i32, ActorId>,
operator_registry: ActorRef<OperatorRegistry>, operator_registry: ActorRef<OperatorRegistry>,
} }
@@ -27,6 +29,7 @@ impl FlowCoordinator {
pub fn new(operator_registry: ActorRef<OperatorRegistry>) -> Self { pub fn new(operator_registry: ActorRef<OperatorRegistry>) -> Self {
Self { Self {
clients: HashMap::default(), clients: HashMap::default(),
client_ids: HashMap::default(),
operator_registry, operator_registry,
} }
} }
@@ -48,6 +51,7 @@ impl Actor for FlowCoordinator {
_: ActorStopReason, _: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> { ) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
if self.clients.remove(&id).is_some() { if self.clients.remove(&id).is_some() {
self.client_ids.retain(|_, actor_id| *actor_id != id);
info!( info!(
?id, ?id,
actor = "FlowCoordinator", actor = "FlowCoordinator",
@@ -75,14 +79,28 @@ impl FlowCoordinator {
#[message(ctx)] #[message(ctx)]
pub async fn register_client( pub async fn register_client(
&mut self, &mut self,
client_id: i32,
actor: ActorRef<ClientSession>, actor: ActorRef<ClientSession>,
ctx: &mut Context<Self, ()>, ctx: &mut Context<Self, ()>,
) { ) {
info!(id = %actor.id(), actor = "FlowCoordinator", event = "client.connected"); info!(id = %actor.id(), client_id, actor = "FlowCoordinator", event = "client.connected");
ctx.actor_ref().link(&actor).await; ctx.actor_ref().link(&actor).await;
self.client_ids.insert(client_id, actor.id());
self.clients.insert(actor.id(), actor); self.clients.insert(actor.id(), actor);
} }
#[message]
pub fn is_client_connected(&self, client_id: i32) -> bool {
self.client_ids.contains_key(&client_id)
}
/// Returns the DB `client_ids` of all currently connected SDK clients.
/// Used by operator sessions on startup to seed their approved-client set.
#[message]
pub fn get_connected_client_ids(&self) -> Vec<i32> {
self.client_ids.keys().copied().collect()
}
#[message(ctx)] #[message(ctx)]
pub async fn request_client_approval( pub async fn request_client_approval(
&mut self, &mut self,

View File

@@ -1,8 +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, proposal_manager::ProposalManager, vault::Vault, operator_registry::OperatorRegistry, vault::Vault,
vault_coordinator::VaultCoordinator,
}, },
db, db,
}; };
@@ -15,9 +14,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;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum SpawnError { pub enum SpawnError {
@@ -33,11 +30,9 @@ pub enum SpawnError {
pub struct GlobalActors { pub struct GlobalActors {
pub vault: ActorRef<Vault>, pub vault: ActorRef<Vault>,
pub bootstrapper: ActorRef<Bootstrapper>, pub bootstrapper: ActorRef<Bootstrapper>,
pub vault_coordinator: ActorRef<VaultCoordinator>,
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>,
} }
@@ -50,27 +45,15 @@ 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?),
proposal_manager: ProposalManager::spawn(ProposalManager::new( evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)),
db,
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,
}) })
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
use crate::{ use crate::{
crypto::{ crypto::{
KeyCell, KeyCell, derive_key,
encryption::v1::{self, Nonce}, encryption::v1::{self, Nonce},
integrity::v1::HmacSha256, integrity::v1::HmacSha256,
}, },
db::{ db::{
self, self,
models::{self, RootKeyHistory, RootKeyHistoryId}, models::{self, RootKeyHistory},
schema, schema::{self},
}, },
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
@@ -22,9 +22,10 @@ use hmac::{KeyInit as _, Mac as _};
use kameo::{Actor, Reply, actor::ActorRef, messages}; use kameo::{Actor, Reply, actor::ActorRef, messages};
use kameo_actors::message_bus::{MessageBus, Publish}; use kameo_actors::message_bus::{MessageBus, Publish};
use strum::{EnumDiscriminants, IntoDiscriminant}; use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info}; use tracing::{error, info, warn};
pub mod events { pub mod events {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct Bootstrapped; pub struct Bootstrapped;
@@ -45,6 +46,8 @@ pub enum Error {
Sealed, Sealed,
#[error("Invalid key provided")] #[error("Invalid key provided")]
InvalidKey, InvalidKey,
#[error("Vault locked: too many failed unseal attempts")]
LockedOut,
#[error("Requested aead entry not found")] #[error("Requested aead entry not found")]
NotFound, NotFound,
@@ -60,10 +63,13 @@ pub enum Error {
#[error("Broken database")] #[error("Broken database")]
BrokenDatabase, BrokenDatabase,
#[error("Integrity key version mismatch: envelope uses key {envelope}, current key is {current}")]
KeyVersionMismatch { envelope: i32, current: i32 },
} }
struct Unsealed { struct Unsealed {
root_key_history_id: RootKeyHistoryId, root_key_history_id: i32,
root_key: KeyCell, root_key: KeyCell,
} }
@@ -72,13 +78,14 @@ struct Unsealed {
enum State { enum State {
#[default] #[default]
Unbootstrapped, Unbootstrapped,
Sealed { Sealed {
root_key_history_id: RootKeyHistoryId, root_key_history_id: i32,
}, },
Unsealed(Unsealed), Unsealed(Unsealed),
} }
const MAX_UNSEAL_ATTEMPTS: u32 = 5;
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed). /// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
/// ///
/// Provides API for encrypting and decrypting data using the vault root key. /// Provides API for encrypting and decrypting data using the vault root key.
@@ -88,8 +95,10 @@ pub struct Vault {
db: db::DatabasePool, db: db::DatabasePool,
state: State, state: State,
events: ActorRef<MessageBus>, events: ActorRef<MessageBus>,
unseal_failures: u32,
} }
#[messages]
impl Vault { impl Vault {
pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> { pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
let state = { let state = {
@@ -109,15 +118,12 @@ impl Vault {
} }
}; };
Ok(Self { db, state, events }) Ok(Self { db, state, events, unseal_failures: 0 })
} }
// Exclusive transaction to avoid race conditions if multiple vaults write // Exclusive transaction to avoid race condtions if multiple vaults write
// additional layer of protection against nonce-reuse // additional layer of protection against nonce-reuse
async fn get_new_nonce( async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> {
pool: &db::DatabasePool,
root_key_id: RootKeyHistoryId,
) -> Result<Nonce, Error> {
let mut conn = pool.get().await?; let mut conn = pool.get().await?;
let nonce = conn let nonce = conn
@@ -130,7 +136,7 @@ impl Vault {
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| { let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
error!( error!(
"Broken database: invalid nonce for root key history id={:#?}", "Broken database: invalid nonce for root key history id={}",
root_key_id root_key_id
); );
Error::BrokenDatabase Error::BrokenDatabase
@@ -157,47 +163,43 @@ impl Vault {
State::Sealed { .. } => Err(Error::Sealed), State::Sealed { .. } => Err(Error::Sealed),
} }
} }
}
#[messages]
impl Vault {
#[message] #[message]
pub async fn bootstrap(&mut self, mut seal_key: KeyCell) -> Result<(), Error> { pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if !matches!(&self.state, State::Unbootstrapped) { if !matches!(self.state, State::Unbootstrapped) {
return Err(Error::AlreadyBootstrapped); return Err(Error::AlreadyBootstrapped);
} }
let salt = v1::generate_salt();
let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = KeyCell::new_secure_random(); let mut root_key = KeyCell::new_secure_random();
// Zero nonces are fine because they are one-time // Zero nonces are fine because they are one-time
let root_key_nonce = Nonce::default(); let root_key_nonce = Nonce::default();
let data_encryption_nonce = Nonce::default(); let data_encryption_nonce = Nonce::default();
// Generate salt (kept for schema compat) let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
let root_key_salt = v1::generate_salt(); let root_key_reader = reader.as_slice();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|rk| {
seal_key seal_key
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, rk.as_slice()) .encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader)
.map_err(|err| { .map_err(|err| {
error!(?err, "Fatal bootstrap error"); error!(?err, "Fatal bootstrap error");
Error::Encryption(err) Error::Encryption(err)
}) })
})?; })?;
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let root_key_history_id = conn let root_key_history_id = conn
.transaction(async |conn| { .transaction(async |conn| {
let root_key_history_id = insert_into(schema::root_key_history::table) let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory { .values(&models::NewRootKeyHistory {
ciphertext: root_key_ciphertext.clone(), ciphertext: root_key_ciphertext.clone(),
tag: v1::ROOT_KEY_TAG.to_vec(), tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: root_key_nonce.to_vec(), root_key_encryption_nonce: root_key_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce_bytes.clone(), data_encryption_nonce: data_encryption_nonce_bytes.clone(),
schema_version: 1, schema_version: 1,
salt: root_key_salt.to_vec(), salt: salt.to_vec(),
}) })
.returning(schema::root_key_history::id) .returning(schema::root_key_history::id)
.get_result(&mut *conn) .get_result(&mut *conn)
@@ -208,9 +210,7 @@ impl Vault {
.execute(&mut *conn) .execute(&mut *conn)
.await?; .await?;
Result::<_, diesel::result::Error>::Ok(RootKeyHistoryId::from_raw( Result::<_, diesel::result::Error>::Ok(root_key_history_id)
root_key_history_id,
))
}) })
.await?; .await?;
@@ -226,47 +226,70 @@ impl Vault {
} }
#[message] #[message]
pub async fn try_unseal(&mut self, mut seal_key: KeyCell) -> Result<(), Error> { pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if self.unseal_failures >= MAX_UNSEAL_ATTEMPTS {
return Err(Error::LockedOut);
}
let State::Sealed { let State::Sealed {
root_key_history_id, root_key_history_id,
} = &self.state } = &self.state
else { else {
return Err(Error::NotBootstrapped); return Err(Error::NotBootstrapped);
}; };
let root_key_history_id = *root_key_history_id;
// We don't want to hold connection while doing expensive work // We don't want to hold connection while doing expensive KDF work
let current_key = { let current_key = {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
schema::root_key_history::table schema::root_key_history::table
.filter(schema::root_key_history::id.eq(root_key_history_id)) .filter(schema::root_key_history::id.eq(*root_key_history_id))
.select(RootKeyHistory::as_select()) .select(RootKeyHistory::as_select())
.first(&mut conn) .first(&mut conn)
.await? .await?
}; };
let salt = &current_key.salt;
let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| {
error!("Broken database: invalid salt for root key");
Error::BrokenDatabase
})?;
let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
let nonce = let nonce =
Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| { Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| {
error!("Broken database: invalid nonce for root key"); error!("Broken database: invalid nonce for root key");
Error::BrokenDatabase Error::BrokenDatabase
})?; })?;
let mut root_key_bytes = SafeCell::new(current_key.ciphertext.clone()); if seal_key
seal_key .decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key_bytes) .is_err()
.map_err(|err| { {
error!(?err, "Failed to unseal root key: invalid seal key"); self.unseal_failures += 1;
Error::InvalidKey if self.unseal_failures >= MAX_UNSEAL_ATTEMPTS {
})?; error!(
attempts = self.unseal_failures,
let root_key = KeyCell::try_from(root_key_bytes).map_err(|()| { "Vault locked: maximum failed unseal attempts reached"
error!("Broken database: invalid encryption key size"); );
Error::BrokenDatabase } else {
})?; warn!(
attempts = self.unseal_failures,
remaining = MAX_UNSEAL_ATTEMPTS - self.unseal_failures,
"Failed unseal attempt"
);
}
return Err(Error::InvalidKey);
}
self.unseal_failures = 0;
self.state = State::Unsealed(Unsealed { self.state = State::Unsealed(Unsealed {
root_key_history_id: current_key.id, root_key_history_id: current_key.id,
root_key, root_key: KeyCell::try_from(root_key).map_err(|err| {
error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase
})?,
}); });
info!("Vault unsealed successfully"); info!("Vault unsealed successfully");
@@ -275,79 +298,10 @@ impl Vault {
Ok(()) Ok(())
} }
/// Re-encrypts the root key with `new_seal_key` and records a new root_key_history row. /// Decrypts an AEAD entry. The `aad` must match the value used at encryption time;
/// Called after a Shamir re-key so the old seal key is no longer sufficient to unseal. /// a mismatch causes authentication failure, preventing cross-wallet key swaps.
#[message] #[message]
pub async fn rekey_root_key(&mut self, mut new_seal_key: KeyCell) -> Result<(), Error> { pub async fn decrypt(&mut self, aead_id: i32, aad: Vec<u8>) -> Result<SafeCell<Vec<u8>>, 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]
pub async fn seal(&mut self) -> Result<(), Error> {
let Unsealed {
root_key_history_id,
..
} = Self::expect_unsealed(&mut self.state)?;
self.state = State::Sealed {
root_key_history_id: *root_key_history_id,
};
let _ = self.events.tell(Publish(events::VaultResealed)).await;
Ok(())
}
}
// Server-side cryptographic operations
#[messages]
impl Vault {
#[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?; let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?;
let row: models::AeadEncrypted = { let row: models::AeadEncrypted = {
@@ -369,13 +323,15 @@ impl Vault {
Error::BrokenDatabase Error::BrokenDatabase
})?; })?;
let mut output = SafeCell::new(row.ciphertext); let mut output = SafeCell::new(row.ciphertext);
root_key.decrypt_in_place(&nonce, v1::TAG, &mut output)?; root_key.decrypt_in_place(&nonce, &aad, &mut output)?;
Ok(output) Ok(output)
} }
/// Creates a new `aead_encrypted` entry and returns its ID.
/// The `aad` is bound into the ciphertext and must be reproduced exactly at decryption time.
// Creates new `aead_encrypted` entry in the database and returns it's ID // Creates new `aead_encrypted` entry in the database and returns it's ID
#[message] #[message]
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> { pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>, aad: Vec<u8>) -> Result<i32, Error> {
let Unsealed { let Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
@@ -387,7 +343,7 @@ impl Vault {
let mut ciphertext_buffer = plaintext.write(); let mut ciphertext_buffer = plaintext.write();
let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut(); let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut();
root_key.encrypt_in_place(&nonce, v1::TAG, &mut *ciphertext_buffer)?; root_key.encrypt_in_place(&nonce, &aad, &mut *ciphertext_buffer)?;
let ciphertext = std::mem::take(ciphertext_buffer); let ciphertext = std::mem::take(ciphertext_buffer);
@@ -414,10 +370,7 @@ impl Vault {
} }
#[message] #[message]
pub fn sign_integrity( pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
&mut self,
mac_input: Vec<u8>,
) -> Result<(RootKeyHistoryId, Vec<u8>), Error> {
let Unsealed { let Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
@@ -427,7 +380,7 @@ impl Vault {
HmacSha256::new_from_slice(k) HmacSha256::new_from_slice(k)
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size")) .unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
}); });
hmac.update(&root_key_history_id.to_raw().to_be_bytes()); hmac.update(&root_key_history_id.to_be_bytes());
hmac.update(&mac_input); hmac.update(&mac_input);
let mac = hmac.finalize().into_bytes().to_vec(); let mac = hmac.finalize().into_bytes().to_vec();
@@ -439,7 +392,7 @@ impl Vault {
&mut self, &mut self,
mac_input: Vec<u8>, mac_input: Vec<u8>,
expected_mac: Vec<u8>, expected_mac: Vec<u8>,
key_version: RootKeyHistoryId, key_version: i32,
) -> Result<bool, Error> { ) -> Result<bool, Error> {
let Unsealed { let Unsealed {
root_key, root_key,
@@ -447,18 +400,35 @@ impl Vault {
} = Self::expect_unsealed(&mut self.state)?; } = Self::expect_unsealed(&mut self.state)?;
if *root_key_history_id != key_version { if *root_key_history_id != key_version {
return Ok(false); return Err(Error::KeyVersionMismatch {
envelope: key_version,
current: *root_key_history_id,
});
} }
let mut hmac = root_key.0.read_inline(|k| { let mut hmac = root_key.0.read_inline(|k| {
HmacSha256::new_from_slice(k) HmacSha256::new_from_slice(k)
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size")) .unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
}); });
hmac.update(&key_version.to_raw().to_be_bytes()); hmac.update(&key_version.to_be_bytes());
hmac.update(&mac_input); hmac.update(&mac_input);
Ok(hmac.verify_slice(&expected_mac).is_ok()) Ok(hmac.verify_slice(&expected_mac).is_ok())
} }
#[message]
pub async fn seal(&mut self) -> Result<(), Error> {
let Unsealed {
root_key_history_id,
..
} = Self::expect_unsealed(&mut self.state)?;
self.state = State::Sealed {
root_key_history_id: *root_key_history_id,
};
let _ = self.events.tell(Publish(events::VaultResealed)).await;
Ok(())
}
} }
#[cfg(test)] #[cfg(test)]
@@ -472,7 +442,8 @@ mod tests {
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await .await
.unwrap(); .unwrap();
actor.bootstrap(KeyCell::from([0u8; 32])).await.unwrap(); let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.bootstrap(seal_key).await.unwrap();
actor actor
} }
@@ -481,12 +452,13 @@ mod tests {
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() { async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await; let mut actor = bootstrapped_actor(&db).await;
let State::Unsealed(Unsealed { let State::Unsealed(Unsealed {
root_key_history_id, root_key_history_id,
.. ..
}) = actor.state }) = actor.state
else { else {
panic!("expected unsealed state"); panic!("expected unsealed state")
}; };
let n1 = Vault::get_new_nonce(&db, root_key_history_id) let n1 = Vault::get_new_nonce(&db, root_key_history_id)
@@ -506,7 +478,7 @@ mod tests {
assert_eq!(root_row.data_encryption_nonce, n2.to_vec()); assert_eq!(root_row.data_encryption_nonce, n2.to_vec());
let id = actor let id = actor
.create_new(SafeCell::new(b"post-interleave".to_vec())) .create_new(SafeCell::new(b"post-interleave".to_vec()), b"test-aad".to_vec())
.await .await
.unwrap(); .unwrap();
let row: models::AeadEncrypted = schema::aead_encrypted::table let row: models::AeadEncrypted = schema::aead_encrypted::table

View File

@@ -1,721 +0,0 @@
use std::collections::HashMap;
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{Actor, actor::ActorRef, messages};
use rand_core::{OsRng, RngCore as _};
use tracing::error;
use crate::{
actors::vault::{Bootstrap, RekeyRootKey, TryUnseal, Vault},
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir, shamir::shamir_threshold},
db::{self, models, schema},
};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Already coordinating a bootstrap")]
AlreadyBootstrapping,
#[error("Already coordinating an unseal")]
AlreadyUnsealing,
#[error("Rekey not in progress")]
NotRekeying,
#[error("Bootstrap not in progress")]
NotBootstrapping,
#[error("Unseal not in progress")]
NotUnsealing,
#[error("Operator already contributed")]
DuplicateContribution,
#[error("Operator not found in database")]
OperatorNotFound,
#[error("Invalid passphrase (decryption failed)")]
InvalidPassphrase,
#[error("Shamir error: {0}")]
Shamir(String),
#[error("Database connection error: {0}")]
DatabaseConnection(#[from] db::PoolError),
#[error("Database query error: {0}")]
DatabaseQuery(#[from] diesel::result::Error),
#[error("Encryption error")]
Encryption,
#[error("Vault error")]
VaultError,
#[error("Two-operator vaults require at least one recovery share")]
TwoOperatorsRequireRecovery,
#[error("Broken database")]
BrokenDatabase,
}
// Passphrases stored as plain Vec<u8> (not SafeCell) so CoordinatorState is Sync.
// They are ephemeral and dropped immediately after use.
enum CoordinatorState {
Idle,
Bootstrapping {
declared_count: usize,
recovery_count: usize,
passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
},
Unsealing {
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>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
},
}
#[derive(Actor)]
pub struct VaultCoordinator {
db: db::DatabasePool,
vault: ActorRef<Vault>,
state: CoordinatorState,
}
impl VaultCoordinator {
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
Self {
db,
vault,
state: CoordinatorState::Idle,
}
}
}
const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
fn encrypt_share(
passphrase_bytes: Vec<u8>,
share: &[u8],
) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), Error> {
let mut share_salt = vec![0u8; 32];
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(
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 seal_key_bytes = [0u8; 32];
OsRng.fill_bytes(&mut seal_key_bytes);
// threshold == 1 means any single share reconstructs the key (degenerate split).
// vsss-rs requires threshold >= 2, so we store the key directly in this case.
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 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?;
}
vault.ask(Bootstrap { seal_key }).await.map_err(|err| {
error!(?err, "Vault bootstrap failed");
Error::VaultError
})?;
Ok(())
}
/// §3.5: Unseal using any threshold-sized mix of ordinary + recovery shares.
async fn finalize_unseal(
db: db::DatabasePool,
vault: ActorRef<Vault>,
ordinary_passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
) -> Result<(), Error> {
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();
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
schema::operator::table
.filter(schema::operator::id.eq(Some(operator_id_raw)))
.select((
schema::operator::share,
schema::operator::share_nonce,
schema::operator::share_salt,
))
.first(&mut conn)
.await
.map_err(|_| Error::OperatorNotFound)?;
shares.push(decrypt_share(
passphrase_bytes,
encrypted_share,
&share_nonce_bytes,
&share_salt,
operator_id_raw,
)?);
}
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
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);
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
.ask(RekeyRootKey { new_seal_key })
.await
.map_err(|err| {
error!(?err, "Vault rekey failed");
Error::VaultError
})?;
Ok(())
}
#[messages]
impl VaultCoordinator {
/// Phase 1 of multi-operator bootstrap: declare the committee size.
#[message]
#[expect(clippy::unused_async, reason = "kameo requires messages to be async")]
pub async fn start_bootstrap(
&mut self,
operator_id: i32,
declared_count: usize,
recovery_count: usize,
) -> Result<(), Error> {
let _ = operator_id; // fixme!: any authenticated operator may announce the committee size. the first call wins
if !matches!(self.state, CoordinatorState::Idle) {
return Err(Error::AlreadyBootstrapping);
}
if declared_count == 2 && recovery_count == 0 {
return Err(Error::TwoOperatorsRequireRecovery);
}
self.state = CoordinatorState::Bootstrapping {
declared_count,
recovery_count,
passphrases: HashMap::new(),
recovery_passphrases: HashMap::new(),
};
Ok(())
}
/// Phase 2 of multi-operator bootstrap: ordinary operator contributes a passphrase.
/// Returns Ok(true) when all ordinary + recovery operators contributed and bootstrap finalized.
#[message]
pub async fn contribute_bootstrap(
&mut self,
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 passphrases.contains_key(&operator_id) {
return Err(Error::DuplicateContribution);
}
let passphrase_bytes = passphrase.read().to_vec();
passphrases.insert(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)
}
/// 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.
#[message]
pub async fn contribute_unseal(
&mut self,
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 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) {
return Err(Error::DuplicateContribution);
}
passphrases.insert(operator_id, passphrase.read().to_vec());
if passphrases.len() < *ordinary_count || recovery_passphrases.len() < *recovery_count {
return Ok(false);
}
self.do_finalize_rekey().await
}
/// 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 {
return Err(Error::NotRekeying);
};
if recovery_passphrases.contains_key(&recovery_operator_id) {
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
}
}

View File

@@ -61,12 +61,12 @@ mod tests {
#[test] #[test]
fn derive_seal_key_deterministic() { fn derive_seal_key_deterministic() {
static PASSWORD: &[u8] = b"password"; static PASSWORD: &[u8] = b"password";
let mut password = SafeCell::new(PASSWORD.to_vec()); let password = SafeCell::new(PASSWORD.to_vec());
let mut password2 = SafeCell::new(PASSWORD.to_vec()); let password2 = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt(); let salt = generate_salt();
let mut key1 = derive_key(&mut password, &salt); let mut key1 = derive_key(password, &salt);
let mut key2 = derive_key(&mut password2, &salt); let mut key2 = derive_key(password2, &salt);
let key1_reader = key1.0.read(); let key1_reader = key1.0.read();
let key2_reader = key2.0.read(); let key2_reader = key2.0.read();
@@ -77,10 +77,10 @@ mod tests {
#[test] #[test]
fn successful_derive() { fn successful_derive() {
static PASSWORD: &[u8] = b"password"; static PASSWORD: &[u8] = b"password";
let mut password = SafeCell::new(PASSWORD.to_vec()); let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt(); let salt = generate_salt();
let mut key = derive_key(&mut password, &salt); let mut key = derive_key(password, &salt);
let key_reader = key.0.read(); let key_reader = key.0.read();
assert_ne!(key_reader.as_slice(), &[0u8; 32][..]); assert_ne!(key_reader.as_slice(), &[0u8; 32][..]);

View File

@@ -192,7 +192,9 @@ pub async fn verify_entity<E: Integrable>(
Ok(false) => Err(Error::MacMismatch { Ok(false) => Err(Error::MacMismatch {
entity_kind: E::KIND, entity_kind: E::KIND,
}), }),
Err(SendError::HandlerError(vault::Error::Sealed)) => Ok(AttestationStatus::Unavailable), Err(SendError::HandlerError(
vault::Error::Sealed | vault::Error::KeyVersionMismatch { .. },
)) => Ok(AttestationStatus::Unavailable),
Err(_) => Err(Error::VaultSend), Err(_) => Err(Error::VaultSend),
} }
} }
@@ -215,6 +217,8 @@ mod tests {
}, },
db::{self, schema}, db::{self, schema},
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use super::{Error, Integrable, sign_entity, verify_entity}; use super::{Error, Integrable, sign_entity, verify_entity};
#[derive(Clone, arbiter_macros::Hashable)] #[derive(Clone, arbiter_macros::Hashable)]
struct DummyEntity { struct DummyEntity {
@@ -233,7 +237,7 @@ mod tests {
); );
actor actor
.ask(Bootstrap { .ask(Bootstrap {
seal_key: crate::crypto::KeyCell::from([0u8; 32]), seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
}) })
.await .await
.unwrap(); .unwrap();
@@ -329,4 +333,47 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. })); assert!(matches!(err, Error::MacMismatch { .. }));
} }
#[tokio::test]
async fn key_version_mismatch_returns_unavailable_not_mac_mismatch() {
use crate::db::schema::integrity_envelope;
use super::AttestationStatus;
const ENTITY_ID: &[u8] = b"entity-id-rotation-test";
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap();
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap();
// Simulate key rotation: update the stored key_version to a stale value.
// After real rotation the vault's root_key_history_id would advance, but
// here we achieve the same mismatch by back-dating the envelope's key_version.
diesel::update(integrity_envelope::table)
.filter(integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(integrity_envelope::entity_id.eq(ENTITY_ID))
.set(integrity_envelope::key_version.eq(0))
.execute(&mut conn)
.await
.unwrap();
// Must NOT error — version mismatch is Unavailable, not tampered.
let status = verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.expect("key version mismatch must not be treated as an error");
assert_eq!(
status,
AttestationStatus::Unavailable,
"stale key_version must yield Unavailable, not MacMismatch"
);
}
} }

View File

@@ -1,5 +1,5 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use encryption::v1::Nonce; use encryption::v1::{Nonce, Salt};
use argon2::{Algorithm, Argon2}; use argon2::{Algorithm, Argon2};
use chacha20poly1305::{ use chacha20poly1305::{
@@ -13,7 +13,6 @@ use rand::{
pub mod encryption; pub mod encryption;
pub mod integrity; pub mod integrity;
pub mod shamir;
pub struct KeyCell(pub SafeCell<Key>); pub struct KeyCell(pub SafeCell<Key>);
impl From<SafeCell<Key>> for KeyCell { impl From<SafeCell<Key>> for KeyCell {
@@ -21,15 +20,6 @@ impl From<SafeCell<Key>> for KeyCell {
Self(value) Self(value)
} }
} }
impl From<[u8; 32]> for KeyCell {
fn from(bytes: [u8; 32]) -> Self {
let cell = SafeCell::new_inline_default(|key: &mut Key| {
key.copy_from_slice(&bytes);
});
Self(cell)
}
}
impl TryFrom<SafeCell<Vec<u8>>> for KeyCell { impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
type Error = (); type Error = ();
@@ -38,7 +28,7 @@ impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
if value.len() != size_of::<Key>() { if value.len() != size_of::<Key>() {
return Err(()); return Err(());
} }
let cell = SafeCell::new_inline_default(|cell_write: &mut Key| { let cell = SafeCell::new_inline(|cell_write: &mut Key| {
cell_write.copy_from_slice(&value); cell_write.copy_from_slice(&value);
}); });
Ok(Self(cell)) Ok(Self(cell))
@@ -47,7 +37,7 @@ impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
impl KeyCell { impl KeyCell {
pub fn new_secure_random() -> Self { pub fn new_secure_random() -> Self {
let key = SafeCell::new_inline_default(|key_buffer: &mut Key| { let key = SafeCell::new_inline(|key_buffer: &mut Key| {
let mut rng = StdRng::try_from_rng(&mut SysRng) let mut rng = StdRng::try_from_rng(&mut SysRng)
.expect("Rng failure is unrecoverable and should panic"); .expect("Rng failure is unrecoverable and should panic");
rng.fill_bytes(key_buffer); rng.fill_bytes(key_buffer);
@@ -104,7 +94,7 @@ impl KeyCell {
} }
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation. /// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_key(password: &mut SafeCell<Vec<u8>>, salt: &[u8]) -> KeyCell { pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
let params = { let params = {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
{ {
@@ -142,10 +132,10 @@ mod tests {
#[test] #[test]
fn encrypt_decrypt() { fn encrypt_decrypt() {
static PASSWORD: &[u8] = b"password"; static PASSWORD: &[u8] = b"password";
let mut password = SafeCell::new(PASSWORD.to_vec()); let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt(); let salt = generate_salt();
let mut key = derive_key(&mut password, &salt); let mut key = derive_key(password, &salt);
let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305 let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
let associated_data = b"associated data"; let associated_data = b"associated data";
let mut buffer = b"secret data".to_vec(); let mut buffer = b"secret data".to_vec();

View File

@@ -1,41 +0,0 @@
use vsss_rs::Gf256;
#[derive(Debug, thiserror::Error)]
pub enum ShamirError {
#[error("Failed to split key: {0}")]
Split(String),
#[error("Failed to combine shares: {0}")]
Combine(String),
}
/// Split `key` into `total` shares where any `threshold` shares can reconstruct it.
/// Each returned Vec<u8> is a share with format [`identifier_byte`, `value_bytes`...].
pub fn split_key(
threshold: usize,
total: usize,
key: &[u8; 32],
rng: impl rand_core::RngCore + rand_core::CryptoRng,
) -> Result<Vec<Vec<u8>>, ShamirError> {
Gf256::split_array(threshold, total, key.as_slice(), rng)
.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.
pub fn combine_shares(shares: &[Vec<u8>]) -> Result<[u8; 32], ShamirError> {
let bytes = Gf256::combine_array(shares)
.map_err(|e| ShamirError::Combine(format!("{e:?}")))?;
<[u8; 32]>::try_from(bytes.as_slice())
.map_err(|_| ShamirError::Combine("unexpected reconstructed key length".to_owned()))
}

View File

@@ -15,11 +15,10 @@ 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, Text}, sql_types::Integer,
sqlite::{Sqlite, SqliteType}, sqlite::{Sqlite, SqliteType},
}; };
@@ -62,7 +61,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 Backend>::RawValue<'_>, mut bytes: <Sqlite as diesel::backend::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!(
@@ -80,41 +79,10 @@ pub mod types {
} }
} }
macro_rules! declare_id { #[derive(Debug, FromSqlRow, AsExpression, Clone)]
($name:ident) => { #[diesel(sql_type = Integer)]
#[derive(Debug, FromSqlRow, AsExpression, Clone, Hash, Copy, PartialEq, Eq)] #[repr(transparent)] // hint compiler to optimize the wrapper struct away
#[diesel(sql_type = Integer)] pub struct ChainId(pub i32);
#[repr(transparent)] // hint compiler to optimize the wrapper struct away
pub struct $name(i32);
impl $name {
pub const fn to_raw(self) -> i32 {
self.0
}
pub const fn from_raw(raw: i32) -> Self {
Self(raw)
}
}
impl FromSql<Integer, Sqlite> for $name {
fn from_sql(
bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
FromSql::<Integer, Sqlite>::from_sql(bytes).map(Self)
}
}
impl ToSql<Integer, Sqlite> for $name {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
}
}
};
}
declare_id!(ChainId);
#[expect( #[expect(
clippy::cast_sign_loss, clippy::cast_sign_loss,
@@ -135,50 +103,19 @@ pub mod types {
} }
}; };
declare_id!(OperatorId); impl FromSql<Integer, Sqlite> for ChainId {
declare_id!(OperatorIdentityId); fn from_sql(
declare_id!(AeadEncryptedId); bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
declare_id!(RootKeyHistoryId); ) -> diesel::deserialize::Result<Self> {
declare_id!(TlsHistoryId); FromSql::<Integer, Sqlite>::from_sql(bytes).map(Self)
declare_id!(EvmWalletId); }
declare_id!(ClientId);
#[derive(Debug, Clone, PartialEq, Eq, AsExpression, FromSqlRow)]
#[diesel(sql_type = Text)]
pub enum ProposalStatus {
Pending,
Approved,
Rejected,
Expired,
} }
impl ToSql<Integer, Sqlite> for ChainId {
impl ToSql<Text, Sqlite> for ProposalStatus {
fn to_sql<'b>( fn to_sql<'b>(
&'b self, &'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>, out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result { ) -> diesel::serialize::Result {
let s: &str = match self { ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
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()),
}
} }
} }
} }
@@ -193,12 +130,12 @@ pub use types::*;
)] )]
#[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))] #[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))]
pub struct AeadEncrypted { pub struct AeadEncrypted {
pub id: AeadEncryptedId, pub id: i32,
pub ciphertext: Vec<u8>, pub ciphertext: Vec<u8>,
pub tag: Vec<u8>, pub tag: Vec<u8>,
pub current_nonce: Vec<u8>, pub current_nonce: Vec<u8>,
pub schema_version: i32, pub schema_version: i32,
pub associated_root_key_id: RootKeyHistoryId, pub associated_root_key_id: i32, // references root_key_history.id
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }
@@ -211,7 +148,7 @@ pub struct AeadEncrypted {
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct RootKeyHistory { pub struct RootKeyHistory {
pub id: RootKeyHistoryId, pub id: i32,
pub ciphertext: Vec<u8>, pub ciphertext: Vec<u8>,
pub tag: Vec<u8>, pub tag: Vec<u8>,
pub root_key_encryption_nonce: Vec<u8>, pub root_key_encryption_nonce: Vec<u8>,
@@ -229,7 +166,7 @@ pub struct RootKeyHistory {
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct TlsHistory { pub struct TlsHistory {
pub id: TlsHistoryId, pub id: i32,
pub cert: String, pub cert: String,
pub cert_key: String, // PEM Encoded private key pub cert_key: String, // PEM Encoded private key
pub ca_cert: String, // PEM Encoded certificate for cert signing pub ca_cert: String, // PEM Encoded certificate for cert signing
@@ -254,7 +191,7 @@ pub struct ArbiterSettings {
attributes_with = "deriveless" attributes_with = "deriveless"
)] )]
pub struct EvmWallet { pub struct EvmWallet {
pub id: EvmWalletId, pub id: i32,
pub address: Vec<u8>, pub address: Vec<u8>,
pub aead_encrypted_id: i32, pub aead_encrypted_id: i32,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
@@ -276,7 +213,7 @@ pub struct EvmWallet {
)] )]
pub struct EvmWalletAccess { pub struct EvmWalletAccess {
pub id: i32, pub id: i32,
pub wallet_id: EvmWalletId, pub wallet_id: i32,
pub client_id: i32, pub client_id: i32,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }
@@ -303,7 +240,7 @@ pub struct ProgramClientMetadataHistory {
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = schema::program_client, check_for_backend(Sqlite))] #[diesel(table_name = schema::program_client, check_for_backend(Sqlite))]
pub struct ProgramClient { pub struct ProgramClient {
pub id: ClientId, pub id: i32,
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub metadata_id: i32, pub metadata_id: i32,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
@@ -313,23 +250,12 @@ pub struct ProgramClient {
#[derive(Queryable, Debug)] #[derive(Queryable, Debug)]
#[diesel(table_name = schema::operator_client, check_for_backend(Sqlite))] #[diesel(table_name = schema::operator_client, check_for_backend(Sqlite))]
pub struct OperatorClient { pub struct OperatorClient {
pub id: OperatorIdentityId, pub id: i32,
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp, pub updated_at: SqliteTimestamp,
} }
#[derive(Queryable, Debug)]
#[diesel(table_name = schema::operator, check_for_backend(Sqlite))]
pub struct Operator {
pub id: OperatorId,
pub share: Vec<u8>,
pub share_nonce: Vec<u8>,
pub share_salt: Vec<u8>,
pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_ether_transfer_limit, check_for_backend(Sqlite))] #[diesel(table_name = evm_ether_transfer_limit, check_for_backend(Sqlite))]
#[view( #[view(
@@ -473,73 +399,8 @@ pub struct IntegrityEnvelope {
pub entity_kind: String, pub entity_kind: String,
pub entity_id: Vec<u8>, pub entity_id: Vec<u8>,
pub payload_version: i32, pub payload_version: i32,
pub key_version: RootKeyHistoryId, pub key_version: i32,
pub mac: Vec<u8>, pub mac: Vec<u8>,
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,
}

View File

@@ -152,98 +152,6 @@ diesel::table! {
} }
} }
diesel::table! {
operator (id) {
id -> Nullable<Integer>,
share -> Binary,
share_nonce -> Binary,
share_salt -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
operator_identity (id) {
id -> Integer,
public_key -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
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,
@@ -277,6 +185,15 @@ diesel::table! {
} }
} }
diesel::table! {
operator_client (id) {
id -> Integer,
public_key -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::joinable!(aead_encrypted -> root_key_history (associated_root_key_id)); diesel::joinable!(aead_encrypted -> root_key_history (associated_root_key_id));
diesel::joinable!(arbiter_settings -> root_key_history (root_key_id)); diesel::joinable!(arbiter_settings -> root_key_history (root_key_id));
diesel::joinable!(arbiter_settings -> tls_history (tls_id)); diesel::joinable!(arbiter_settings -> tls_history (tls_id));
@@ -295,24 +212,10 @@ diesel::joinable!(evm_transaction_log -> evm_wallet_access (wallet_access_id));
diesel::joinable!(evm_wallet -> aead_encrypted (aead_encrypted_id)); diesel::joinable!(evm_wallet -> aead_encrypted (aead_encrypted_id));
diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id)); 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!(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,
@@ -327,11 +230,8 @@ diesel::allow_tables_to_appear_in_same_query!(
evm_wallet, evm_wallet,
evm_wallet_access, evm_wallet_access,
integrity_envelope, integrity_envelope,
operator,
operator_identity,
program_client, program_client,
proposal,
proposal_vote,
root_key_history, root_key_history,
tls_history, tls_history,
operator_client,
); );

View File

@@ -1,28 +1,34 @@
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use crate::{ use crate::{
actors::vault::Vault, actors::vault::Vault,
crypto::integrity, crypto::integrity,
db::{ db::{
self, DatabaseError, self, DatabaseError,
models::{ models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp, EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget,
EvmEtherTransferLimit, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit,
EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
}, },
schema::{self, evm_transaction_log}, schema::{self, evm_transaction_log},
}, },
evm::policies::{ evm::policies::{
CombinedSettings, DatabaseID, EvalContext, EvalViolation, Grant, Policy, CombinedSettings, DatabaseID, EvalContext, EvalViolation, Grant, Policy,
SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
token_transfers::TokenTransfer, ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
}, },
}; };
use alloy::{ use alloy::{
consensus::TxEip1559, consensus::TxEip1559,
primitives::{TxKind, U256}, primitives::{Address, TxKind, U256},
}; };
use chrono::Utc; use chrono::Utc;
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite}; use diesel::{
use diesel_async::{AsyncConnection, RunQueryDsl}; ExpressionMethods as _, OptionalExtension, QueryDsl as _, QueryResult, SelectableHelper,
use kameo::actor::ActorRef; insert_into, sqlite::Sqlite, update,
};
pub mod abi; pub mod abi;
pub mod safe_signer; pub mod safe_signer;
@@ -272,6 +278,151 @@ impl Engine {
Ok(id) Ok(id)
} }
pub async fn revoke_grant(
&self,
basic_grant_id: i32,
) -> Result<(), DatabaseError> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let vault = self.vault.clone();
conn.transaction(async move |conn| {
use crate::db::schema::{
evm_basic_grant, evm_ether_transfer_grant, evm_ether_transfer_grant_target,
evm_ether_transfer_limit, evm_token_transfer_grant,
evm_token_transfer_volume_limit,
};
update(evm_basic_grant::table)
.filter(evm_basic_grant::id.eq(basic_grant_id))
.set(evm_basic_grant::revoked_at.eq(SqliteTimestamp(Utc::now())))
.execute(&mut *conn)
.await?;
let basic_grant: EvmBasicGrant = evm_basic_grant::table
.filter(evm_basic_grant::id.eq(basic_grant_id))
.select(EvmBasicGrant::as_select())
.first(&mut *conn)
.await?;
let shared = SharedGrantSettings::try_from_model(basic_grant)?;
if let Some(ether_grant) = evm_ether_transfer_grant::table
.filter(evm_ether_transfer_grant::basic_grant_id.eq(basic_grant_id))
.select(EvmEtherTransferGrant::as_select())
.first(&mut *conn)
.await
.optional()?
{
let target_rows: Vec<EvmEtherTransferGrantTarget> =
evm_ether_transfer_grant_target::table
.filter(evm_ether_transfer_grant_target::grant_id.eq(ether_grant.id))
.select(EvmEtherTransferGrantTarget::as_select())
.load(&mut *conn)
.await?;
let targets: Vec<Address> = target_rows
.into_iter()
.filter_map(|target| {
let arr: [u8; 20] = target.address.try_into().ok()?;
Some(Address::from(arr))
})
.collect();
let limit: EvmEtherTransferLimit = evm_ether_transfer_limit::table
.filter(evm_ether_transfer_limit::id.eq(ether_grant.limit_id))
.select(EvmEtherTransferLimit::as_select())
.first(&mut *conn)
.await?;
let settings = CombinedSettings {
shared: shared.clone(),
specific: policies::ether_transfer::Settings {
target: targets,
limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
|err| {
diesel::result::Error::DeserializationError(Box::new(err))
},
)?,
window: chrono::Duration::seconds(limit.window_secs.into()),
},
},
};
integrity::sign_entity(&mut *conn, &vault, &settings, basic_grant_id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
return QueryResult::Ok(());
}
if let Some(token_grant) = evm_token_transfer_grant::table
.filter(evm_token_transfer_grant::basic_grant_id.eq(basic_grant_id))
.select(EvmTokenTransferGrant::as_select())
.first(&mut *conn)
.await
.optional()?
{
let volume_limit_rows: Vec<EvmTokenTransferVolumeLimit> =
evm_token_transfer_volume_limit::table
.filter(evm_token_transfer_volume_limit::grant_id.eq(token_grant.id))
.select(EvmTokenTransferVolumeLimit::as_select())
.load(&mut *conn)
.await?;
let volume_limits: Vec<VolumeRateLimit> = volume_limit_rows
.into_iter()
.map(|row| {
Ok(VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(
|err| {
diesel::result::Error::DeserializationError(Box::new(err))
},
)?,
window: chrono::Duration::seconds(row.window_secs.into()),
})
})
.collect::<QueryResult<Vec<_>>>()?;
let target: Option<Address> = match token_grant.receiver {
None => None,
Some(bytes) => {
let arr: [u8; 20] = bytes.try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
"Invalid receiver address length".into(),
)
})?;
Some(Address::from(arr))
}
};
let token_contract: [u8; 20] =
token_grant.token_contract.clone().try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
"Invalid token contract address length".into(),
)
})?;
let settings = CombinedSettings {
shared,
specific: policies::token_transfers::Settings {
token_contract: Address::from(token_contract),
target,
volume_limits,
},
};
integrity::sign_entity(&mut *conn, &vault, &settings, basic_grant_id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
return QueryResult::Ok(());
}
Err(diesel::result::Error::NotFound)
})
.await
.map_err(DatabaseError::from)
}
async fn list_one_kind<Kind: Policy, Y>( async fn list_one_kind<Kind: Policy, Y>(
&self, &self,
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
@@ -351,21 +502,26 @@ impl Engine {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use alloy::primitives::{Address, Bytes, U256, address}; use alloy::primitives::{Address, Bytes, U256, address};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into}; use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
use rstest::rstest; use rstest::rstest;
use crate::actors::{GlobalActors, vault::{Bootstrap, Vault}};
use crate::crypto::integrity;
use crate::db::{ use crate::db::{
self, DatabaseConnection, self, DatabaseConnection,
models::{ models::{
EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog, EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
SqliteTimestamp,
}, },
schema::{evm_basic_grant, evm_transaction_log}, schema::{evm_basic_grant, evm_transaction_log},
}; };
use crate::evm::policies::ether_transfer::EtherTransfer;
use crate::evm::policies::{ use crate::evm::policies::{
EvalContext, EvalViolation, SharedGrantSettings, TransactionRateLimit, CombinedSettings, EvalContext, EvalViolation, Policy, SharedGrantSettings,
TransactionRateLimit, VolumeRateLimit,
}; };
use super::check_shared_constraints; use super::check_shared_constraints;
@@ -378,7 +534,7 @@ mod tests {
EvalContext { EvalContext {
target: EvmWalletAccess { target: EvmWalletAccess {
id: WALLET_ACCESS_ID, id: WALLET_ACCESS_ID,
wallet_id: EvmWalletId::from_raw(5), wallet_id: 10,
client_id: 20, client_id: 20,
created_at: SqliteTimestamp(Utc::now()), created_at: SqliteTimestamp(Utc::now()),
}, },
@@ -397,6 +553,7 @@ mod tests {
chain: CHAIN_ID, chain: CHAIN_ID,
valid_from: None, valid_from: None,
valid_until: None, valid_until: None,
revoked_at: None,
max_gas_fee_per_gas: None, max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None, max_priority_fee_per_gas: None,
rate_limit: None, rate_limit: None,
@@ -605,4 +762,115 @@ mod tests {
assert!(violations.is_empty()); assert!(violations.is_empty());
} }
} }
async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef<Vault> {
let actor = Vault::spawn(
Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap(),
);
actor
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
})
.await
.unwrap();
actor
}
#[tokio::test]
async fn revoke_grant_preserves_revoked_integrity() {
use crate::db::schema::evm_basic_grant;
use diesel::ExpressionMethods as _;
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let engine = super::Engine::new(db.clone(), vault.clone());
let full_grant = CombinedSettings {
shared: SharedGrantSettings {
wallet_access_id: WALLET_ACCESS_ID,
chain: CHAIN_ID,
valid_from: None,
valid_until: None,
revoked_at: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
},
specific: super::policies::ether_transfer::Settings {
target: vec![RECIPIENT],
limit: VolumeRateLimit {
max_volume: U256::from(100u64),
window: Duration::hours(1),
},
},
};
let grant_id = engine
.create_grant::<EtherTransfer>(full_grant)
.await
.unwrap();
engine.revoke_grant(grant_id).await.unwrap();
let mut conn = db.get().await.unwrap();
diesel::update(evm_basic_grant::table)
.filter(evm_basic_grant::id.eq(grant_id))
.set(evm_basic_grant::revoked_at.eq::<Option<SqliteTimestamp>>(None))
.execute(&mut conn)
.await
.unwrap();
let wallet_access = EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
};
let context = EvalContext {
target: wallet_access,
chain: CHAIN_ID,
to: RECIPIENT,
value: U256::ONE,
calldata: Bytes::new(),
max_fee_per_gas: 1,
max_priority_fee_per_gas: 1,
};
let grant = EtherTransfer::try_find_grant(
&context, &mut conn,
)
.await
.unwrap()
.unwrap();
let result =
integrity::verify_entity(&mut conn, &vault, &grant.settings, grant.id).await;
assert!(matches!(
result,
Err(integrity::Error::MacMismatch { .. })
));
}
#[test]
fn shared_settings_hash_changes_when_revoked_at_changes() {
use arbiter_crypto::hashing::Hashable;
use sha2::Digest;
let active = shared_settings();
let revoked = SharedGrantSettings {
revoked_at: Some(Utc::now()),
..shared_settings()
};
let mut active_hash = sha2::Sha256::new();
active.hash(&mut active_hash);
let mut revoked_hash = sha2::Sha256::new();
revoked.hash(&mut revoked_hash);
assert_ne!(active_hash.finalize(), revoked_hash.finalize());
}
} }

View File

@@ -144,6 +144,7 @@ pub struct SharedGrantSettings {
pub valid_from: Option<DateTime<Utc>>, pub valid_from: Option<DateTime<Utc>>,
pub valid_until: Option<DateTime<Utc>>, pub valid_until: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
pub max_gas_fee_per_gas: Option<U256>, pub max_gas_fee_per_gas: Option<U256>,
pub max_priority_fee_per_gas: Option<U256>, pub max_priority_fee_per_gas: Option<U256>,
@@ -158,6 +159,7 @@ impl SharedGrantSettings {
chain: model.chain_id.into(), chain: model.chain_id.into(),
valid_from: model.valid_from.map(Into::into), valid_from: model.valid_from.map(Into::into),
valid_until: model.valid_until.map(Into::into), valid_until: model.valid_until.map(Into::into),
revoked_at: model.revoked_at.map(Into::into),
max_gas_fee_per_gas: model max_gas_fee_per_gas: model
.max_gas_fee_per_gas .max_gas_fee_per_gas
.map(|b| utils::try_bytes_to_u256(&b)) .map(|b| utils::try_bytes_to_u256(&b))

View File

@@ -3,8 +3,7 @@ use crate::{
db::{ db::{
self, DatabaseConnection, self, DatabaseConnection,
models::{ models::{
EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog, EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
SqliteTimestamp,
}, },
schema::{evm_basic_grant, evm_transaction_log}, schema::{evm_basic_grant, evm_transaction_log},
}, },
@@ -32,7 +31,7 @@ fn ctx(to: Address, value: U256) -> EvalContext {
EvalContext { EvalContext {
target: EvmWalletAccess { target: EvmWalletAccess {
id: WALLET_ACCESS_ID, id: WALLET_ACCESS_ID,
wallet_id: EvmWalletId::from_raw(10), wallet_id: 10,
client_id: 20, client_id: 20,
created_at: SqliteTimestamp(Utc::now()), created_at: SqliteTimestamp(Utc::now()),
}, },
@@ -80,6 +79,7 @@ fn shared() -> SharedGrantSettings {
chain: CHAIN_ID, chain: CHAIN_ID,
valid_from: None, valid_from: None,
valid_until: None, valid_until: None,
revoked_at: None,
max_gas_fee_per_gas: None, max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None, max_priority_fee_per_gas: None,
rate_limit: None, rate_limit: None,

View File

@@ -2,7 +2,7 @@ use super::{Settings, TokenTransfer};
use crate::{ use crate::{
db::{ db::{
self, DatabaseConnection, self, DatabaseConnection,
models::{EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, SqliteTimestamp}, models::{EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, SqliteTimestamp},
schema::evm_basic_grant, schema::evm_basic_grant,
}, },
evm::{ evm::{
@@ -45,7 +45,7 @@ fn ctx(to: Address, calldata: Bytes) -> EvalContext {
EvalContext { EvalContext {
target: EvmWalletAccess { target: EvmWalletAccess {
id: WALLET_ACCESS_ID, id: WALLET_ACCESS_ID,
wallet_id: EvmWalletId::from_raw(10), wallet_id: 10,
client_id: 20, client_id: 20,
created_at: SqliteTimestamp(Utc::now()), created_at: SqliteTimestamp(Utc::now()),
}, },
@@ -98,6 +98,7 @@ fn shared() -> SharedGrantSettings {
chain: CHAIN_ID, chain: CHAIN_ID,
valid_from: None, valid_from: None,
valid_until: None, valid_until: None,
revoked_at: None,
max_gas_fee_per_gas: None, max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None, max_priority_fee_per_gas: None,
rate_limit: None, rate_limit: None,

View File

@@ -44,7 +44,7 @@ impl std::fmt::Debug for SafeSigner {
/// Returns the protected key bytes and the derived Ethereum address. /// Returns the protected key bytes and the derived Ethereum address.
pub fn generate(rng: &mut impl rand::Rng) -> (SafeCell<[u8; 32]>, Address) { pub fn generate(rng: &mut impl rand::Rng) -> (SafeCell<[u8; 32]>, Address) {
loop { loop {
let mut cell = SafeCell::new_inline_default(|w: &mut [u8; 32]| { let mut cell = SafeCell::new_inline(|w: &mut [u8; 32]| {
rng.fill_bytes(w); rng.fill_bytes(w);
}); });

View File

@@ -200,7 +200,7 @@ impl Convert for auth::Outbound {
.timestamp .timestamp
.timestamp_nanos_opt() .timestamp_nanos_opt()
.expect("timestamp within range") .expect("timestamp within range")
as u64, .cast_unsigned(),
random: challenge.nonce.to_vec(), random: challenge.nonce.to_vec(),
}) })
} }

View File

@@ -19,7 +19,6 @@ 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;
@@ -116,7 +115,6 @@ 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,
} }
} }

View File

@@ -80,7 +80,7 @@ impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {
.timestamp .timestamp
.timestamp_nanos_opt() .timestamp_nanos_opt()
.expect("timestamp within range") .expect("timestamp within range")
as u64, .cast_unsigned(),
random: challenge.nonce.to_vec(), random: challenge.nonce.to_vec(),
}) })
} }
@@ -171,7 +171,7 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
Some(auth::Inbound::AuthChallengeRequest { Some(auth::Inbound::AuthChallengeRequest {
pubkey, pubkey,
bootstrap_token, bootstrap_token: bootstrap_token.map(String::into_bytes),
}) })
} }
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => { AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => {

View File

@@ -90,7 +90,7 @@ async fn handle_wallet_list(
.into_iter() .into_iter()
.map(|(id, address)| WalletEntry { .map(|(id, address)| WalletEntry {
address: address.to_vec(), address: address.to_vec(),
id: id.to_raw(), id,
}) })
.collect(), .collect(),
}), }),
@@ -217,6 +217,11 @@ async fn handle_sign_transaction(
result: Some(vet_error.convert()), result: Some(vet_error.convert()),
} }
} }
Err(kameo::error::SendError::HandlerError(
SessionSignTransactionError::ClientNotConnected,
)) => {
return Err(Status::permission_denied("client not connected"));
}
Err(kameo::error::SendError::HandlerError(SessionSignTransactionError::Internal)) => { Err(kameo::error::SendError::HandlerError(SessionSignTransactionError::Internal)) => {
EvmSignTransactionResponse { EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error( result: Some(EvmSignTransactionResult::Error(

View File

@@ -1,155 +0,0 @@
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 },
))))
}

View File

@@ -1,10 +1,11 @@
use crate::{ use crate::{
db::models::{CoreEvmWalletAccess, EvmWalletId, NewEvmWalletAccess}, db::models::{CoreEvmWalletAccess, NewEvmWalletAccess},
evm::policies::{ evm::policies::{
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer, SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer,
token_transfers, token_transfers,
}, },
grpc::{Convert, TryConvert}, grpc::Convert,
grpc::TryConvert,
}; };
use arbiter_proto::{ use arbiter_proto::{
proto::evm::{ proto::evm::{
@@ -86,6 +87,7 @@ impl TryConvert for ProtoSharedSettings {
.valid_until .valid_until
.map(ProtoTimestamp::try_convert) .map(ProtoTimestamp::try_convert)
.transpose()?, .transpose()?,
revoked_at: None,
max_gas_fee_per_gas: self max_gas_fee_per_gas: self
.max_gas_fee_per_gas .max_gas_fee_per_gas
.as_deref() .as_deref()
@@ -149,7 +151,7 @@ impl Convert for WalletAccess {
fn convert(self) -> Self::Output { fn convert(self) -> Self::Output {
NewEvmWalletAccess { NewEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(self.wallet_id), wallet_id: self.wallet_id,
client_id: self.sdk_client_id, client_id: self.sdk_client_id,
} }
} }
@@ -164,7 +166,7 @@ impl TryConvert for SdkClientWalletAccess {
return Err(Status::invalid_argument("Missing wallet access entry")); return Err(Status::invalid_argument("Missing wallet access entry"));
}; };
Ok(CoreEvmWalletAccess { Ok(CoreEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(access.wallet_id), wallet_id: access.wallet_id,
client_id: access.sdk_client_id, client_id: access.sdk_client_id,
id: self.id, id: self.id,
}) })

View File

@@ -103,7 +103,7 @@ impl Convert for EvmWalletAccess {
Self::Output { Self::Output {
id: self.id, id: self.id,
access: Some(WalletAccess { access: Some(WalletAccess {
wallet_id: self.wallet_id.to_raw(), wallet_id: self.wallet_id,
sdk_client_id: self.client_id, sdk_client_id: self.client_id,
}), }),
} }

View File

@@ -2,7 +2,7 @@ use crate::{
db::models::NewEvmWalletAccess, db::models::NewEvmWalletAccess,
grpc::Convert, grpc::Convert,
peers::operator::{ peers::operator::{
OperatorSession, OutOfBand, OutOfBand, OperatorSession,
session::handlers::{ session::handlers::{
HandleGrantEvmWalletAccess, HandleListWalletAccess, HandleNewClientApprove, HandleGrantEvmWalletAccess, HandleListWalletAccess, HandleNewClientApprove,
HandleRevokeEvmWalletAccess, HandleSdkClientList, HandleRevokeEvmWalletAccess, HandleSdkClientList,
@@ -11,8 +11,8 @@ use crate::{
}; };
use arbiter_crypto::authn; use arbiter_crypto::authn;
use arbiter_proto::proto::{ use arbiter_proto::proto::{
shared::ClientInfo as ProtoClientMetadata,
operator::{ operator::{
operator_response::Payload as OperatorResponsePayload,
sdk_client::{ sdk_client::{
self as proto_sdk_client, ConnectionCancel as ProtoSdkClientConnectionCancel, self as proto_sdk_client, ConnectionCancel as ProtoSdkClientConnectionCancel,
ConnectionRequest as ProtoSdkClientConnectionRequest, ConnectionRequest as ProtoSdkClientConnectionRequest,
@@ -24,8 +24,8 @@ use arbiter_proto::proto::{
request::Payload as SdkClientRequestPayload, request::Payload as SdkClientRequestPayload,
response::Payload as SdkClientResponsePayload, response::Payload as SdkClientResponsePayload,
}, },
operator_response::Payload as OperatorResponsePayload,
}, },
shared::ClientInfo as ProtoClientMetadata,
}; };
use kameo::actor::ActorRef; use kameo::actor::ActorRef;
@@ -115,7 +115,7 @@ async fn handle_list(
clients: clients clients: clients
.into_iter() .into_iter()
.map(|(client, metadata)| ProtoSdkClientEntry { .map(|(client, metadata)| ProtoSdkClientEntry {
id: client.id.to_raw(), id: client.id,
pubkey: client.public_key.clone(), pubkey: client.public_key.clone(),
info: Some(ProtoClientMetadata { info: Some(ProtoClientMetadata {
name: metadata.name, name: metadata.name,

View File

@@ -1,24 +1,16 @@
use crate::{ use crate::{
actors::vault::VaultState, actors::vault::VaultState,
peers::operator::{ peers::operator::{OperatorSession, session::handlers::HandleQueryVaultState},
OperatorSession,
session::handlers::{
HandleContributeRecoveryRekeyPassphrase, HandleContributeRekeyPassphrase,
HandleQueryVaultState,
},
},
}; };
use arbiter_proto::{ use arbiter_proto::{
proto::shared::VaultState as ProtoVaultState,
proto::operator::{ proto::operator::{
operator_response::Payload as OperatorResponsePayload, operator_response::Payload as OperatorResponsePayload,
vault::{ vault::{
self as proto_vault, self as proto_vault, request::Payload as VaultRequestPayload,
rekey::{self as proto_rekey, RekeyResult as ProtoRekeyResult},
request::Payload as VaultRequestPayload,
response::Payload as VaultResponsePayload, response::Payload as VaultResponsePayload,
}, },
}, },
proto::shared::VaultState as ProtoVaultState,
}; };
use kameo::actor::ActorRef; use kameo::actor::ActorRef;
@@ -41,7 +33,6 @@ 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",
@@ -50,51 +41,6 @@ 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> {

View File

@@ -1,17 +1,14 @@
use crate::{ 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, HandleHandshake, HandleUnsealEncryptedKey,
HandleContributeRecoveryBootstrapPassphrase, HandleContributeRecoveryUnsealPassphrase,
HandleContributeUnsealPassphrase, HandleDeclareCommittee, HandleHandshake,
HandleUnsealEncryptedKey,
}, },
}; };
use arbiter_proto::proto::operator::{ use arbiter_proto::proto::operator::{
operator_request::Payload as OperatorRequestPayload, operator_request::Payload as OperatorRequestPayload,
vault::{ vault::{
self as proto_vault, self as proto_vault,
bootstrap::{self as proto_bootstrap, request::Payload as BootstrapRequestPayload}, bootstrap::{self as proto_bootstrap},
request::Payload as VaultRequestPayload, request::Payload as VaultRequestPayload,
unseal::{self as proto_unseal, request::Payload as UnsealRequestPayload}, unseal::{self as proto_unseal, request::Payload as UnsealRequestPayload},
}, },
@@ -53,9 +50,6 @@ 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",
)),
} }
} }
} }
@@ -79,21 +73,6 @@ impl TryConvert for UnsealRequestPayload {
match self { match self {
Self::Start(start) => start.try_convert(), Self::Start(start) => start.try_convert(),
Self::EncryptedKey(key) => Ok(key.convert()), Self::EncryptedKey(key) => Ok(key.convert()),
Self::ContributePassphrase(cp) => Ok(
vault_gate::Inbound::HandleContributeUnsealPassphrase(
HandleContributeUnsealPassphrase {
passphrase: cp.passphrase,
},
),
),
Self::ContributeRecoveryPassphrase(crp) => Ok(
vault_gate::Inbound::HandleContributeRecoveryUnsealPassphrase(
HandleContributeRecoveryUnsealPassphrase {
recovery_operator_id: crp.recovery_operator_id,
passphrase: crp.passphrase,
},
),
),
} }
} }
} }
@@ -128,44 +107,12 @@ impl TryConvert for proto_bootstrap::Request {
type Error = Status; type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> { fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
self.payload self.encrypted_key
.ok_or_else(|| Status::invalid_argument("Missing bootstrap payload"))? .ok_or_else(|| Status::invalid_argument("Missing bootstrap encrypted key"))?
.try_convert() .try_convert()
} }
} }
impl TryConvert for BootstrapRequestPayload {
type Output = vault_gate::Inbound;
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
match self {
Self::EncryptedKey(key) => key.try_convert(),
Self::DeclareCommittee(dc) => Ok(
vault_gate::Inbound::HandleDeclareCommittee(HandleDeclareCommittee {
count: dc.count as usize,
recovery_count: dc.recovery_count as usize,
}),
),
Self::ContributePassphrase(cp) => Ok(
vault_gate::Inbound::HandleContributeBootstrapPassphrase(
HandleContributeBootstrapPassphrase {
passphrase: cp.passphrase,
},
),
),
Self::ContributeRecoveryPassphrase(crp) => Ok(
vault_gate::Inbound::HandleContributeRecoveryBootstrapPassphrase(
HandleContributeRecoveryBootstrapPassphrase {
recovery_operator_id: crp.recovery_operator_id,
passphrase: crp.passphrase,
},
),
),
}
}
}
impl TryConvert for proto_bootstrap::BootstrapEncryptedKey { impl TryConvert for proto_bootstrap::BootstrapEncryptedKey {
type Output = vault_gate::Inbound; type Output = vault_gate::Inbound;
type Error = Status; type Error = Status;

View File

@@ -4,6 +4,7 @@ use crate::{
peers::operator::vault_gate::{self as vault_gate}, peers::operator::vault_gate::{self as vault_gate},
}; };
use arbiter_proto::proto::{ use arbiter_proto::proto::{
shared::VaultState as ProtoVaultState,
operator::{ operator::{
operator_response::Payload as OperatorResponsePayload, operator_response::Payload as OperatorResponsePayload,
vault::{ vault::{
@@ -16,7 +17,6 @@ use arbiter_proto::proto::{
}, },
}, },
}, },
shared::VaultState as ProtoVaultState,
}; };
use tonic::Status; use tonic::Status;
@@ -87,6 +87,7 @@ impl TryConvert for vault_gate::Outbound {
let proto_result = match result { let proto_result = match result {
Ok(()) => ProtoUnsealResult::Success, Ok(()) => ProtoUnsealResult::Success,
Err(vault_gate::Error::InvalidKey) => ProtoUnsealResult::InvalidKey, Err(vault_gate::Error::InvalidKey) => ProtoUnsealResult::InvalidKey,
Err(vault_gate::Error::LockedOut) => ProtoUnsealResult::LockedOut,
Err(err) => { Err(err) => {
warn!(?err, "unseal failed"); warn!(?err, "unseal failed");
return Err(Status::internal("Failed to unseal vault")); return Err(Status::internal("Failed to unseal vault"));
@@ -110,68 +111,6 @@ impl TryConvert for vault_gate::Outbound {
}; };
Ok(wrap_bootstrap_response(proto_result)) Ok(wrap_bootstrap_response(proto_result))
} }
Self::HandleDeclareCommittee(result) => {
let proto_result = match result {
Ok(()) => ProtoBootstrapResult::Success,
Err(err) => {
warn!(?err, "declare committee failed");
return Err(Status::internal("Failed to declare committee"));
}
};
Ok(wrap_bootstrap_response(proto_result))
}
Self::HandleContributeBootstrapPassphrase(result) => {
let proto_result = match result {
Ok(true) => ProtoBootstrapResult::Success,
Ok(false) => ProtoBootstrapResult::AwaitingContributions,
Err(err) => {
warn!(?err, "contribute bootstrap passphrase failed");
return Err(Status::internal("Failed to contribute bootstrap passphrase"));
}
};
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) => {
let proto_result = match result {
Ok(true) => ProtoUnsealResult::Success,
Ok(false) => ProtoUnsealResult::AwaitingContributions,
Err(err) => {
warn!(?err, "contribute unseal passphrase failed");
return Err(Status::internal("Failed to contribute unseal passphrase"));
}
};
Ok(wrap_unseal_response(UnsealResponsePayload::Result(
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(),
)))
}
} }
} }
} }

View File

@@ -8,7 +8,7 @@ use crate::{
crypto::integrity::{self, AttestationStatus}, crypto::integrity::{self, AttestationStatus},
db::{ db::{
self, self,
models::{ProgramClientMetadata, SqliteTimestamp}, models::ProgramClientMetadata,
schema::program_client, schema::program_client,
}, },
}; };
@@ -18,14 +18,13 @@ use arbiter_proto::{
transport::{Bi, expect_message}, transport::{Bi, expect_message},
}; };
use chrono::Utc;
use diesel::{ use diesel::{
ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, SelectableHelper as _, ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, SelectableHelper as _,
dsl::insert_into, update, dsl::insert_into,
}; };
use diesel_async::RunQueryDsl as _; use diesel_async::RunQueryDsl as _;
use kameo::{actor::ActorRef, error::SendError}; use kameo::{actor::ActorRef, error::SendError};
use tracing::error; use tracing::{error, warn};
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum Error { pub enum Error {
@@ -211,71 +210,47 @@ async fn insert_client(
.await .await
} }
async fn sync_client_metadata( /// Compares stored metadata against what a reconnecting client presents.
/// Metadata is frozen after initial operator approval and must not be silently
/// overwritten. Doing so would let an approved client forge its displayed
/// identity in later approval prompts. Drift is logged and ignored.
async fn check_metadata_drift(
db: &db::DatabasePool, db: &db::DatabasePool,
client_id: i32, client_id: i32,
metadata: &ClientMetadata, presented: &ClientMetadata,
) -> Result<(), Error> { ) -> Result<(), Error> {
use crate::db::schema::{client_metadata, client_metadata_history}; use crate::db::schema::client_metadata;
let now = SqliteTimestamp(Utc::now());
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable Error::DatabasePoolUnavailable
})?; })?;
conn.exclusive_transaction(async |conn| { let current: ProgramClientMetadata = program_client::table
let (current_metadata_id, current): (i32, ProgramClientMetadata) = program_client::table .find(client_id)
.find(client_id) .inner_join(client_metadata::table)
.inner_join(client_metadata::table) .select(ProgramClientMetadata::as_select())
.select(( .first(&mut conn)
program_client::metadata_id, .await
ProgramClientMetadata::as_select(), .map_err(|e| {
)) error!(error = ?e, "Database error");
.first(&mut *conn) Error::DatabaseOperationFailed
.await?; })?;
let unchanged = current.name == metadata.name let changed = current.name != presented.name
&& current.description == metadata.description || current.description != presented.description
&& current.version == metadata.version; || current.version != presented.version;
if unchanged {
return Ok(());
}
insert_into(client_metadata_history::table) if changed {
.values(( warn!(
client_metadata_history::metadata_id.eq(current_metadata_id), client_id,
client_metadata_history::client_id.eq(client_id), stored_name = %current.name,
)) presented_name = %presented.name,
.execute(&mut *conn) "reconnecting client presented different metadata; ignoring - metadata is frozen after operator approval"
.await?; );
}
let metadata_id = insert_into(client_metadata::table) Ok(())
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(&mut *conn)
.await?;
update(program_client::table.find(client_id))
.set((
program_client::metadata_id.eq(metadata_id),
program_client::updated_at.eq(now),
))
.execute(&mut *conn)
.await?;
Ok::<(), diesel::result::Error>(())
})
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})
} }
async fn challenge_client<T>( async fn challenge_client<T>(
@@ -324,6 +299,7 @@ where
let client_id = if let Some(id) = get_client_id(&props.db, &pubkey).await? { let client_id = if let Some(id) = get_client_id(&props.db, &pubkey).await? {
verify_integrity(&props.db, &props.actors.vault, &pubkey).await?; verify_integrity(&props.db, &props.actors.vault, &pubkey).await?;
check_metadata_drift(&props.db, id, &metadata).await?;
id id
} else { } else {
approve_new_client( approve_new_client(
@@ -337,8 +313,6 @@ where
insert_client(&props.db, &props.actors.vault, &pubkey, &metadata).await? insert_client(&props.db, &props.actors.vault, &pubkey, &metadata).await?
}; };
sync_client_metadata(&props.db, client_id, &metadata).await?;
let challenge = AuthChallenge::generate(&mut rand::rng()); let challenge = AuthChallenge::generate(&mut rand::rng());
challenge_client(transport, pubkey, challenge).await?; challenge_client(transport, pubkey, challenge).await?;

View File

@@ -83,7 +83,7 @@ impl Actor for ClientSession {
args.props args.props
.actors .actors
.flow_coordinator .flow_coordinator
.ask(RegisterClient { actor: this }) .ask(RegisterClient { client_id: args.client_id, actor: this })
.await .await
.map_err(|_| Error::ConnectionRegistrationFailed)?; .map_err(|_| Error::ConnectionRegistrationFailed)?;
Ok(args) Ok(args)

View File

@@ -14,7 +14,7 @@ mod state;
pub enum Inbound { pub enum Inbound {
AuthChallengeRequest { AuthChallengeRequest {
pubkey: authn::PublicKey, pubkey: authn::PublicKey,
bootstrap_token: Option<String>, bootstrap_token: Option<Vec<u8>>,
}, },
AuthChallengeSolution { AuthChallengeSolution {
signature: Vec<u8>, signature: Vec<u8>,

View File

@@ -4,7 +4,7 @@ use super::{
}; };
use crate::{ use crate::{
actors::bootstrap::ConsumeToken, actors::bootstrap::ConsumeToken,
db::{DatabasePool, schema::operator_identity}, db::{DatabasePool, schema::operator_client},
peers::operator::auth::Outbound, peers::operator::auth::Outbound,
}; };
use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT}; use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT};
@@ -14,19 +14,18 @@ use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use tracing::error; use tracing::error;
pub(crate) struct ChallengeRequest { pub(super) struct ChallengeRequest {
pub(crate) pubkey: authn::PublicKey, pub(super) pubkey: authn::PublicKey,
pub(crate) bootstrap_token: Option<String>, pub(super) bootstrap_token: Option<Vec<u8>>,
} }
pub struct ChallengeContext { pub struct ChallengeContext {
pub challenge: AuthChallenge, pub(super) challenge: AuthChallenge,
pub pubkey: authn::PublicKey, pub(super) pubkey: authn::PublicKey,
pub bootstrap_token: Option<String>,
} }
pub(crate) struct ChallengeSolution { pub(super) struct ChallengeSolution {
pub(crate) solution: Vec<u8>, pub(super) solution: Vec<u8>,
} }
smlang::statemachine!( smlang::statemachine!(
@@ -44,9 +43,9 @@ async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<O
Error::internal("Database unavailable") Error::internal("Database unavailable")
})?; })?;
operator_identity::table operator_client::table
.filter(operator_identity::public_key.eq(pubkey.to_bytes())) .filter(operator_client::public_key.eq(pubkey.to_bytes()))
.select(operator_identity::id) .select(operator_client::id)
.first::<i32>(&mut conn) .first::<i32>(&mut conn)
.await .await
.optional() .optional()
@@ -63,9 +62,9 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i3
Error::internal("Database unavailable") Error::internal("Database unavailable")
})?; })?;
let id: i32 = diesel::insert_into(operator_identity::table) let id: i32 = diesel::insert_into(operator_client::table)
.values((operator_identity::public_key.eq(pubkey_bytes),)) .values((operator_client::public_key.eq(pubkey_bytes),))
.returning(operator_identity::id) .returning(operator_client::id)
.get_result(&mut conn) .get_result(&mut conn)
.await .await
.map_err(|e| { .map_err(|e| {
@@ -79,11 +78,16 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i3
pub(super) struct AuthContext<'a, T: ?Sized> { pub(super) struct AuthContext<'a, T: ?Sized> {
pub(super) conn: &'a mut OperatorConnection, pub(super) conn: &'a mut OperatorConnection,
pub(super) transport: &'a mut T, pub(super) transport: &'a mut T,
bootstrap_token: Option<Vec<u8>>,
} }
impl<'a, T: ?Sized> AuthContext<'a, T> { impl<'a, T: ?Sized> AuthContext<'a, T> {
pub(super) const fn new(conn: &'a mut OperatorConnection, transport: &'a mut T) -> Self { pub(super) const fn new(conn: &'a mut OperatorConnection, transport: &'a mut T) -> Self {
Self { conn, transport } Self {
conn,
transport,
bootstrap_token: None,
}
} }
} }
@@ -108,6 +112,8 @@ where
} }
} }
self.bootstrap_token = bootstrap_token;
let challenge = AuthChallenge::generate(&mut rand::rng()); let challenge = AuthChallenge::generate(&mut rand::rng());
self.transport self.transport
@@ -120,20 +126,12 @@ where
Error::Transport Error::Transport
})?; })?;
Ok(ChallengeContext { Ok(ChallengeContext { challenge, pubkey })
challenge,
pubkey,
bootstrap_token,
})
} }
async fn verify_solution( async fn verify_solution(
&mut self, &mut self,
ChallengeContext { ChallengeContext { challenge, pubkey }: &ChallengeContext,
challenge,
pubkey,
bootstrap_token,
}: &ChallengeContext,
ChallengeSolution { solution }: ChallengeSolution, ChallengeSolution { solution }: ChallengeSolution,
) -> Result<Credentials, Self::Error> { ) -> Result<Credentials, Self::Error> {
let signature = authn::Signature::try_from(solution.as_slice()).map_err(|()| { let signature = authn::Signature::try_from(solution.as_slice()).map_err(|()| {
@@ -152,15 +150,13 @@ where
} }
// Resolve client id: bootstrap (consume token + register) or lookup // Resolve client id: bootstrap (consume token + register) or lookup
let id = match bootstrap_token { let id = match self.bootstrap_token.take() {
Some(token) => { Some(token) => {
let token_ok: bool = self let token_ok: bool = self
.conn .conn
.actors .actors
.bootstrapper .bootstrapper
.ask(ConsumeToken { .ask(ConsumeToken { token })
token: token.clone(),
})
.await .await
.map_err(|e| { .map_err(|e| {
error!(?e, "Failed to consume bootstrap token"); error!(?e, "Failed to consume bootstrap token");

View File

@@ -180,7 +180,6 @@ where
Ok(OperatorSession::spawn(OperatorSession::new( Ok(OperatorSession::spawn(OperatorSession::new(
props.clone(), props.clone(),
creds.clone(),
oob_sender, oob_sender,
))) )))
} }

View File

@@ -1,15 +1,14 @@
use super::{Error, OperatorSession}; use super::{Error, OperatorSession};
use crate::{ use crate::{
actors::{ actors::evm::{
evm::{ ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorListGrants,
ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorDeleteGrant, SignTransactionError as EvmSignError,
OperatorListGrants, SignTransactionError as EvmSignError,
},
flow_coordinator::client_connect_approval::ClientApprovalAnswer,
vault::VaultState,
}, },
db::models::{ actors::flow_coordinator::{IsClientConnected, client_connect_approval::ClientApprovalAnswer},
EvmWalletAccess, EvmWalletId, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata, actors::vault::VaultState,
db::{
models::{EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata},
schema::program_client,
}, },
evm::policies::{Grant, SpecificGrant}, evm::policies::{Grant, SpecificGrant},
}; };
@@ -19,13 +18,16 @@ use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper}; use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{error::SendError, messages, prelude::Context}; use kameo::{error::SendError, messages, prelude::Context};
use tracing::error; use tracing::{error, info, warn};
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum SignTransactionError { pub enum SignTransactionError {
#[error("Policy evaluation failed")] #[error("Policy evaluation failed")]
Vet(#[from] crate::evm::VetError), Vet(#[from] crate::evm::VetError),
#[error("Client not connected")]
ClientNotConnected,
#[error("Internal signing error")] #[error("Internal signing error")]
Internal, Internal,
} }
@@ -74,9 +76,7 @@ impl OperatorSession {
} }
#[message] #[message]
pub(crate) async fn handle_evm_wallet_list( pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<(i32, Address)>, Error> {
&mut self,
) -> Result<Vec<(EvmWalletId, Address)>, Error> {
match self.props.actors.evm.ask(ListWallets {}).await { match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => Ok(wallets), Ok(wallets) => Ok(wallets),
Err(err) => { Err(err) => {
@@ -122,23 +122,22 @@ impl OperatorSession {
} }
#[message] #[message]
pub(crate) async fn handle_grant_delete( pub(crate) fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), GrantMutationError> {
&mut self, // match self
grant_id: i32, // .props
) -> Result<(), GrantMutationError> { // .actors
match self // .evm
.props // .ask(OperatorDeleteGrant { grant_id })
.actors // .await
.evm // {
.ask(OperatorDeleteGrant { grant_id }) // Ok(()) => Ok(()),
.await // Err(err) => {
{ // error!(?err, "EVM grant delete failed");
Ok(()) => Ok(()), // Err(GrantMutationError::Internal)
Err(err) => { // }
error!(?err, "EVM grant delete failed"); // }
Err(GrantMutationError::Internal) let _ = grant_id;
} todo!()
}
} }
#[message] #[message]
@@ -148,6 +147,30 @@ impl OperatorSession {
wallet_address: Address, wallet_address: Address,
transaction: TxEip1559, transaction: TxEip1559,
) -> Result<Signature, SignTransactionError> { ) -> Result<Signature, SignTransactionError> {
if !self.approved_client_ids.contains(&client_id) {
warn!(
client_id,
"operator attempted to sign for client not in its approved set"
);
return Err(SignTransactionError::ClientNotConnected);
}
let connected = self
.props
.actors
.flow_coordinator
.ask(IsClientConnected { client_id })
.await
.unwrap_or(false);
if !connected {
self.approved_client_ids.remove(&client_id);
warn!(client_id, "operator attempted to sign for disconnected client");
return Err(SignTransactionError::ClientNotConnected);
}
info!(client_id, event = "sign_transaction", "operator.sign_transaction");
match self match self
.props .props
.actors .actors
@@ -203,7 +226,7 @@ impl OperatorSession {
use crate::db::schema::evm_wallet_access; use crate::db::schema::evm_wallet_access;
for entry in entries { for entry in entries {
diesel::delete(evm_wallet_access::table) diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::wallet_id.eq(entry)) .filter(evm_wallet_access::id.eq(entry))
.execute(&mut *conn) .execute(&mut *conn)
.await?; .await?;
} }
@@ -218,9 +241,8 @@ impl OperatorSession {
pub(crate) async fn handle_list_wallet_access( pub(crate) async fn handle_list_wallet_access(
&mut self, &mut self,
) -> Result<Vec<EvmWalletAccess>, Error> { ) -> Result<Vec<EvmWalletAccess>, Error> {
use crate::db::schema::evm_wallet_access;
let mut conn = self.props.db.get().await?; let mut conn = self.props.db.get().await?;
let access_entries = evm_wallet_access::table let access_entries = crate::db::schema::evm_wallet_access::table
.select(EvmWalletAccess::as_select()) .select(EvmWalletAccess::as_select())
.load::<_>(&mut conn) .load::<_>(&mut conn)
.await?; .await?;
@@ -257,6 +279,30 @@ impl OperatorSession {
ctx.actor_ref().unlink(&pending_approval.controller).await; ctx.actor_ref().unlink(&pending_approval.controller).await;
if approved {
let pubkey_bytes = pending_approval.pubkey.to_bytes();
match self.props.db.get().await {
Ok(mut conn) => {
match program_client::table
.filter(program_client::public_key.eq(pubkey_bytes.as_slice()))
.select(program_client::id)
.first::<i32>(&mut conn)
.await
{
Ok(client_id) => {
self.approved_client_ids.insert(client_id);
}
Err(err) => {
error!(?err, "Failed to look up client_id for approved pubkey");
}
}
}
Err(err) => {
error!(?err, "DB pool error after client approval");
}
}
}
Ok(()) Ok(())
} }
@@ -280,101 +326,141 @@ impl OperatorSession {
} }
} }
#[messages] #[cfg(test)]
impl OperatorSession { mod tests {
#[message] use crate::db::{self, models::NewEvmWalletAccess, schema::evm_wallet_access};
pub(crate) async fn handle_create_proposal( use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
&mut self, use diesel_async::{AsyncConnection, RunQueryDsl};
kind: crate::actors::proposal_manager::ProposalKind,
ttl_secs: Option<i64>, /// Regression test: revocation must delete by access-entry `id`, not by `wallet_id`.
) -> Result<i32, Error> { ///
use crate::actors::proposal_manager::CreateProposal; /// Before the fix, revoking `entry_id=1` would delete all rows where `wallet_id=1`,
let initiator_id = self.credentials.id; /// wiping out every client's access to wallet #1.
self.props #[tokio::test]
.actors async fn revoke_deletes_by_entry_id_not_wallet_id() {
.proposal_manager use crate::db::models::EvmWalletAccess;
.ask(CreateProposal { kind, initiator_id, ttl_secs })
.await let pool = db::create_test_pool().await;
.map_err(|e| { let mut conn = pool.get().await.expect("pool connection");
error!(?e, "create_proposal failed");
Error::internal("Failed to create proposal") // Insert two access entries for the same wallet but different clients.
// entry A: id will be 1, wallet_id=1, client_id=10
// entry B: id will be 2, wallet_id=1, client_id=20
let entry_a = diesel::insert_into(evm_wallet_access::table)
.values(NewEvmWalletAccess {
wallet_id: 1,
client_id: 10,
}) })
.returning(EvmWalletAccess::as_select())
.get_result(&mut *conn)
.await
.expect("insert entry A");
let entry_b = diesel::insert_into(evm_wallet_access::table)
.values(NewEvmWalletAccess {
wallet_id: 1,
client_id: 20,
})
.returning(EvmWalletAccess::as_select())
.get_result(&mut *conn)
.await
.expect("insert entry B");
// Revoke only entry A by its primary key id.
conn.transaction(async |conn| {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::id.eq(entry_a.id))
.execute(&mut *conn)
.await
})
.await
.expect("revoke entry A");
// Entry A must be gone.
let gone = evm_wallet_access::table
.filter(evm_wallet_access::id.eq(entry_a.id))
.count()
.get_result::<i64>(&mut *conn)
.await
.expect("count entry A");
assert_eq!(gone, 0, "revoked entry must be deleted");
// Entry B (same wallet, different client) must still exist.
let still_there = evm_wallet_access::table
.filter(evm_wallet_access::id.eq(entry_b.id))
.count()
.get_result::<i64>(&mut *conn)
.await
.expect("count entry B");
assert_eq!(still_there, 1, "unrelated entry must not be deleted");
} }
#[message] /// Regression test: when `entry_id` and `wallet_id` differ, only the correct row is removed.
pub(crate) async fn handle_cast_vote( ///
&mut self, /// This specifically catches the case where `entry.id=5` and `wallet_id=1` are different values;
proposal_id: i32, /// the old bug would delete by `wallet_id`, potentially matching a completely different entry.
approve: bool, #[tokio::test]
signature: Vec<u8>, async fn revoke_with_mismatched_wallet_and_entry_ids() {
) -> Result<crate::actors::proposal_manager::VoteOutcome, crate::actors::proposal_manager::Error> { use crate::db::models::EvmWalletAccess;
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] let pool = db::create_test_pool().await;
pub(crate) async fn handle_query_pending( let mut conn = pool.get().await.expect("pool connection");
&mut self,
) -> Vec<crate::actors::proposal_manager::ProposalSummary> { // Insert entries to force auto-increment IDs to diverge from wallet_ids.
use crate::actors::proposal_manager::QueryPending; // We'll insert 5 placeholder entries first so that the real entry gets id=6.
let operator_id = self.credentials.id; for i in 1_i32..=5 {
self.props diesel::insert_into(evm_wallet_access::table)
.actors .values(NewEvmWalletAccess {
.proposal_manager wallet_id: 99,
.ask(QueryPending { operator_id }) client_id: i,
})
.execute(&mut *conn)
.await
.expect("insert placeholder");
}
// Real target: wallet_id=1, will get id=6.
let target = diesel::insert_into(evm_wallet_access::table)
.values(NewEvmWalletAccess {
wallet_id: 1,
client_id: 1,
})
.returning(EvmWalletAccess::as_select())
.get_result(&mut *conn)
.await .await
.unwrap_or_default() .expect("insert target");
}
} // Sanity: target.id != target.wallet_id
assert_ne!(
#[messages] target.id, target.wallet_id,
impl OperatorSession { "test prerequisite: id and wallet_id must differ"
#[message] );
pub(crate) async fn handle_contribute_rekey_passphrase(
&mut self, // Revoke by entry id.
passphrase: Vec<u8>, conn.transaction(async |conn| {
) -> Result<bool, Error> { diesel::delete(evm_wallet_access::table)
use crate::actors::vault_coordinator::ContributeRekey; .filter(evm_wallet_access::id.eq(target.id))
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; .execute(&mut *conn)
.await
let operator_id = self.credentials.id; })
self.props .await
.actors .expect("revoke target");
.vault_coordinator
.ask(ContributeRekey { let remaining = evm_wallet_access::table
operator_id, .filter(evm_wallet_access::id.eq(target.id))
passphrase: SafeCell::new(passphrase), .count()
}) .get_result::<i64>(&mut *conn)
.await .await
.map_err(|_| Error::internal("VaultCoordinator unavailable")) .expect("count target");
} assert_eq!(remaining, 0, "target must be deleted by its entry id");
#[message] // Placeholders for wallet_id=99 must be untouched.
pub(crate) async fn handle_contribute_recovery_rekey_passphrase( let placeholders = evm_wallet_access::table
&mut self, .filter(evm_wallet_access::wallet_id.eq(99))
recovery_operator_id: i32, .count()
passphrase: Vec<u8>, .get_result::<i64>(&mut *conn)
) -> Result<bool, Error> { .await
use crate::actors::vault_coordinator::ContributeRecoveryRekey; .expect("count placeholders");
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; assert_eq!(placeholders, 5, "unrelated entries must survive");
self.props
.actors
.vault_coordinator
.ask(ContributeRecoveryRekey {
recovery_operator_id,
passphrase: SafeCell::new(passphrase),
})
.await
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
} }
} }

View File

@@ -1,16 +1,14 @@
use super::{Credentials, OutOfBand, OperatorConnection}; use super::{OutOfBand, OperatorConnection};
use crate::{ use crate::{
actors::{ actors::{
flow_coordinator::client_connect_approval::ClientApprovalController, flow_coordinator::{GetConnectedClientIds, client_connect_approval::{ClientApprovalAnswer, ClientApprovalController}}, operator_registry::ConnectOperator,
operator_registry::ConnectOperator, }, peers::client::ClientProfile,
},
peers::client::ClientProfile,
}; };
use arbiter_crypto::authn; use arbiter_crypto::authn;
use arbiter_proto::transport::Sender; use arbiter_proto::transport::Sender;
use kameo::{Actor, actor::ActorRef, messages}; use kameo::{Actor, actor::ActorRef, messages};
use std::{borrow::Cow, collections::HashMap}; use std::{borrow::Cow, collections::{HashMap, HashSet}};
use thiserror::Error; use thiserror::Error;
use tracing::error; use tracing::error;
@@ -51,21 +49,24 @@ 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>,
/// DB `client_ids` this operator session is allowed to sign for.
/// Seeded from currently-connected clients on start, then updated as
/// approvals are granted or denied during the session lifetime.
approved_client_ids: HashSet<i32>,
} }
pub mod handlers; pub mod handlers;
impl OperatorSession { impl OperatorSession {
pub(crate) fn new(props: OperatorConnection, credentials: Credentials, sender: Box<dyn Sender<OutOfBand>>) -> Self { pub(crate) fn new(props: OperatorConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self {
Self { Self {
props, props,
credentials,
sender, sender,
pending_client_approvals: HashMap::default(), pending_client_approvals: HashMap::default(),
approved_client_ids: HashSet::default(),
} }
} }
} }
@@ -90,6 +91,7 @@ impl OperatorSession {
actor = "operator", actor = "operator",
event = "failed to announce new client connection" event = "failed to announce new client connection"
); );
let _ = controller.tell(ClientApprovalAnswer { approved: false }).await;
return; return;
} }
@@ -108,7 +110,7 @@ impl Actor for OperatorSession {
type Error = Error; type Error = Error;
async fn on_start(args: Self::Args, this: ActorRef<Self>) -> Result<Self, Self::Error> { async fn on_start(mut args: Self::Args, this: ActorRef<Self>) -> Result<Self, Self::Error> {
args.props args.props
.actors .actors
.operator_registry .operator_registry
@@ -123,6 +125,16 @@ impl Actor for OperatorSession {
); );
Error::internal("Failed to register operator connection with operator registry") Error::internal("Failed to register operator connection with operator registry")
})?; })?;
// Seed approved set with clients already connected when this session starts.
// New clients will be added via handle_new_client_approve as they are approved.
match args.props.actors.flow_coordinator.ask(GetConnectedClientIds {}).await {
Ok(ids) => args.approved_client_ids.extend(ids),
Err(err) => {
error!(?err, "Failed to fetch connected client IDs on operator session start");
}
}
Ok(args) Ok(args)
} }

View File

@@ -3,12 +3,8 @@ use crate::{
actors::{ actors::{
GlobalActors, GlobalActors,
vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events}, vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events},
vault_coordinator::{
ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal,
ContributeUnseal, StartBootstrap,
},
}, },
crypto::{KeyCell, integrity::{self}}, crypto::integrity::{self},
db::DatabasePool, db::DatabasePool,
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
@@ -21,9 +17,6 @@ use tokio::sync::oneshot;
use tracing::{error, info}; use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret}; use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret};
pub use VaultGateMessage as Inbound;
pub use VaultGateMessageReply as Outbound;
pub mod state; pub mod state;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -32,6 +25,8 @@ pub enum Error {
AlreadyBootstrapped, AlreadyBootstrapped,
#[error("Invalid key provided")] #[error("Invalid key provided")]
InvalidKey, InvalidKey,
#[error("Vault locked: too many failed unseal attempts")]
LockedOut,
#[error("State transition failed")] #[error("State transition failed")]
State, State,
@@ -105,9 +100,11 @@ impl VaultGate {
nonce: &[u8], nonce: &[u8],
ciphertext: &[u8], ciphertext: &[u8],
associated_data: &[u8], associated_data: &[u8],
) -> Result<KeyCell, ()> { ) -> Result<SafeCell<Vec<u8>>, ()> {
let nonce = XNonce::from_slice(nonce); let nonce = XNonce::from_slice(nonce);
let cipher = XChaCha20Poly1305::new(secret.as_bytes().into()); let cipher = XChaCha20Poly1305::new(secret.as_bytes().into());
let mut key_buffer = SafeCell::new(ciphertext.to_vec()); let mut key_buffer = SafeCell::new(ciphertext.to_vec());
let decryption_result = key_buffer.write_inline(|write_handle| { let decryption_result = key_buffer.write_inline(|write_handle| {
@@ -115,9 +112,7 @@ impl VaultGate {
}); });
match decryption_result { match decryption_result {
Ok(()) => KeyCell::try_from(key_buffer).map_err(|()| { Ok(()) => Ok(key_buffer),
error!("Decrypted key material has unexpected length");
}),
Err(err) => { Err(err) => {
error!(?err, "Failed to decrypt encrypted key material"); error!(?err, "Failed to decrypt encrypted key material");
Err(()) Err(())
@@ -126,7 +121,7 @@ impl VaultGate {
} }
} }
#[messages(enum)] #[messages(messages = Inbound, replies = Outbound)]
impl VaultGate { impl VaultGate {
#[message] #[message]
pub fn handle_handshake( pub fn handle_handshake(
@@ -159,14 +154,17 @@ impl VaultGate {
return Err(Error::State); return Err(Error::State);
}; };
let Ok(seal_key) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) else { let Ok(seal_key_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
else {
return Err(Error::InvalidKey); return Err(Error::InvalidKey);
}; };
match self match self
.actors .actors
.vault .vault
.ask(TryUnseal { seal_key }) .ask(TryUnseal {
seal_key_raw: seal_key_buffer,
})
.await .await
{ {
Ok(()) => { Ok(()) => {
@@ -174,6 +172,7 @@ impl VaultGate {
Ok(()) Ok(())
} }
Err(SendError::HandlerError(vault::Error::InvalidKey)) => Err(Error::InvalidKey), Err(SendError::HandlerError(vault::Error::InvalidKey)) => Err(Error::InvalidKey),
Err(SendError::HandlerError(vault::Error::LockedOut)) => Err(Error::LockedOut),
Err(SendError::HandlerError(err)) => { Err(SendError::HandlerError(err)) => {
error!(?err, "Vault failed to unseal key"); error!(?err, "Vault failed to unseal key");
Err(Error::InvalidKey) Err(Error::InvalidKey)
@@ -196,14 +195,17 @@ impl VaultGate {
return Err(Error::State); return Err(Error::State);
}; };
let Ok(seal_key) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) else { let Ok(seal_key_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
else {
return Err(Error::InvalidKey); return Err(Error::InvalidKey);
}; };
match self match self
.actors .actors
.vault .vault
.ask(Bootstrap { seal_key }) .ask(Bootstrap {
seal_key_raw: seal_key_buffer,
})
.await .await
{ {
Ok(()) => { Ok(()) => {
@@ -235,89 +237,6 @@ impl VaultGate {
Ok(answer) Ok(answer)
} }
#[message]
pub async fn handle_declare_committee(
&mut self,
count: usize,
recovery_count: usize,
) -> Result<(), Error> {
self.actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: self.auth_creds.id,
declared_count: count,
recovery_count,
})
.await
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
}
#[message]
pub async fn handle_contribute_bootstrap_passphrase(
&mut self,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
let passphrase_cell = SafeCell::new(passphrase);
self.actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: self.auth_creds.id,
passphrase: passphrase_cell,
})
.await
.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]
pub async fn handle_contribute_unseal_passphrase(
&mut self,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
let passphrase_cell = SafeCell::new(passphrase);
self.actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: self.auth_creds.id,
passphrase: passphrase_cell,
})
.await
.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 {

View File

@@ -1,5 +1,8 @@
use super::common::ChannelTransport; use super::common::ChannelTransport;
use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT}; use arbiter_crypto::{
authn::{self, AuthChallenge, CLIENT_CONTEXT},
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, ClientMetadata,
transport::{Receiver, Sender}, transport::{Receiver, Sender},
@@ -83,8 +86,8 @@ async fn insert_bootstrap_sentinel_operator(db: &db::DatabasePool) {
.0 .0
.to_vec(); .to_vec();
insert_into(schema::operator_identity::table) insert_into(schema::operator_client::table)
.values((schema::operator_identity::public_key.eq(sentinel_key),)) .values((schema::operator_client::public_key.eq(sentinel_key),))
.execute(&mut conn) .execute(&mut conn)
.await .await
.unwrap(); .unwrap();
@@ -97,7 +100,7 @@ async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors {
actors actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
}) })
.await .await
.unwrap(); .unwrap();
@@ -263,7 +266,7 @@ pub async fn metadata_unchanged_does_not_append_history() {
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
pub async fn metadata_change_appends_history_and_repoints_binding() { pub async fn metadata_frozen_after_approval_ignores_reconnect_changes() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await; let actors = spawn_test_actors(&db).await;
let new_key = MlDsa87::key_gen(&mut rand::rng()); let new_key = MlDsa87::key_gen(&mut rand::rng());
@@ -284,6 +287,7 @@ pub async fn metadata_change_appends_history_and_repoints_binding() {
connect_client(props, &mut server_transport).await; connect_client(props, &mut server_transport).await;
}); });
// Reconnect presenting different metadata — must be silently ignored.
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(), pubkey: verifying_key(&new_key).into(),
@@ -310,6 +314,7 @@ pub async fn metadata_change_appends_history_and_repoints_binding() {
client_metadata, client_metadata_history, program_client, client_metadata, client_metadata_history, program_client,
}; };
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
// Metadata is frozen: no new row, no history entry.
let metadata_count: i64 = client_metadata::table let metadata_count: i64 = client_metadata::table
.count() .count()
.get_result(&mut conn) .get_result(&mut conn)
@@ -335,15 +340,16 @@ pub async fn metadata_change_appends_history_and_repoints_binding() {
.first::<(String, Option<String>, Option<String>)>(&mut conn) .first::<(String, Option<String>, Option<String>)>(&mut conn)
.await .await
.unwrap(); .unwrap();
assert_eq!(metadata_count, 2); assert_eq!(metadata_count, 1, "frozen: no new metadata row on reconnect");
assert_eq!(history_count, 1); assert_eq!(history_count, 0, "frozen: no history entry on reconnect");
assert_eq!( assert_eq!(
current, current,
( (
"client".to_owned(), "client".to_owned(),
Some("new".to_owned()), Some("old".to_owned()),
Some("2.0.0".to_owned()) Some("1.0.0".to_owned())
) ),
"frozen: original metadata must be preserved"
); );
} }
} }

View File

@@ -2,6 +2,7 @@
dead_code, dead_code,
reason = "Common test utilities that may not be used in every test" reason = "Common test utilities that may not be used in every test"
)] )]
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_proto::transport::{Bi, Error, Receiver, Sender}; use arbiter_proto::transport::{Bi, Error, Receiver, Sender};
use arbiter_server::{ use arbiter_server::{
actors::{GlobalActors, vault::Vault}, actors::{GlobalActors, vault::Vault},
@@ -18,7 +19,7 @@ pub(crate) async fn bootstrapped_vault(db: &db::DatabasePool) -> Vault {
.await .await
.unwrap(); .unwrap();
actor actor
.bootstrap(arbiter_server::crypto::KeyCell::from([0u8; 32])) .bootstrap(SafeCell::new(b"test-seal-key".to_vec()))
.await .await
.unwrap(); .unwrap();
actor actor

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,8 @@
use super::common::ChannelTransport; use super::common::ChannelTransport;
use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT}; use arbiter_crypto::{
authn::{self, AuthChallenge, OPERATOR_CONTEXT},
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_proto::transport::{Error as TransportError, Receiver, Sender}; use arbiter_proto::transport::{Error as TransportError, Receiver, Sender};
use arbiter_server::{ use arbiter_server::{
actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap}, actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap},
@@ -154,7 +157,7 @@ pub async fn bootstrap_token_auth() {
actors actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
}) })
.await .await
.unwrap(); .unwrap();
@@ -171,7 +174,7 @@ pub async fn bootstrap_token_auth() {
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(), pubkey: verifying_key(&new_key).into(),
bootstrap_token: Some(token), bootstrap_token: Some(token.into_bytes()),
}) })
.await .await
.unwrap(); .unwrap();
@@ -203,8 +206,8 @@ pub async fn bootstrap_token_auth() {
task.await.unwrap().unwrap(); task.await.unwrap().unwrap();
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let stored_pubkey: Vec<u8> = schema::operator_identity::table let stored_pubkey: Vec<u8> = schema::operator_client::table
.select(schema::operator_identity::public_key) .select(schema::operator_client::public_key)
.first::<Vec<u8>>(&mut conn) .first::<Vec<u8>>(&mut conn)
.await .await
.unwrap(); .unwrap();
@@ -228,7 +231,7 @@ pub async fn bootstrap_invalid_token_auth() {
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(), pubkey: verifying_key(&new_key).into(),
bootstrap_token: Some("invalid_token".to_owned()), bootstrap_token: Some(b"invalid_token".to_vec()),
}) })
.await .await
.unwrap(); .unwrap();
@@ -256,7 +259,7 @@ pub async fn bootstrap_invalid_token_auth() {
)); ));
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let count: i64 = schema::operator_identity::table let count: i64 = schema::operator_client::table
.count() .count()
.get_result::<i64>(&mut conn) .get_result::<i64>(&mut conn)
.await .await
@@ -272,7 +275,7 @@ pub async fn challenge_auth() {
actors actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
}) })
.await .await
.unwrap(); .unwrap();
@@ -282,9 +285,9 @@ pub async fn challenge_auth() {
{ {
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let id: i32 = insert_into(schema::operator_identity::table) let id: i32 = insert_into(schema::operator_client::table)
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),)) .values((schema::operator_client::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::operator_identity::id) .returning(schema::operator_client::id)
.get_result(&mut conn) .get_result(&mut conn)
.await .await
.unwrap(); .unwrap();
@@ -358,7 +361,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
actors actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
}) })
.await .await
.unwrap(); .unwrap();
@@ -368,8 +371,8 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
{ {
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
insert_into(schema::operator_identity::table) insert_into(schema::operator_client::table)
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),)) .values((schema::operator_client::public_key.eq(pubkey_bytes.clone()),))
.execute(&mut conn) .execute(&mut conn)
.await .await
.unwrap(); .unwrap();
@@ -397,7 +400,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
let challenge = match response { let challenge = match response {
Ok(resp) => match resp { Ok(resp) => match resp {
auth::Outbound::AuthChallenge { challenge } => challenge, auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"), other @ auth::Outbound::AuthSuccess => panic!("Expected AuthChallenge, got {other:?}"),
}, },
Err(err) => panic!("Expected Ok response, got Err({err:?})"), Err(err) => panic!("Expected Ok response, got Err({err:?})"),
}; };
@@ -431,7 +434,7 @@ pub async fn challenge_auth_rejects_invalid_signature() {
actors actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]), seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
}) })
.await .await
.unwrap(); .unwrap();
@@ -441,9 +444,9 @@ pub async fn challenge_auth_rejects_invalid_signature() {
{ {
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let id: i32 = insert_into(schema::operator_identity::table) let id: i32 = insert_into(schema::operator_client::table)
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),)) .values((schema::operator_client::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::operator_identity::id) .returning(schema::operator_client::id)
.get_result(&mut conn) .get_result(&mut conn)
.await .await
.unwrap(); .unwrap();

View File

@@ -1,4 +1,7 @@
use arbiter_crypto::authn; use arbiter_crypto::{
authn,
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_server::{ use arbiter_server::{
actors::{ actors::{
GlobalActors, GlobalActors,
@@ -19,7 +22,7 @@ use tokio::sync::oneshot;
use x25519_dalek::{EphemeralSecret, PublicKey}; use x25519_dalek::{EphemeralSecret, PublicKey};
async fn setup_sealed_gate( async fn setup_sealed_gate(
seal_key: &[u8; 32], seal_key: &[u8],
) -> ( ) -> (
db::DatabasePool, db::DatabasePool,
kameo::actor::ActorRef<VaultGate>, kameo::actor::ActorRef<VaultGate>,
@@ -31,7 +34,7 @@ async fn setup_sealed_gate(
actors actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key: arbiter_server::crypto::KeyCell::from(*seal_key), seal_key_raw: SafeCell::new(seal_key.to_vec()),
}) })
.await .await
.unwrap(); .unwrap();
@@ -47,7 +50,7 @@ async fn setup_sealed_gate(
async fn client_dh_encrypt( async fn client_dh_encrypt(
gate: &kameo::actor::ActorRef<VaultGate>, gate: &kameo::actor::ActorRef<VaultGate>,
key_to_send: &[u8; 32], key_to_send: &[u8],
) -> HandleUnsealEncryptedKey { ) -> HandleUnsealEncryptedKey {
let client_secret = EphemeralSecret::random(); let client_secret = EphemeralSecret::random();
let client_public = PublicKey::from(&client_secret); let client_public = PublicKey::from(&client_secret);
@@ -80,7 +83,7 @@ async fn client_dh_encrypt(
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
pub async fn unseal_success() { pub async fn unseal_success() {
let seal_key = b"test-seal-key-padded-to-32bytes!"; let seal_key = b"test-seal-key";
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
let encrypted_key = client_dh_encrypt(&gate, seal_key).await; let encrypted_key = client_dh_encrypt(&gate, seal_key).await;
@@ -92,10 +95,10 @@ pub async fn unseal_success() {
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
pub async fn unseal_wrong_seal_key() { pub async fn unseal_wrong_seal_key() {
let seal_key = b"test-seal-key-padded-to-32bytes!"; let seal_key = b"test-seal-key";
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
let encrypted_key = client_dh_encrypt(&gate, b"wrong-key-padded-to-32-bytes!!!!").await; let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await;
let response = gate.ask(encrypted_key).await; let response = gate.ask(encrypted_key).await;
assert!(matches!( assert!(matches!(
@@ -109,7 +112,7 @@ pub async fn unseal_wrong_seal_key() {
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
pub async fn unseal_corrupted_ciphertext() { pub async fn unseal_corrupted_ciphertext() {
let seal_key = b"test-seal-key-padded-to-32bytes!"; let seal_key = b"test-seal-key";
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
let client_secret = EphemeralSecret::random(); let client_secret = EphemeralSecret::random();
@@ -140,11 +143,11 @@ pub async fn unseal_corrupted_ciphertext() {
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
pub async fn unseal_retry_after_invalid_key() { pub async fn unseal_retry_after_invalid_key() {
let seal_key = b"real-seal-key-padded-to-32bytes!"; let seal_key = b"real-seal-key";
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await; let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
{ {
let encrypted_key = client_dh_encrypt(&gate, b"wrong-key-padded-to-32-bytes!!!!").await; let encrypted_key = client_dh_encrypt(&gate, b"wrong-key").await;
let response = gate.ask(encrypted_key).await; let response = gate.ask(encrypted_key).await;
assert!(matches!( assert!(matches!(

View File

@@ -14,6 +14,8 @@ use kameo::actor::{ActorRef, Spawn as _};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use tokio::task::JoinSet; use tokio::task::JoinSet;
const TEST_AAD: &[u8] = b"test-aad";
async fn write_concurrently( async fn write_concurrently(
actor: ActorRef<Vault>, actor: ActorRef<Vault>,
prefix: &'static str, prefix: &'static str,
@@ -27,6 +29,7 @@ async fn write_concurrently(
let id = actor let id = actor
.ask(CreateNew { .ask(CreateNew {
plaintext: SafeCell::new(plaintext.clone()), plaintext: SafeCell::new(plaintext.clone()),
aad: TEST_AAD.to_vec(),
}) })
.await .await
.unwrap(); .unwrap();
@@ -120,7 +123,7 @@ async fn insert_failure_does_not_create_partial_row() {
drop(conn); drop(conn);
let err = actor let err = actor
.create_new(SafeCell::new(b"should fail".to_vec())) .create_new(SafeCell::new(b"should fail".to_vec()), TEST_AAD.to_vec())
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, Error::DatabaseTransaction(_))); assert!(matches!(err, Error::DatabaseTransaction(_)));
@@ -166,12 +169,12 @@ async fn decrypt_roundtrip_after_high_concurrency() {
.await .await
.unwrap(); .unwrap();
decryptor decryptor
.try_unseal(arbiter_server::crypto::KeyCell::from([0u8; 32])) .try_unseal(SafeCell::new(b"test-seal-key".to_vec()))
.await .await
.unwrap(); .unwrap();
for (id, plaintext) in expected { for (id, plaintext) in expected {
let mut decrypted = decryptor.decrypt(id).await.unwrap(); let mut decrypted = decryptor.decrypt(id, TEST_AAD.to_vec()).await.unwrap();
assert_eq!(*decrypted.read(), plaintext); assert_eq!(*decrypted.read(), plaintext);
} }
} }

View File

@@ -3,29 +3,26 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_server::{ use arbiter_server::{
actors::{ actors::{
GlobalActors, GlobalActors,
vault::{Error, GetState, Vault, VaultState}, vault::{Error, Vault},
vault_coordinator::{
ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal,
Error as CoordinatorError, StartBootstrap, VaultCoordinator,
},
}, },
crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}}, crypto::encryption::v1::{Nonce, ROOT_KEY_TAG},
db::{self, models, schema}, db::{self, models, schema},
}; };
use diesel::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into}; use diesel::{QueryDsl, SelectableHelper};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::actor::Spawn as _;
const TEST_AAD: &[u8] = b"test-aad";
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn test_bootstrap() { async fn bootstrap() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await .await
.unwrap(); .unwrap();
let seal_key = KeyCell::from([0u8; 32]); let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.bootstrap(seal_key).await.unwrap(); actor.bootstrap(seal_key).await.unwrap();
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
@@ -44,25 +41,25 @@ async fn test_bootstrap() {
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn test_bootstrap_rejects_double() { async fn bootstrap_rejects_double() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let seal_key2 = KeyCell::from([0u8; 32]); let seal_key2 = SafeCell::new(b"test-seal-key".to_vec());
let err = actor.bootstrap(seal_key2).await.unwrap_err(); let err = actor.bootstrap(seal_key2).await.unwrap_err();
assert!(matches!(err, Error::AlreadyBootstrapped)); assert!(matches!(err, Error::AlreadyBootstrapped));
} }
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn test_create_new_before_bootstrap_fails() { async fn create_new_before_bootstrap_fails() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = Vault::new(db, GlobalActors::spawn_message_bus()) let mut actor = Vault::new(db, GlobalActors::spawn_message_bus())
.await .await
.unwrap(); .unwrap();
let err = actor let err = actor
.create_new(SafeCell::new(b"data".to_vec())) .create_new(SafeCell::new(b"data".to_vec()), TEST_AAD.to_vec())
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, Error::NotBootstrapped)); assert!(matches!(err, Error::NotBootstrapped));
@@ -70,19 +67,19 @@ async fn test_create_new_before_bootstrap_fails() {
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn test_decrypt_before_bootstrap_fails() { async fn decrypt_before_bootstrap_fails() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = Vault::new(db, GlobalActors::spawn_message_bus()) let mut actor = Vault::new(db, GlobalActors::spawn_message_bus())
.await .await
.unwrap(); .unwrap();
let err = actor.decrypt(1).await.unwrap_err(); let err = actor.decrypt(1, TEST_AAD.to_vec()).await.unwrap_err();
assert!(matches!(err, Error::NotBootstrapped)); assert!(matches!(err, Error::NotBootstrapped));
} }
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn test_new_restores_sealed_state() { async fn new_restores_sealed_state() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actor = common::bootstrapped_vault(&db).await; let actor = common::bootstrapped_vault(&db).await;
drop(actor); drop(actor);
@@ -90,19 +87,19 @@ async fn test_new_restores_sealed_state() {
let mut actor2 = Vault::new(db, GlobalActors::spawn_message_bus()) let mut actor2 = Vault::new(db, GlobalActors::spawn_message_bus())
.await .await
.unwrap(); .unwrap();
let err = actor2.decrypt(1).await.unwrap_err(); let err = actor2.decrypt(1, TEST_AAD.to_vec()).await.unwrap_err();
assert!(matches!(err, Error::Sealed)); assert!(matches!(err, Error::Sealed));
} }
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn test_unseal_correct_password() { async fn unseal_correct_password() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let plaintext = b"survive a restart"; let plaintext = b"survive a restart";
let aead_id = actor let aead_id = actor
.create_new(SafeCell::new(plaintext.to_vec())) .create_new(SafeCell::new(plaintext.to_vec()), TEST_AAD.to_vec())
.await .await
.unwrap(); .unwrap();
drop(actor); drop(actor);
@@ -110,22 +107,22 @@ async fn test_unseal_correct_password() {
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await .await
.unwrap(); .unwrap();
let seal_key = KeyCell::from([0u8; 32]); let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.try_unseal(seal_key).await.unwrap(); actor.try_unseal(seal_key).await.unwrap();
let mut decrypted = actor.decrypt(aead_id).await.unwrap(); let mut decrypted = actor.decrypt(aead_id, TEST_AAD.to_vec()).await.unwrap();
assert_eq!(*decrypted.read(), plaintext); assert_eq!(*decrypted.read(), plaintext);
} }
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn test_unseal_wrong_then_correct_password() { async fn unseal_wrong_then_correct_password() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let plaintext = b"important data"; let plaintext = b"important data";
let aead_id = actor let aead_id = actor
.create_new(SafeCell::new(plaintext.to_vec())) .create_new(SafeCell::new(plaintext.to_vec()), TEST_AAD.to_vec())
.await .await
.unwrap(); .unwrap();
drop(actor); drop(actor);
@@ -134,137 +131,13 @@ async fn test_unseal_wrong_then_correct_password() {
.await .await
.unwrap(); .unwrap();
let bad_key = KeyCell::from([1u8; 32]); let bad_key = SafeCell::new(b"wrong-password".to_vec());
let err = actor.try_unseal(bad_key).await.unwrap_err(); let err = actor.try_unseal(bad_key).await.unwrap_err();
assert!(matches!(err, Error::InvalidKey)); assert!(matches!(err, Error::InvalidKey));
let good_key = KeyCell::from([0u8; 32]); let good_key = SafeCell::new(b"test-seal-key".to_vec());
actor.try_unseal(good_key).await.unwrap(); actor.try_unseal(good_key).await.unwrap();
let mut decrypted = actor.decrypt(aead_id).await.unwrap(); let mut decrypted = actor.decrypt(aead_id, TEST_AAD.to_vec()).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);
}

View File

@@ -10,45 +10,47 @@ use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper, dsl::update};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use std::collections::HashSet; use std::collections::HashSet;
const TEST_AAD: &[u8] = b"test-aad";
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn test_create_decrypt_roundtrip() { async fn create_decrypt_roundtrip() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let plaintext = b"hello arbiter"; let plaintext = b"hello arbiter";
let aead_id = actor let aead_id = actor
.create_new(SafeCell::new(plaintext.to_vec())) .create_new(SafeCell::new(plaintext.to_vec()), TEST_AAD.to_vec())
.await .await
.unwrap(); .unwrap();
let mut decrypted = actor.decrypt(aead_id).await.unwrap(); let mut decrypted = actor.decrypt(aead_id, TEST_AAD.to_vec()).await.unwrap();
assert_eq!(*decrypted.read(), plaintext); assert_eq!(*decrypted.read(), plaintext);
} }
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn test_decrypt_nonexistent_returns_not_found() { async fn decrypt_nonexistent_returns_not_found() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let err = actor.decrypt(9999).await.unwrap_err(); let err = actor.decrypt(9999, TEST_AAD.to_vec()).await.unwrap_err();
assert!(matches!(err, Error::NotFound)); assert!(matches!(err, Error::NotFound));
} }
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn test_ciphertext_differs_across_entries() { async fn ciphertext_differs_across_entries() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let plaintext = b"same content"; let plaintext = b"same content";
let id1 = actor let id1 = actor
.create_new(SafeCell::new(plaintext.to_vec())) .create_new(SafeCell::new(plaintext.to_vec()), TEST_AAD.to_vec())
.await .await
.unwrap(); .unwrap();
let id2 = actor let id2 = actor
.create_new(SafeCell::new(plaintext.to_vec())) .create_new(SafeCell::new(plaintext.to_vec()), TEST_AAD.to_vec())
.await .await
.unwrap(); .unwrap();
@@ -68,22 +70,22 @@ async fn test_ciphertext_differs_across_entries() {
assert_ne!(row1.ciphertext, row2.ciphertext); assert_ne!(row1.ciphertext, row2.ciphertext);
let mut d1 = actor.decrypt(id1).await.unwrap(); let mut d1 = actor.decrypt(id1, TEST_AAD.to_vec()).await.unwrap();
let mut d2 = actor.decrypt(id2).await.unwrap(); let mut d2 = actor.decrypt(id2, TEST_AAD.to_vec()).await.unwrap();
assert_eq!(*d1.read(), plaintext); assert_eq!(*d1.read(), plaintext);
assert_eq!(*d2.read(), plaintext); assert_eq!(*d2.read(), plaintext);
} }
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn test_nonce_never_reused() { async fn nonce_never_reused() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let n = 5; let n = 5;
for i in 0..n { for i in 0..n {
actor actor
.create_new(SafeCell::new(format!("secret {i}").into_bytes())) .create_new(SafeCell::new(format!("secret {i}").into_bytes()), TEST_AAD.to_vec())
.await .await
.unwrap(); .unwrap();
} }
@@ -137,7 +139,7 @@ async fn broken_db_nonce_format_fails_closed() {
drop(conn); drop(conn);
let err = actor let err = actor
.create_new(SafeCell::new(b"must fail".to_vec())) .create_new(SafeCell::new(b"must fail".to_vec()), TEST_AAD.to_vec())
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, Error::BrokenDatabase)); assert!(matches!(err, Error::BrokenDatabase));
@@ -145,7 +147,7 @@ async fn broken_db_nonce_format_fails_closed() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await; let mut actor = common::bootstrapped_vault(&db).await;
let id = actor let id = actor
.create_new(SafeCell::new(b"decrypt target".to_vec())) .create_new(SafeCell::new(b"decrypt target".to_vec()), TEST_AAD.to_vec())
.await .await
.unwrap(); .unwrap();
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
@@ -156,6 +158,6 @@ async fn broken_db_nonce_format_fails_closed() {
.unwrap(); .unwrap();
drop(conn); drop(conn);
let err = actor.decrypt(id).await.unwrap_err(); let err = actor.decrypt(id, TEST_AAD.to_vec()).await.unwrap_err();
assert!(matches!(err, Error::BrokenDatabase)); assert!(matches!(err, Error::BrokenDatabase));
} }