Compare commits

..

107 Commits

Author SHA1 Message Date
CleverWild
438bfd4ca1 chore(deps): bump kameo version 2026-08-25 14:46:32 +02:00
CleverWild
cfd0d5bbe6 feat(vault)!: implement full Shamir re-key flow and governance execution (§3.3–§3.5)
- Add `rekey.proto` with `ContributePassphrase` / `ContributeRecoveryPassphrase` / `RekeyResult`
- Wire `rekey` as a 4th vault stream payload in `vault.proto` and gRPC dispatch
- Add `RekeyRootKey` message to `Vault` actor: generates new random seal key, re-encrypts root key, writes new `root_key_history` row
- Add `StartRekey`, `ContributeRekey`, `ContributeRecoveryRekey` messages to `VaultCoordinator`; `finalize_rekey` uses threshold-1 fast path identical to bootstrap
- `execute_replace_operator` now UPDATEs `operator_identity.public_key` in-place (avoids FK constraint violation), deletes stale `operator` share row, then triggers `StartRekey`
- `execute_update_shamir_parameters` triggers `StartRekey` instead of warning stub
- `ProposalKind::ReplaceOperator` carries `old_operator_id`; encode/decode updated accordingly
- `GlobalActors::spawn` extracts `vault_coordinator` before `Ok(Self { … })` so it can be cloned into `ProposalManager::new`
- Add `handle_rekey` in session handlers forwarding passphrase contributions to `VaultCoordinator`
- Fix test: rename `replace_operator_inserts_identity_row` → `replace_operator_updates_pubkey_and_starts_rekey`, assert count stays 1 and pubkey is updated
2026-08-25 14:46:32 +02:00
CleverWild
2378690329 refactor(proposal): replace string kind dispatch with ProposalKindTag enum (strum) 2026-08-25 14:46:32 +02:00
CleverWild
8d940daf90 feat(vault): add recovery passphrase handling for bootstrap and unseal processes 2026-08-25 14:46:32 +02:00
CleverWild
547cb9325b fix(crypto): handle 1-of-N Shamir split when ordinary_count=1 2026-08-25 14:46:32 +02:00
CleverWild
048343cc94 feat(server): recovery operators with sleeping/wakeup mechanism (§3.5/§3.6) 2026-08-25 14:46:32 +02:00
CleverWild
95c5a54530 feat(server): key-rotation proposals require full quorum (§3.3) 2026-08-25 14:46:32 +02:00
CleverWild
d2de5bcd37 feat(server): two-operator vault requires at least one recovery share 2026-08-25 14:46:32 +02:00
CleverWild
a31ef6389b refactor(server): typed pubkey len via u32::try_from in ReplaceOperator 2026-08-25 14:46:32 +02:00
CleverWild
039a20225a feat(server): ProposalKind::ApproveOneOffTransaction 2026-08-25 14:46:32 +02:00
CleverWild
1d86820afe feat(server): ProposalKind::ApprovePersistentGrant 2026-08-25 14:46:32 +02:00
CleverWild
16e388d7fd feat(server): ProposalKind::UpdateShamirParameters 2026-08-25 14:46:32 +02:00
CleverWild
48c77c7d96 feat(server): ProposalKind::ReplaceOperator 2026-08-25 14:46:32 +02:00
CleverWild
d60fa81441 feat(server): ProposalKind ::GrantWalletAccess and ::ApproveServerUpdate 2026-08-25 14:46:32 +02:00
CleverWild
f9795747a6 test(server): governance integration tests 2026-08-25 14:46:32 +02:00
CleverWild
cd01ad6a9d feat(server::grpc): wire governance RPCs through operator session 2026-08-25 14:46:32 +02:00
CleverWild
4dac689745 feat(server): introduce ProposalManager actor with quorum voting logic 2026-08-25 14:46:31 +02:00
CleverWild
4879d9971c feat(crypto): expose governance signing context and make shamir_threshold pub const 2026-08-25 14:46:31 +02:00
CleverWild
b0437ebfce feat(db): add proposal and proposal_vote tables 2026-08-25 14:46:31 +02:00
CleverWild
93070af53b feat(proto): add governance proposal/vote RPC definitions 2026-08-25 14:46:31 +02:00
CleverWild
70c4def8b9 housekepping: add fixme for start_bootstrap's operator_id 2026-08-25 14:46:31 +02:00
CleverWild
4bb408e509 refactor(server::crypto): use fixed-size [u8; 32] and KeyCell throughout seal key API 2026-08-25 14:46:31 +02:00
CleverWild
a7c53aa9b6 fix(server::tests): tighten unseal test seal_key params to &[u8; 32] 2026-08-25 14:46:31 +02:00
CleverWild
8265281213 feat(server::grpc): wire Shamir committee bootstrap and unseal proto messages
Adds DeclareCommittee and ContributePassphrase variants to bootstrap.proto,
ContributePassphrase to unseal.proto, and AwaitingContributions result codes
to both. Implements corresponding inbound converters and outbound reply
mappings. VaultGate handlers delegate to VaultCoordinator.
2026-08-25 14:46:31 +02:00
CleverWild
c8948c73fb feat(server): introduce VaultCoordinator for multi-operator Shamir bootstrap/unseal
VaultCoordinator collects operator passphrases, splits the seal key into
Shamir shares on bootstrap (encrypting each share with the operator's
passphrase via Argon2 + XChaCha20-Poly1305), and reconstructs the seal
key from threshold shares on unseal. Adds vsss-rs 5.4.0 and rand_core 0.6
dependencies.
2026-08-25 14:46:31 +02:00
CleverWild
602dda1fa7 refactor(server::actors::vault): clean up Bootstrap/TryUnseal, remove Bootstrapping state
Bootstrap and TryUnseal now accept a SafeCell<Vec<u8>> seal key directly.
The Bootstrapping intermediate state is removed — multi-operator coordination
is the responsibility of VaultCoordinator, which calls Bootstrap atomically
once all shares are collected.
2026-08-25 14:46:31 +02:00
CleverWild
8c49ee6e51 feat(server::crypto): add Shamir secret sharing utilities
Wraps vsss_rs Gf256::split_array / combine_array into thin split_key /
combine_shares helpers. Also widens derive_key salt parameter from &[u8;16]
to &[u8] to accommodate the 32-byte share salts.
2026-08-25 14:46:31 +02:00
CleverWild
bcd08dbb64 feat(server::db): add share_salt column to operator table
Each operator row now stores a 32-byte random salt used to derive the
per-operator share encryption key from their passphrase (Argon2 KDF).
2026-08-25 14:46:31 +02:00
CleverWild
2abc6fa0e6 feat(server::actors::evm): implement operator_delete_grant
Sets revoked_at on the evm_basic_grant row; returns NotFound if the grant
does not exist. Wires the handler in OperatorSession replacing the todo!().
2026-08-25 14:46:31 +02:00
CleverWild
81cf3693c2 fix(server::peers::operator::auth): make ChallengeContext pub for smlang state machine
smlang generates a public state enum whose variants contain ChallengeContext,
requiring the type itself to be fully public. Also tightens the wildcard arm
in client auth to an exhaustive match.
2026-08-25 14:46:31 +02:00
Skipper
9000909e54 WIP: some things 2026-08-25 14:46:31 +02:00
Skipper
e911165137 feat(grpc): governance contract 2026-08-25 14:46:31 +02:00
Skipper
a773255935 refactor(server::db): introduced newtype wrappers for entity id's in database 2026-05-04 19:35:27 +02:00
Skipper
3f801abdff housekeeping(server): deps upgrade + diesel migration to AsyncFnOnce
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
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-05-01 11:22:40 +02:00
Skipper
2b44570ab4 fix(server): MacOS build version
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-19 13:47:47 +02:00
Skipper
1f9b253433 housekeeping(server): removed unused deps 2026-04-19 13:46:49 +02:00
Skipper
a1c3ffd2d1 refactor: rename to to better reflect meaning
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-19 13:41:50 +02:00
Skipper
fd25de32a1 docs: move to folder and update to new challenge payload 2026-04-18 15:17:18 +02:00
Skipper
9ab074170b merge: feat-lints into main
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
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
ci/woodpecker/push/useragent-analyze Pipeline failed
2026-04-18 15:04:33 +02:00
18b8a3bbf5 Merge pull request 'refactor-integrity-check' (#90) from refactor-integrity-check 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: #90
2026-04-18 11:54:30 +00:00
Skipper
38cf1b98b9 housekeeping(server): clippy warns fix
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/pr/useragent-analyze Pipeline failed
2026-04-18 13:53:11 +02:00
Skipper
9cf87b2058 merge: refactor-integrity-check into main
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-18 13:46:28 +02:00
Skipper
929d50b589 housekeeping(server): clean too-broad visibility markers and organize imports
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
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-18 13:30:09 +02:00
Skipper
70acfc99b5 merge: refactor-integrity-check into main 2026-04-18 13:19:13 +02:00
28f84d03ab Merge pull request 'housekeeping(server): dependencies upgrade' (#89) from push-zmvtzuwrnyyv 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
Reviewed-on: #89
2026-04-17 19:20:50 +00:00
Skipper
4a8e51ef32 docs: updated to new auth challenge format and removed stale TOCTOU race condition note
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-17 18:25:55 +02:00
Skipper
9ee86afc19 fix(useragent): now using new challenge format 2026-04-17 18:19:51 +02:00
Skipper
790026e93b fix(server::tests): api surface of auth challenge changed 2026-04-17 17:58:22 +02:00
Skipper
0e09afda5d refactor(server::{useragent::auth, client::auth}): use random based + timestamp nonce instead of monotonic counter in database 2026-04-17 17:44:42 +02:00
Skipper
51e6571d80 refactor(server): now keeps track of useragents, instead of 2026-04-17 00:00:43 +02:00
Skipper
3b828d5874 refactor(server::grpc::vault_gate): standard approach using / traits 2026-04-16 22:15:18 +02:00
Skipper
a6f94e3115 fix(server): sending fixed vault state when on stage 2026-04-16 19:36:41 +02:00
hdbg
f49e995c2f WIP: kameo::messages wiring for transport generalization
Some checks failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-16 17:18:46 +02:00
Skipper
e88df432fb housekeeping(server): dependencies upgrade
Some checks failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-04-14 19:10:07 +02:00
hdbg
87ee0fe87b feat(user-agent): add VaultGate for sealed vault authentication 2026-04-12 11:53:05 +02:00
CleverWild
41b3fc5d39 fix(lints): remove unstable ones
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-04-10 01:00:21 +02:00
CleverWild
f6a0c32b9d feat: rustc and clippy linting
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-04-10 00:42:43 +02:00
hdbg
205227a3df fix(server::integrity): vault now differentias between expected/unexpected states for commands more granularly 2026-04-08 18:21:48 +02:00
hdbg
a4070e7df7 fix(useragent): unsafe, but working implementation of ml-dsa 2026-04-08 17:43:51 +02:00
hdbg
6b8da567dd fix(server::user_agent): useragents now self-sign themselves on bootstrap 2026-04-08 17:40:45 +02:00
hdbg
1585f90cae refactor(server): reorganized client/user_agent actors into separate module peers and added event MessageBus 2026-04-08 12:34:16 +02:00
62dff3f810 Merge pull request 'refactor(hashing): introduce Hashable derive macro and migrate server types' (#82) from hashing-proc-macro into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #82
Reviewed-by: Stas <business@jexter.tech>
2026-04-08 00:18:40 +00:00
CleverWild
6e22f368c9 refactor(hashing): introduce Hashable derive macro and migrate server types
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-04-08 01:32:59 +02:00
f3cf6a9438 Merge pull request 'Post-quantum crypto and better useragent security' (#80) from push-xrxykvkuxpsv into main
Some checks failed
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
Reviewed-on: #80
2026-04-07 19:26:54 +00:00
hdbg
a9f9fc2a9d housekeeping(server): fixed clippy warns
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-07 16:28:47 +02:00
hdbg
d22ab49e3d refactor(server): moved shared module crypto into arbiter-crypto 2026-04-07 16:24:51 +02:00
hdbg
a845181ef6 docs: ml-dsa scheme everywhere
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-04-07 15:02:32 +02:00
hdbg
0d424f3afc refactor(server): migrated auth to ml-dsa 2026-04-07 14:55:31 +02:00
hdbg
1497884ce6 fix(server::bootsrapper): token compare is now constant-time
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:33:47 +02:00
hdbg
b3464cf8a6 tests(server::client::auth): integrity envelope insertion for valid paths
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
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:24:13 +02:00
hdbg
46d1318b6f feat(server): add integrity verification for client keys 2026-04-06 18:13:11 +02:00
9c80d51d45 Merge pull request 'fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing' (#77) from push-opwuyuwxknyo 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 failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #77
2026-04-06 15:42:47 +00:00
hdbg
33456a644d tests(server): property-based testing for ordering independency for hash
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-06 17:40:41 +02:00
hdbg
5bc0c42cc7 fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing 2026-04-06 16:25:32 +02:00
hdbg
f6b62ab884 fix(server): added chain_id check and covered check_shared_constraints with unit tests
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
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-04-06 12:57:18 +02:00
hdbg
2dd5a3f32f tests(server): initial cargo-mutants
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 12:03:56 +02:00
hdbg
1aca9d4007 fix(server): simplify hash function for debug profile 2026-04-05 22:50:28 +02:00
5ee1b49c43 Merge pull request 'feat(server): integrity envelope engine for EVM grants with HMAC verification' (#51) from integrity-envelope into main
Some checks failed
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
Reviewed-on: #51
2026-04-05 16:26:51 +00:00
hdbg
00745bb381 tests(server): fixed for new integrity checks
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-04-05 14:49:02 +02:00
hdbg
b122aa464c refactor(server): rework envelopes and integrity check
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-04-05 14:17:00 +02:00
hdbg
9fab945a00 fix(server): remove stale mentions of miette
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
2026-04-05 10:45:24 +02:00
CleverWild
aeed664e9a chore: inline integrity proto types
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
2026-04-05 10:44:21 +02:00
CleverWild
4057c1fc12 feat(server): integrity envelope engine for EVM grants with HMAC verification 2026-04-05 10:44:21 +02:00
hdbg
f5eb51978d docs: add recovery operators and multi-operator details 2026-04-05 08:27:24 +00:00
hdbg
d997e0f843 docs: add multi-operator governance section 2026-04-05 08:27:24 +00:00
hdbg
7aca281a81 merge: @main into client-integrity-verification
Some checks failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/push/useragent-analyze Pipeline failed
ci/woodpecker/push/server-test Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/pr/server-audit Pipeline was successful
2026-04-05 10:25:46 +02:00
hdbg
01b12515bd housekeeping(server): fixed clippy warns
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
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:33:48 +02:00
hdbg
4a50daa7ea refactor(user-agent): remove backfill pubkey integrity tags
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:32:00 +02:00
hdbg
352ee3ee63 fix(server): previously, user agent auth accepted invalid signatures
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 failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 14:28:07 +02:00
hdbg
dd51d756da refactor(server): separate crypto by purpose and moved outside of actor into separate module 2026-04-04 14:21:52 +02:00
CleverWild
0bb6e596ac feat(auth): implement attestation status verification for public keys
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-04 12:10:45 +02:00
CleverWild
881f16bb1a fix(keyholder): comment drift 2026-04-04 12:02:50 +02:00
CleverWild
78895bca5b refactor(keyholder): generalize derive_useragent_integrity_key and compute_useragent_pubkey_integrity_tag corespondenly to derive_integrity_key and compute_integrity_tag 2026-04-04 12:00:39 +02:00
CleverWild
a02ef68a70 feat(auth): add seal-key-derived pubkey integrity tags with auth enforcement and unseal backfill
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 failed
2026-03-30 00:17:04 +02:00
hdbg
e5be55e141 style(dashboard): format code and add title margin
Some checks failed
ci/woodpecker/push/useragent-analyze Pipeline failed
2026-03-29 10:54:02 +02:00
hdbg
8f0eb7130b feat(grants-create): add configurable grant authorization fields 2026-03-29 00:37:58 +01:00
hdbg
94fe04a6a4 refactor(useragent::evm::grants): split into more files & flutter_form_builder usage 2026-03-29 00:37:58 +01:00
hdbg
976c11902c fix(useragent::dashboard): screen pushed twice due to improper listen hook 2026-03-29 00:37:58 +01:00
hdbg
c8d2662a36 refactor(grants): wrap grant list in SingleChildScrollView 2026-03-29 00:37:58 +01:00
hdbg
ac5fedddd1 style(dashboard): remove const from _CalloutBell and add title to nav rail 2026-03-29 00:37:58 +01:00
hdbg
0c2d4986a2 refactor(useragent): moved shared CreamPanel and StatePanel into generic widgets 2026-03-29 00:37:58 +01:00
hdbg
a3203936d2 feat(evm): add EVM grants screen with create UI and list 2026-03-29 00:37:58 +01:00
hdbg
fb1c0ec130 refactor(proto): restructure wallet access messages for improved data organization 2026-03-29 00:37:58 +01:00
hdbg
2a21758369 refactor(server::evm): removed repetetive errors and error variants 2026-03-29 00:37:58 +01:00
hdbg
1abb5fa006 refactor(useragent::evm::table): broke down into more widgets 2026-03-29 00:37:58 +01:00
hdbg
e1b1c857fa refactor(useragent::evm): moved out header into general widget 2026-03-29 00:37:58 +01:00
hdbg
4216007af3 feat(useragent): vibe-coded access list 2026-03-29 00:37:58 +01:00
64 changed files with 6566 additions and 2688 deletions

109
AGENTS.md
View File

@@ -1,16 +1,13 @@
# AGENTS.md # AGENTS.md
Guidance for coding agents (Claude Code, Codex, …) working in this repository. This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview ## Project Overview
Arbiter is a **permissioned signing service** for cryptocurrency wallets: Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of:
- **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies - **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies
- **`useragent/`** — Flutter app (desktop + mobile + web targets) with a Rust core via `flutter_rust_bridge` - **`operator/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
- **`protobufs/`** — Protocol Buffer definitions shared between server and clients - **`protobufs/`** — Protocol Buffer definitions shared between server and client
- **`docs/`** — `ARCHITECTURE.md` (peer types, flows, threat model) and `IMPLEMENTATION.md`; treat them as the design source of truth and update them when behaviour changes
- **`scripts/`** — helper scripts, e.g. `gen_erc20_registry.py`
The vault never exposes key material; it only produces signatures when requests satisfy configured policies. The vault never exposes key material; it only produces signatures when requests satisfy configured policies.
@@ -21,7 +18,7 @@ Tools are managed via [mise](https://mise.jdx.dev/). Install all required tools:
mise install mise install
``` ```
Key versions live in `mise.toml` (currently Rust 1.95.0 with clippy, Flutter 3.41.7-stable, protoc 29.6, diesel_cli 2.3.7 with `sqlite-bundled`, Python 3.14). Also provided there: `cargo-nextest`, `cargo-audit`, `cargo-vet`, `cargo-shear`, `cargo-mutants`, `cargo-features-manager`, `cargo-edit`, `ast-grep`, `flutter_rust_bridge_codegen`. Key versions: Rust 1.93.0 (with clippy), Flutter 3.38.9-stable, protoc 29.6, diesel_cli 2.3.6 (sqlite).
## Server (Rust workspace at `server/`) ## Server (Rust workspace at `server/`)
@@ -29,14 +26,10 @@ Key versions live in `mise.toml` (currently Rust 1.95.0 with clippy, Flutter 3.4
| Crate | Purpose | | Crate | Purpose |
|---|---| |---|---|
| `arbiter-proto` | Generated gRPC stubs + protobuf types (`tonic-prost-build`); also `ArbiterUrl`, `home_path()`, `BOOTSTRAP_PATH` | | `arbiter-proto` | Generated gRPC stubs + protobuf types; compiled from `protobufs/*.proto` via `tonic-prost-build` |
| `arbiter-crypto` | Shared crypto primitives: `authn` (ML-DSA), `safecell` (hardened memory), `hashing::Hashable`, re-exported `x-wing` | | `arbiter-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation |
| `arbiter-macros` | `#[derive(Hashable)]` — canonical hashing of structs for the DB integrity layer | | `arbiter-operator` | Rust client library for the operator side of the gRPC protocol |
| `arbiter-server` | Main daemon — actors, peers, DB, EVM policy engine, gRPC service implementation | | `arbiter-client` | Rust client library for SDK clients |
| `arbiter-client` | Rust client library for SDK clients (`ArbiterClient`, EVM wallet, key storage) |
| `arbiter-tokens-registry` | Generated ERC-20 token registry used by token-transfer policies |
Workspace lints (`server/Cargo.toml`) are strict: most of clippy `pedantic`/`nursery` plus a large restriction set. `as` casts, indexing/slicing, `dbg!`, float arithmetic and undocumented `unsafe` are denied or warned — expect to add an `#[expect(..., reason = "...")]` rather than to silence a lint globally.
### Common Commands ### Common Commands
@@ -49,78 +42,54 @@ cargo build
# Run the server daemon # Run the server daemon
cargo run -p arbiter-server cargo run -p arbiter-server
# Run all tests (preferred over cargo test; CI uses --all-features) # Run all tests (preferred over cargo test)
cargo nextest run cargo nextest run
# Run a single test # Run a single test
cargo nextest run <test_name> cargo nextest run <test_name>
# Lint (CI runs it with -D warnings) # Lint
cargo clippy --all -- -D warnings cargo clippy
# Security audit # Security audit
cargo audit cargo audit
# Supply-chain review (config in server/supply-chain/)
cargo vet
# Check unused dependencies # Check unused dependencies
cargo shear cargo shear
# Mutation testing # Run snapshot tests and update snapshots
cargo mutants cargo insta review
``` ```
### CI
Woodpecker pipelines in `.woodpecker/` run on `server/**` changes: `server-lint` (clippy), `server-test` (nextest, `--all-features`), `server-audit`, `server-vet`, plus `useragent-analyze` for the Flutter app.
### Architecture ### Architecture
The server is actor-based using the **kameo** crate. Long-lived state lives in `GlobalActors` (`src/actors/mod.rs`): The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
- **`Bootstrapper`** — one-time bootstrap token, written to `~/.arbiter/bootstrap_token` on first run - **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
- **`Vault`** — encrypted root key and the Sealed/Unsealed state machine; on unseal decrypts the root key into a `memsafe`-backed `SafeCell` - **`Vault`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
- **`FlowCoordinator`** — cross-connection flow between operators and SDK clients - **`FlowCoordinator`** — Coordinates cross-connection flow between operators and SDK clients.
- **`OperatorRegistry`** — tracks currently connected operators - **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
- **`EvmActor`** — EVM transaction policy enforcement and signing
- **`events`** — a `kameo_actors::MessageBus` (`DeliveryStrategy::Guaranteed`) for cross-actor notifications
Per-connection state lives under **`src/peers/`**, not `actors/`: `peers/client/` and `peers/operator/`, each with `auth` (challenge-response) and `session` (post-auth) sub-modules; the operator side additionally has `vault_gate/` for the unseal handshake. Per-connection actors live under `actors/operator/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
The gRPC surface lives in **`src/grpc/`**, split per peer (`client/`, `operator/`, `common/`) and per direction (`inbound.rs` — requests to the daemon, `outbound.rs` — server-initiated streams), with `request_tracker.rs` correlating the two. **Database:** SQLite via `diesel-async` + `bb8` connection pool. Schema managed by embedded Diesel migrations in `crates/arbiter-server/migrations/`. DB file lives at `~/.arbiter/arbiter.sqlite`. Tests use a temp-file DB via `db::create_test_pool()`.
EVM logic is in `src/evm/`: `policies/ether_transfer/`, `policies/token_transfers/`, `abi.rs`, `safe_signer.rs`.
**Database:** SQLite via `diesel-async` + `bb8`. Schema in `src/db/schema.rs`, models in `src/db/models.rs`, embedded migrations in `crates/arbiter-server/migrations/`. DB file lives at `~/.arbiter/arbiter.sqlite`; tests use a temp-file DB via `db::create_test_pool()`.
Entity ids are newtypes generated by the `declare_id!` macro in `db::models` (`OperatorId`, `ChainId`, …), each a `#[repr(transparent)]` wrapper over `i32` with `to_raw`/`from_raw`. Pass these around instead of bare `i32`.
**Row integrity:** sensitive rows are covered by an HMAC-SHA256 envelope (`src/crypto/integrity/`, table `integrity_envelope`), keyed from the vault root key. A struct becomes coverable by deriving `arbiter_macros::Hashable` and implementing `Integrable` (`KIND` + `VERSION`). When adding or changing a covered entity, keep the derive and the payload version in sync — a mismatch surfaces as `PayloadVersionMismatch` or `MacMismatch` at runtime.
**Cryptography:** **Cryptography:**
- Authentication: **ML-DSA-87** (post-quantum, `arbiter-crypto::authn::v1`), challenge-response with per-peer nonce tracking - Authentication: ed25519 (challenge-response, nonce-tracked per peer)
- Encryption at rest: XChaCha20-Poly1305, versioned modules (`crypto/encryption/v1.rs`) with a `schema_version` column for transparent migration on unseal - Encryption at rest: XChaCha20-Poly1305 (versioned via `scheme` field for transparent migration on unseal)
- Password KDF: Argon2 - Password KDF: Argon2
- Unseal transport: X25519 ephemeral key exchange (`peers/operator/vault_gate/`); `x-wing` (hybrid PQ KEM) is available via `arbiter-crypto` - Unseal transport: X25519 ephemeral key exchange
- TLS: self-signed certificate (rustls + aws-lc-rs, `prefer-post-quantum`), fingerprint distributed via `ArbiterUrl` - TLS: self-signed certificate (aws-lc-rs backend), fingerprint distributed via `ArbiterUrl`
Crypto modules are versioned by convention: `mod.rs` re-exports the current `vN`. Add a `v(N+1)` rather than editing an existing version in place. **Protocol:** gRPC with Protocol Buffers. The `ArbiterUrl` type encodes host, port, CA cert, and bootstrap token into a single shareable string (printed to console on first run).
**Protocol:** gRPC with Protocol Buffers. `ArbiterUrl` encodes host, port, CA cert and bootstrap token into a single shareable string (printed to console on first run).
### Proto Regeneration ### Proto Regeneration
`arbiter-proto/build.rs` compiles `arbiter.proto`, `operator.proto`, `client.proto` and `evm.proto` (with their `shared/`, `operator/`, `client/` includes) on build: When `.proto` files in `protobufs/` change, rebuild to regenerate:
```sh ```sh
cd server && cargo build -p arbiter-proto cd server && cargo build -p arbiter-proto
``` ```
Dart protobuf stubs are generated separately, from the repo root:
```sh
mise run codegen # protoc --dart_out=grpc:useragent/lib/proto
```
### Database Migrations ### Database Migrations
```sh ```sh
@@ -131,8 +100,6 @@ diesel migration generate <name> --migration-dir crates/arbiter-server/migration
diesel migration run --migration-dir crates/arbiter-server/migrations diesel migration run --migration-dir crates/arbiter-server/migrations
``` ```
Pre-release policy: there is a single `init` migration and no deployed databases yet, so schema changes are made by editing that migration directly instead of stacking new ones. Regenerate `src/db/schema.rs` after changing it.
### Code Conventions ### Code Conventions
**`#[must_use]` Attribute:** **`#[must_use]` Attribute:**
@@ -154,23 +121,29 @@ pub fn verify(&self, nonce: i32, context: &[u8], signature: &Signature) -> bool
This forces callers to either use the return value or explicitly ignore it with `let _ = ...;`, preventing silent failures. This forces callers to either use the return value or explicitly ignore it with `let _ = ...;`, preventing silent failures.
## User Agent (Flutter + flutter_rust_bridge at `useragent/`) ## Operator (Flutter + Rinf at `operator/`)
The Flutter app calls Rust through [flutter_rust_bridge](https://cjycode.com/flutter_rust_bridge/) 2.12.0. The Rust side is the `rust_lib_arbiter` crate at `useragent/rust/`; everything exposed to Dart is declared in `useragent/rust/src/api/` and lands in `useragent/lib/src/rust/` (see `useragent/flutter_rust_bridge.yaml`). Dart UI code is organised as `lib/features/`, `lib/screens/`, `lib/widgets/`, `lib/providers/`, `lib/theme/`, with routing in `lib/router.dart` (`router.gr.dart` is generated). The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `operator/native/hub/` as a separate crate that uses `arbiter-operator` for the gRPC client.
Communication between Dart and Rust uses typed **signals** defined in `operator/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
```sh
cd operator && rinf gen
```
### Common Commands ### Common Commands
```sh ```sh
cd useragent cd operator
# Run the app # Run the app (macOS or Windows)
flutter run flutter run
# Regenerate Rust↔Dart bindings after editing rust/src/api/ # Regenerate Rust↔Dart signal bindings
mise run codegen # flutter_rust_bridge_codegen generate rinf gen
# Analyze Dart code (also run in CI) # Analyze Dart code
flutter analyze flutter analyze
``` ```
Note: `app/` contains only stale generated Flutter artifacts and is not the application source. The Rinf Rust entry point is `operator/native/hub/src/lib.rs`. It spawns actors defined in `operator/native/hub/src/actors/` which handle Dart↔server communication via signals.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,821 @@
# Grant Grid View Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an "EVM Grants" dashboard tab that displays all grants as enriched cards (type, chain, wallet address, client name) with per-card revoke support.
**Architecture:** A new `walletAccessListProvider` fetches wallet accesses with their DB row IDs. The screen (`grants.dart`) watches only `evmGrantsProvider` for top-level state. Each `GrantCard` widget (its own file) watches enrichment providers (`walletAccessListProvider`, `evmProvider`, `sdkClientsProvider`) and the revoke mutation directly — keeping rebuilds scoped to the card. The screen is registered as a dashboard tab in `AdaptiveScaffold`.
**Tech Stack:** Flutter, Riverpod (`riverpod_annotation` + `build_runner` codegen), `sizer` (adaptive sizing), `auto_route`, Protocol Buffers (Dart), `Palette` design tokens.
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `operator/lib/theme/palette.dart` | Modify | Add `Palette.token` (indigo accent for token-transfer cards) |
| `operator/lib/features/connection/evm/wallet_access.dart` | Modify | Add `listAllWalletAccesses()` function |
| `operator/lib/providers/sdk_clients/wallet_access_list.dart` | Create | `WalletAccessListProvider` — fetches full wallet access list with IDs |
| `operator/lib/screens/dashboard/evm/grants/widgets/grant_card.dart` | Create | `GrantCard` widget — watches enrichment providers + revoke mutation; one card per grant |
| `operator/lib/screens/dashboard/evm/grants/grants.dart` | Create | `EvmGrantsScreen` — watches `evmGrantsProvider`; handles loading/error/empty/data states; renders `GrantCard` list |
| `operator/lib/router.dart` | Modify | Register `EvmGrantsRoute` in dashboard children |
| `operator/lib/screens/dashboard.dart` | Modify | Add Grants entry to `routes` list and `NavigationDestination` list |
---
## Task 1: Add `Palette.token`
**Files:**
- Modify: `operator/lib/theme/palette.dart`
- [ ] **Step 1: Add the color**
Replace the contents of `operator/lib/theme/palette.dart` with:
```dart
import 'package:flutter/material.dart';
class Palette {
static const ink = Color(0xFF15263C);
static const coral = Color(0xFFE26254);
static const cream = Color(0xFFFFFAF4);
static const line = Color(0x1A15263C);
static const token = Color(0xFF5C6BC0);
}
```
- [ ] **Step 2: Verify**
```sh
cd operator && flutter analyze lib/theme/palette.dart
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(theme): add Palette.token for token-transfer grant cards"
jj new
```
---
## Task 2: Add `listAllWalletAccesses` feature function
**Files:**
- Modify: `operator/lib/features/connection/evm/wallet_access.dart`
`readClientWalletAccess` (existing) filters the list to one client's wallet IDs and returns `Set<int>`. This new function returns the complete unfiltered list with row IDs so the grant cards can resolve wallet_access_id → wallet + client.
- [ ] **Step 1: Append function**
Add at the bottom of `operator/lib/features/connection/evm/wallet_access.dart`:
```dart
Future<List<SdkClientWalletAccess>> listAllWalletAccesses(
Connection connection,
) async {
final response = await connection.ask(
OperatorRequest(listWalletAccess: Empty()),
);
if (!response.hasListWalletAccessResponse()) {
throw Exception(
'Expected list wallet access response, got ${response.whichPayload()}',
);
}
return response.listWalletAccessResponse.accesses.toList(growable: false);
}
```
Each returned `SdkClientWalletAccess` has:
- `.id` — the `evm_wallet_access` row ID (same value as `wallet_access_id` in a `GrantEntry`)
- `.access.walletId` — the EVM wallet DB ID
- `.access.sdkClientId` — the SDK client DB ID
- [ ] **Step 2: Verify**
```sh
cd operator && flutter analyze lib/features/connection/evm/wallet_access.dart
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(evm): add listAllWalletAccesses feature function"
jj new
```
---
## Task 3: Create `WalletAccessListProvider`
**Files:**
- Create: `operator/lib/providers/sdk_clients/wallet_access_list.dart`
- Generated: `operator/lib/providers/sdk_clients/wallet_access_list.g.dart`
Mirrors the structure of `EvmGrants` in `providers/evm/evm_grants.dart` — class-based `@riverpod` with a `refresh()` method.
- [ ] **Step 1: Write the provider**
Create `operator/lib/providers/sdk_clients/wallet_access_list.dart`:
```dart
import 'package:arbiter/features/connection/evm/wallet_access.dart';
import 'package:arbiter/proto/operator.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:mtcore/markettakers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'wallet_access_list.g.dart';
@riverpod
class WalletAccessList extends _$WalletAccessList {
@override
Future<List<SdkClientWalletAccess>?> build() async {
final connection = await ref.watch(connectionManagerProvider.future);
if (connection == null) {
return null;
}
try {
return await listAllWalletAccesses(connection);
} catch (e, st) {
talker.handle(e, st);
rethrow;
}
}
Future<void> refresh() async {
final connection = await ref.read(connectionManagerProvider.future);
if (connection == null) {
state = const AsyncData(null);
return;
}
state = const AsyncLoading();
state = await AsyncValue.guard(() => listAllWalletAccesses(connection));
}
}
```
- [ ] **Step 2: Run code generation**
```sh
cd operator && dart run build_runner build --delete-conflicting-outputs
```
Expected: `operator/lib/providers/sdk_clients/wallet_access_list.g.dart` created. No errors.
- [ ] **Step 3: Verify**
```sh
cd operator && flutter analyze lib/providers/sdk_clients/
```
Expected: no issues.
- [ ] **Step 4: Commit**
```sh
jj describe -m "feat(providers): add WalletAccessListProvider"
jj new
```
---
## Task 4: Create `GrantCard` widget
**Files:**
- Create: `operator/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`
This widget owns all per-card logic: enrichment lookups, revoke action, and rebuild scope. The screen only passes it a `GrantEntry` — the card fetches everything else itself.
**Key types:**
- `GrantEntry` (from `proto/evm.pb.dart`): `.id`, `.shared.walletAccessId`, `.shared.chainId`, `.specific.whichGrant()`
- `SpecificGrant_Grant.etherTransfer` / `.tokenTransfer` — enum values for the oneof
- `SdkClientWalletAccess` (from `proto/operator.pb.dart`): `.id`, `.access.walletId`, `.access.sdkClientId`
- `WalletEntry` (from `proto/evm.pb.dart`): `.id`, `.address` (List<int>)
- `SdkClientEntry` (from `proto/operator.pb.dart`): `.id`, `.info.name`
- `revokeEvmGrantMutation``Mutation<void>` (global; all revoke buttons disable together while any revoke is in flight)
- `executeRevokeEvmGrant(ref, grantId: int)``Future<void>`
- [ ] **Step 1: Write the widget**
Create `operator/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`:
```dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/proto/operator.pb.dart';
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/list.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
String _shortAddress(List<int> bytes) {
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
}
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
class GrantCard extends ConsumerWidget {
const GrantCard({super.key, required this.grant});
final GrantEntry grant;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Enrichment lookups — each watch scopes rebuilds to this card only
final walletAccesses =
ref.watch(walletAccessListProvider).asData?.value ?? const [];
final wallets = ref.watch(evmProvider).asData?.value ?? const [];
final clients = ref.watch(sdkClientsProvider).asData?.value ?? const [];
final revoking = ref.watch(revokeEvmGrantMutation) is MutationPending;
final isEther =
grant.specific.whichGrant() == SpecificGrant_Grant.etherTransfer;
final accent = isEther ? Palette.coral : Palette.token;
final typeLabel = isEther ? 'Ether' : 'Token';
final theme = Theme.of(context);
final muted = Palette.ink.withValues(alpha: 0.62);
// Resolve wallet_access_id → wallet address + client name
final accessById = <int, SdkClientWalletAccess>{
for (final a in walletAccesses) a.id: a,
};
final walletById = <int, WalletEntry>{
for (final w in wallets) w.id: w,
};
final clientNameById = <int, String>{
for (final c in clients) c.id: c.info.name,
};
final accessId = grant.shared.walletAccessId;
final access = accessById[accessId];
final wallet = access != null ? walletById[access.access.walletId] : null;
final walletLabel = wallet != null
? _shortAddress(wallet.address)
: 'Access #$accessId';
final clientLabel = () {
if (access == null) return '';
final name = clientNameById[access.access.sdkClientId] ?? '';
return name.isEmpty ? 'Client #${access.access.sdkClientId}' : name;
}();
void showError(String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> revoke() async {
try {
await executeRevokeEvmGrant(ref, grantId: grant.id);
} catch (e) {
showError(_formatError(e));
}
}
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Accent strip
Container(
width: 0.8.w,
decoration: BoxDecoration(
color: accent,
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(24),
),
),
),
// Card body
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 1.6.w,
vertical: 1.4.h,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row 1: type badge · chain · spacer · revoke button
Row(
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.4.h,
),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
typeLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: accent,
fontWeight: FontWeight.w800,
),
),
),
SizedBox(width: 1.w),
Container(
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.4.h,
),
decoration: BoxDecoration(
color: Palette.ink.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Chain ${grant.shared.chainId}',
style: theme.textTheme.labelSmall?.copyWith(
color: muted,
fontWeight: FontWeight.w700,
),
),
),
const Spacer(),
if (revoking)
SizedBox(
width: 1.8.h,
height: 1.8.h,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Palette.coral,
),
)
else
OutlinedButton.icon(
onPressed: revoke,
style: OutlinedButton.styleFrom(
foregroundColor: Palette.coral,
side: BorderSide(
color: Palette.coral.withValues(alpha: 0.4),
),
padding: EdgeInsets.symmetric(
horizontal: 1.w,
vertical: 0.6.h,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: const Icon(Icons.block_rounded, size: 16),
label: const Text('Revoke'),
),
],
),
SizedBox(height: 0.8.h),
// Row 2: wallet address · client name
Row(
children: [
Text(
walletLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: Palette.ink,
fontFamily: 'monospace',
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 0.8.w),
child: Text(
'·',
style: theme.textTheme.bodySmall
?.copyWith(color: muted),
),
),
Expanded(
child: Text(
clientLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall
?.copyWith(color: muted),
),
),
],
),
],
),
),
),
],
),
),
);
}
}
```
- [ ] **Step 2: Verify**
```sh
cd operator && flutter analyze lib/screens/dashboard/evm/grants/widgets/grant_card.dart
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(grants): add GrantCard widget with self-contained enrichment"
jj new
```
---
## Task 5: Create `EvmGrantsScreen`
**Files:**
- Create: `operator/lib/screens/dashboard/evm/grants/grants.dart`
The screen watches only `evmGrantsProvider` for top-level state (loading / error / no connection / empty / data). When there is data it renders a list of `GrantCard` widgets — each card manages its own enrichment subscriptions.
- [ ] **Step 1: Write the screen**
Create `operator/lib/screens/dashboard/evm/grants/grants.dart`:
```dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/router.gr.dart';
import 'package:arbiter/screens/dashboard/evm/grants/widgets/grant_card.dart';
import 'package:arbiter/theme/palette.dart';
import 'package:arbiter/widgets/page_header.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
String _formatError(Object error) {
final message = error.toString();
if (message.startsWith('Exception: ')) {
return message.substring('Exception: '.length);
}
return message;
}
// ─── State panel ──────────────────────────────────────────────────────────────
class _StatePanel extends StatelessWidget {
const _StatePanel({
required this.icon,
required this.title,
required this.body,
this.actionLabel,
this.onAction,
this.busy = false,
});
final IconData icon;
final String title;
final String body;
final String? actionLabel;
final Future<void> Function()? onAction;
final bool busy;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Palette.cream.withValues(alpha: 0.92),
border: Border.all(color: Palette.line),
),
child: Padding(
padding: EdgeInsets.all(2.8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (busy)
SizedBox(
width: 2.8.h,
height: 2.8.h,
child: const CircularProgressIndicator(strokeWidth: 2.5),
)
else
Icon(icon, size: 34, color: Palette.coral),
SizedBox(height: 1.8.h),
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
color: Palette.ink,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 1.h),
Text(
body,
style: theme.textTheme.bodyLarge?.copyWith(
color: Palette.ink.withValues(alpha: 0.72),
height: 1.5,
),
),
if (actionLabel != null && onAction != null) ...[
SizedBox(height: 2.h),
OutlinedButton.icon(
onPressed: () => onAction!(),
icon: const Icon(Icons.refresh),
label: Text(actionLabel!),
),
],
],
),
),
);
}
}
// ─── Grant list ───────────────────────────────────────────────────────────────
class _GrantList extends StatelessWidget {
const _GrantList({required this.grants});
final List<GrantEntry> grants;
@override
Widget build(BuildContext context) {
return Column(
children: [
for (var i = 0; i < grants.length; i++)
Padding(
padding: EdgeInsets.only(
bottom: i == grants.length - 1 ? 0 : 1.8.h,
),
child: GrantCard(grant: grants[i]),
),
],
);
}
}
// ─── Screen ───────────────────────────────────────────────────────────────────
@RoutePage()
class EvmGrantsScreen extends ConsumerWidget {
const EvmGrantsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Screen watches only the grant list for top-level state decisions
final grantsAsync = ref.watch(evmGrantsProvider);
Future<void> refresh() async {
await Future.wait([
ref.read(evmGrantsProvider.notifier).refresh(),
ref.read(walletAccessListProvider.notifier).refresh(),
]);
}
void showMessage(String message) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> safeRefresh() async {
try {
await refresh();
} catch (e) {
showMessage(_formatError(e));
}
}
final grantsState = grantsAsync.asData?.value;
final grants = grantsState?.grants;
final content = switch (grantsAsync) {
AsyncLoading() when grantsState == null => const _StatePanel(
icon: Icons.hourglass_top,
title: 'Loading grants',
body: 'Pulling grant registry from Arbiter.',
busy: true,
),
AsyncError(:final error) => _StatePanel(
icon: Icons.sync_problem,
title: 'Grant registry unavailable',
body: _formatError(error),
actionLabel: 'Retry',
onAction: safeRefresh,
),
AsyncData(:final value) when value == null => _StatePanel(
icon: Icons.portable_wifi_off,
title: 'No active server connection',
body: 'Reconnect to Arbiter to list EVM grants.',
actionLabel: 'Refresh',
onAction: safeRefresh,
),
_ when grants != null && grants.isEmpty => _StatePanel(
icon: Icons.policy_outlined,
title: 'No grants yet',
body: 'Create a grant to allow SDK clients to sign transactions.',
actionLabel: 'Create grant',
onAction: () => context.router.push(const CreateEvmGrantRoute()),
),
_ => _GrantList(grants: grants ?? const []),
};
return Scaffold(
body: SafeArea(
child: RefreshIndicator.adaptive(
color: Palette.ink,
backgroundColor: Colors.white,
onRefresh: safeRefresh,
child: ListView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h),
children: [
PageHeader(
title: 'EVM Grants',
isBusy: grantsAsync.isLoading,
actions: [
FilledButton.icon(
onPressed: () =>
context.router.push(const CreateEvmGrantRoute()),
icon: const Icon(Icons.add_rounded),
label: const Text('Create grant'),
),
SizedBox(width: 1.w),
OutlinedButton.icon(
onPressed: safeRefresh,
style: OutlinedButton.styleFrom(
foregroundColor: Palette.ink,
side: BorderSide(color: Palette.line),
padding: EdgeInsets.symmetric(
horizontal: 1.4.w,
vertical: 1.2.h,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Refresh'),
),
],
),
SizedBox(height: 1.8.h),
content,
],
),
),
),
);
}
}
```
- [ ] **Step 2: Verify**
```sh
cd operator && flutter analyze lib/screens/dashboard/evm/grants/
```
Expected: no issues.
- [ ] **Step 3: Commit**
```sh
jj describe -m "feat(grants): add EvmGrantsScreen"
jj new
```
---
## Task 6: Wire router and dashboard tab
**Files:**
- Modify: `operator/lib/router.dart`
- Modify: `operator/lib/screens/dashboard.dart`
- Regenerated: `operator/lib/router.gr.dart`
- [ ] **Step 1: Add route to `router.dart`**
Replace the contents of `operator/lib/router.dart` with:
```dart
import 'package:auto_route/auto_route.dart';
import 'router.gr.dart';
@AutoRouterConfig(generateForDir: ['lib/screens'])
class Router extends RootStackRouter {
@override
List<AutoRoute> get routes => [
AutoRoute(page: Bootstrap.page, path: '/bootstrap', initial: true),
AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'),
AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'),
AutoRoute(page: VaultSetupRoute.page, path: '/vault'),
AutoRoute(page: ClientDetailsRoute.page, path: '/clients/:clientId'),
AutoRoute(page: CreateEvmGrantRoute.page, path: '/evm-grants/create'),
AutoRoute(
page: DashboardRouter.page,
path: '/dashboard',
children: [
AutoRoute(page: EvmRoute.page, path: 'evm'),
AutoRoute(page: ClientsRoute.page, path: 'clients'),
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
AutoRoute(page: AboutRoute.page, path: 'about'),
],
),
];
}
```
- [ ] **Step 2: Update `dashboard.dart`**
In `operator/lib/screens/dashboard.dart`, replace the `routes` constant:
```dart
final routes = [
const EvmRoute(),
const ClientsRoute(),
const EvmGrantsRoute(),
const AboutRoute(),
];
```
And replace the `destinations` list inside `AdaptiveScaffold`:
```dart
destinations: const [
NavigationDestination(
icon: Icon(Icons.account_balance_wallet_outlined),
selectedIcon: Icon(Icons.account_balance_wallet),
label: 'Wallets',
),
NavigationDestination(
icon: Icon(Icons.devices_other_outlined),
selectedIcon: Icon(Icons.devices_other),
label: 'Clients',
),
NavigationDestination(
icon: Icon(Icons.policy_outlined),
selectedIcon: Icon(Icons.policy),
label: 'Grants',
),
NavigationDestination(
icon: Icon(Icons.info_outline),
selectedIcon: Icon(Icons.info),
label: 'About',
),
],
```
- [ ] **Step 3: Regenerate router**
```sh
cd operator && dart run build_runner build --delete-conflicting-outputs
```
Expected: `lib/router.gr.dart` updated, `EvmGrantsRoute` now available, no errors.
- [ ] **Step 4: Full project verify**
```sh
cd operator && flutter analyze
```
Expected: no issues.
- [ ] **Step 5: Commit**
```sh
jj describe -m "feat(nav): add Grants dashboard tab"
jj new
```

View File

@@ -0,0 +1,170 @@
# Grant Grid View — Design Spec
**Date:** 2026-03-28
## Overview
Add a "Grants" dashboard tab to the Flutter operator app that displays all EVM grants as a card-based grid. Each card shows a compact summary (type, chain, wallet address, client name) with a revoke action. The tab integrates into the existing `AdaptiveScaffold` navigation alongside Wallets, Clients, and About.
## Scope
- New `walletAccessListProvider` for fetching wallet access entries with their DB row IDs
- New `EvmGrantsScreen` as a dashboard tab
- Grant card widget with enriched display (type, chain, wallet, client)
- Revoke action wired to existing `executeRevokeEvmGrant` mutation
- Dashboard tab bar and router updated
- New token-transfer accent color added to `Palette`
**Out of scope:** Fixing grant creation (separate task).
---
## Data Layer
### `walletAccessListProvider`
**File:** `operator/lib/providers/sdk_clients/wallet_access_list.dart`
- `@riverpod` class, watches `connectionManagerProvider.future`
- Returns `List<SdkClientWalletAccess>?` (null when not connected)
- Each entry: `.id` (wallet_access_id), `.access.walletId`, `.access.sdkClientId`
- Exposes a `refresh()` method following the same pattern as `EvmGrants.refresh()`
### Enrichment at render time (Approach A)
The `EvmGrantsScreen` watches four providers:
1. `evmGrantsProvider` — the grant list
2. `walletAccessListProvider` — to resolve wallet_access_id → (wallet_id, sdk_client_id)
3. `evmProvider` — to resolve wallet_id → wallet address
4. `sdkClientsProvider` — to resolve sdk_client_id → client name
All lookups are in-memory Maps built inside the build method; no extra model class needed.
Fallbacks:
- Wallet address not found → `"Access #N"` where N is the wallet_access_id
- Client name not found → `"Client #N"` where N is the sdk_client_id
---
## Route Structure
```
/dashboard
/evm ← existing (Wallets tab)
/clients ← existing (Clients tab)
/grants ← NEW (Grants tab)
/about ← existing
/evm-grants/create ← existing push route (unchanged)
```
### Changes to `router.dart`
Add inside dashboard children:
```dart
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
```
### Changes to `dashboard.dart`
Add to `routes` list:
```dart
const EvmGrantsRoute()
```
Add `NavigationDestination`:
```dart
NavigationDestination(
icon: Icon(Icons.policy_outlined),
selectedIcon: Icon(Icons.policy),
label: 'Grants',
),
```
---
## Screen: `EvmGrantsScreen`
**File:** `operator/lib/screens/dashboard/evm/grants/grants.dart`
```
Scaffold
└─ SafeArea
└─ RefreshIndicator.adaptive (refreshes evmGrantsProvider + walletAccessListProvider)
└─ ListView (BouncingScrollPhysics + AlwaysScrollableScrollPhysics)
├─ PageHeader
│ title: 'EVM Grants'
│ isBusy: evmGrantsProvider.isLoading
│ actions: [CreateGrantButton, RefreshButton]
├─ SizedBox(height: 1.8.h)
└─ <content>
```
### State handling
Matches the pattern from `EvmScreen` and `ClientsScreen`:
| State | Display |
|---|---|
| Loading (no data yet) | `_StatePanel` with spinner, "Loading grants" |
| Error | `_StatePanel` with coral icon, error message, Retry button |
| No connection | `_StatePanel`, "No active server connection" |
| Empty list | `_StatePanel`, "No grants yet", with Create Grant shortcut |
| Data | Column of `_GrantCard` widgets |
### Header actions
**CreateGrantButton:** `FilledButton.icon` with `Icons.add_rounded`, pushes `CreateEvmGrantRoute()` via `context.router.push(...)`.
**RefreshButton:** `OutlinedButton.icon` with `Icons.refresh`, calls `ref.read(evmGrantsProvider.notifier).refresh()`.
---
## Grant Card: `_GrantCard`
**Layout:**
```
Container (rounded 24, Palette.cream bg, Palette.line border)
└─ IntrinsicHeight > Row
├─ Accent strip (0.8.w wide, full height, rounded left)
└─ Padding > Column
├─ Row 1: TypeBadge + ChainChip + Spacer + RevokeButton
└─ Row 2: WalletText + "·" + ClientText
```
**Accent color by grant type:**
- Ether transfer → `Palette.coral`
- Token transfer → `Palette.token` (new entry in `Palette` — indigo, e.g. `Color(0xFF5C6BC0)`)
**TypeBadge:** Small pill container with accent color background at 15% opacity, accent-colored text. Label: `'Ether'` or `'Token'`.
**ChainChip:** Small container: `'Chain ${grant.shared.chainId}'`, muted ink color.
**WalletText:** Short hex address (`0xabc...def`) from wallet lookup, `bodySmall`, monospace font family.
**ClientText:** Client name from `sdkClientsProvider` lookup, or fallback string. `bodySmall`, muted ink.
**RevokeButton:**
- `OutlinedButton` with `Icons.block_rounded` icon, label `'Revoke'`
- `foregroundColor: Palette.coral`, `side: BorderSide(color: Palette.coral.withValues(alpha: 0.4))`
- Disabled (replaced with `CircularProgressIndicator`) while `revokeEvmGrantMutation` is pending — note: this is a single global mutation, so all revoke buttons disable while any revoke is in flight
- On press: calls `executeRevokeEvmGrant(ref, grantId: grant.id)`; shows `SnackBar` on error
---
## Adaptive Sizing
All sizing uses `sizer` units (`1.h`, `1.w`, etc.). No hardcoded pixel values.
---
## Files to Create / Modify
| File | Action |
|---|---|
| `lib/theme/palette.dart` | Modify — add `Palette.token` color |
| `lib/providers/sdk_clients/wallet_access_list.dart` | Create |
| `lib/screens/dashboard/evm/grants/grants.dart` | Create |
| `lib/router.dart` | Modify — add grants route to dashboard children |
| `lib/screens/dashboard.dart` | Modify — add tab to routes list and NavigationDestinations |

View File

@@ -152,8 +152,5 @@ url = "https://github.com/astral-sh/python-build-standalone/releases/download/20
provenance = "github-attestations" provenance = "github-attestations"
[[tools.rust]] [[tools.rust]]
version = "1.98.1" version = "1.95.0"
backend = "core:rust" backend = "core:rust"
[tools.rust.options]
components = "clippy,rust-analyzer"

View File

@@ -4,7 +4,7 @@
"cargo:cargo-vet" = "0.10.2" "cargo:cargo-vet" = "0.10.2"
flutter = "3.41.7-stable" flutter = "3.41.7-stable"
protoc = "29.6" protoc = "29.6"
rust = { version = "latest", components = "clippy,rust-analyzer" } rust = { version = "1.95.0", components = "clippy,rust-analyzer" }
"cargo:cargo-features-manager" = "0.12.0" "cargo:cargo-features-manager" = "0.12.0"
"cargo:cargo-nextest" = "0.9.133" "cargo:cargo-nextest" = "0.9.133"
"cargo:cargo-shear" = "latest" "cargo:cargo-shear" = "latest"

311
server/Cargo.lock generated
View File

@@ -504,7 +504,7 @@ dependencies = [
"async-trait", "async-trait",
"auto_impl", "auto_impl",
"either", "either",
"elliptic-curve 0.13.8", "elliptic-curve",
"k256", "k256",
"thiserror", "thiserror",
] ]
@@ -769,9 +769,10 @@ dependencies = [
"mutants", "mutants",
"pem", "pem",
"proptest", "proptest",
"prost",
"prost-types", "prost-types",
"rand 0.10.1", "rand 0.10.1",
"rand_core 0.10.1", "rand_core 0.6.4",
"rcgen", "rcgen",
"restructed", "restructed",
"rstest", "rstest",
@@ -822,7 +823,7 @@ dependencies = [
"ark-serialize 0.3.0", "ark-serialize 0.3.0",
"ark-std 0.3.0", "ark-std 0.3.0",
"derivative", "derivative",
"num-bigint 0.4.6", "num-bigint",
"num-traits", "num-traits",
"paste", "paste",
"rustc_version 0.3.3", "rustc_version 0.3.3",
@@ -842,7 +843,7 @@ dependencies = [
"derivative", "derivative",
"digest 0.10.7", "digest 0.10.7",
"itertools 0.10.5", "itertools 0.10.5",
"num-bigint 0.4.6", "num-bigint",
"num-traits", "num-traits",
"paste", "paste",
"rustc_version 0.4.1", "rustc_version 0.4.1",
@@ -863,7 +864,7 @@ dependencies = [
"digest 0.10.7", "digest 0.10.7",
"educe", "educe",
"itertools 0.13.0", "itertools 0.13.0",
"num-bigint 0.4.6", "num-bigint",
"num-traits", "num-traits",
"paste", "paste",
"zeroize", "zeroize",
@@ -905,7 +906,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20"
dependencies = [ dependencies = [
"num-bigint 0.4.6", "num-bigint",
"num-traits", "num-traits",
"quote", "quote",
"syn 1.0.109", "syn 1.0.109",
@@ -917,7 +918,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565"
dependencies = [ dependencies = [
"num-bigint 0.4.6", "num-bigint",
"num-traits", "num-traits",
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -930,7 +931,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3"
dependencies = [ dependencies = [
"num-bigint 0.4.6", "num-bigint",
"num-traits", "num-traits",
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -955,7 +956,7 @@ checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5"
dependencies = [ dependencies = [
"ark-std 0.4.0", "ark-std 0.4.0",
"digest 0.10.7", "digest 0.10.7",
"num-bigint 0.4.6", "num-bigint",
] ]
[[package]] [[package]]
@@ -967,7 +968,7 @@ dependencies = [
"ark-std 0.5.0", "ark-std 0.5.0",
"arrayvec", "arrayvec",
"digest 0.10.7", "digest 0.10.7",
"num-bigint 0.4.6", "num-bigint",
] ]
[[package]] [[package]]
@@ -1197,12 +1198,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base16ct"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -1560,12 +1555,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -1628,22 +1617,20 @@ checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [ dependencies = [
"generic-array 0.14.7", "generic-array 0.14.7",
"rand_core 0.6.4", "rand_core 0.6.4",
"serdect 0.2.0",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
[[package]] [[package]]
name = "crypto-bigint" name = "crypto-bigint"
version = "0.7.5" version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" checksum = "96272c2ff28b807e09250b180ad1fb7889a3258f7455759b5c3c58b719467130"
dependencies = [ dependencies = [
"cpubits",
"ctutils",
"hybrid-array",
"num-traits", "num-traits",
"rand_core 0.10.1", "rand_core 0.6.4",
"serdect 0.4.3", "serdect 0.3.0",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@@ -1676,7 +1663,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [ dependencies = [
"cmov", "cmov",
"subtle",
] ]
[[package]] [[package]]
@@ -1839,7 +1825,7 @@ dependencies = [
"asn1-rs", "asn1-rs",
"displaydoc", "displaydoc",
"nom", "nom",
"num-bigint 0.4.6", "num-bigint",
"num-traits", "num-traits",
"rusticata-macros", "rusticata-macros",
] ]
@@ -2036,7 +2022,7 @@ checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [ dependencies = [
"der 0.7.10", "der 0.7.10",
"digest 0.10.7", "digest 0.10.7",
"elliptic-curve 0.13.8", "elliptic-curve",
"rfc6979", "rfc6979",
"serdect 0.2.0", "serdect 0.2.0",
"signature 2.2.0", "signature 2.2.0",
@@ -2070,50 +2056,34 @@ version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [ dependencies = [
"base16ct 0.2.0", "base16ct",
"crypto-bigint 0.5.5", "crypto-bigint 0.5.5",
"digest 0.10.7", "digest 0.10.7",
"ff 0.13.1", "ff",
"generic-array 0.14.7", "generic-array 0.14.7",
"group 0.13.0", "group",
"hkdf",
"pkcs8 0.10.2", "pkcs8 0.10.2",
"rand_core 0.6.4", "rand_core 0.6.4",
"sec1 0.7.3", "sec1",
"serdect 0.2.0", "serdect 0.2.0",
"subtle", "subtle",
"zeroize", "tap",
]
[[package]]
name = "elliptic-curve"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65"
dependencies = [
"base16ct 1.0.0",
"crypto-bigint 0.7.5",
"crypto-common 0.2.1",
"ff 0.14.0",
"group 0.14.0",
"hybrid-array",
"pkcs8 0.11.0",
"rand_core 0.10.1",
"sec1 0.8.1",
"serdect 0.4.3",
"subtle",
"zeroize", "zeroize",
] ]
[[package]] [[package]]
name = "elliptic-curve-tools" name = "elliptic-curve-tools"
version = "0.3.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0d5e534f103b079a71ef1d66c6e62c89413f1b62709ca11f744429b4afe5b4" checksum = "1de2b6fae800f08032a6ea32995b52925b1d451bff9d445c8ab2932323277faf"
dependencies = [ dependencies = [
"elliptic-curve 0.14.1", "elliptic-curve",
"heapless", "heapless",
"hex",
"multiexp",
"serde", "serde",
"serdect 0.4.3", "zeroize",
] ]
[[package]] [[package]]
@@ -2186,20 +2156,11 @@ 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",
] ]
[[package]]
name = "ff"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f"
dependencies = [
"rand_core 0.10.1",
"subtle",
]
[[package]] [[package]]
name = "fiat-crypto" name = "fiat-crypto"
version = "0.2.9" version = "0.2.9"
@@ -2398,9 +2359,9 @@ dependencies = [
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "1.4.2" version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb130435a959a8d525e6bca66ff6c40981a300ee96d70e3ef56f046556d614a3" checksum = "dab9e9188e97a93276e1fe7b56401b851e2b45a46d045ca658100c1303ada649"
dependencies = [ dependencies = [
"rustversion", "rustversion",
"serde_core", "serde_core",
@@ -2466,22 +2427,11 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [ dependencies = [
"ff 0.13.1", "ff",
"rand_core 0.6.4", "rand_core 0.6.4",
"subtle", "subtle",
] ]
[[package]]
name = "group"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4"
dependencies = [
"ff 0.14.0",
"rand_core 0.10.1",
"subtle",
]
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.13" version = "0.4.13"
@@ -2552,9 +2502,9 @@ checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]] [[package]]
name = "heapless" name = "heapless"
version = "0.9.3" version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
dependencies = [ dependencies = [
"hash32", "hash32",
"stable_deref_trait", "stable_deref_trait",
@@ -2587,6 +2537,15 @@ 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"
@@ -2652,13 +2611,12 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "hybrid-array" name = "hybrid-array"
version = "0.4.15" version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
dependencies = [ dependencies = [
"ctutils", "ctutils",
"serde", "serde",
"subtle",
"typenum", "typenum",
"zeroize", "zeroize",
] ]
@@ -3061,7 +3019,7 @@ checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"ecdsa", "ecdsa",
"elliptic-curve 0.13.8", "elliptic-curve",
"once_cell", "once_cell",
"serdect 0.2.0", "serdect 0.2.0",
"sha2 0.10.9", "sha2 0.10.9",
@@ -3071,7 +3029,7 @@ dependencies = [
[[package]] [[package]]
name = "kameo" name = "kameo"
version = "0.22.2" version = "0.22.2"
source = "git+https://github.com/hdbg/kameo.git?rev=3bbebac#3bbebac9f2a943be75588d80826e6bea45079d5f" source = "git+https://github.com/hdbg/kameo.git?rev=17af90e3#17af90e3ae95fc6f89fa31a2f1b9506ac127f0b7"
dependencies = [ dependencies = [
"downcast-rs", "downcast-rs",
"dyn-clone", "dyn-clone",
@@ -3085,7 +3043,7 @@ dependencies = [
[[package]] [[package]]
name = "kameo_actors" name = "kameo_actors"
version = "0.8.1" version = "0.8.1"
source = "git+https://github.com/hdbg/kameo.git?rev=3bbebac#3bbebac9f2a943be75588d80826e6bea45079d5f" source = "git+https://github.com/hdbg/kameo.git?rev=17af90e3#17af90e3ae95fc6f89fa31a2f1b9506ac127f0b7"
dependencies = [ dependencies = [
"futures", "futures",
"glob", "glob",
@@ -3097,12 +3055,12 @@ dependencies = [
[[package]] [[package]]
name = "kameo_macros" name = "kameo_macros"
version = "0.21.1" version = "0.21.1"
source = "git+https://github.com/hdbg/kameo.git?rev=3bbebac#3bbebac9f2a943be75588d80826e6bea45079d5f" source = "git+https://github.com/hdbg/kameo.git?rev=17af90e3#17af90e3ae95fc6f89fa31a2f1b9506ac127f0b7"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.4",
] ]
[[package]] [[package]]
@@ -3405,6 +3363,20 @@ 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"
@@ -3436,6 +3408,20 @@ 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"
@@ -3444,16 +3430,18 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [ dependencies = [
"num-integer", "num-integer",
"num-traits", "num-traits",
"rand 0.8.6",
"serde",
] ]
[[package]] [[package]]
name = "num-bigint" name = "num-complex"
version = "0.5.1" version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [ dependencies = [
"num-integer",
"num-traits", "num-traits",
"rand 0.8.6",
"serde", "serde",
] ]
@@ -3472,6 +3460,29 @@ 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"
@@ -4351,7 +4362,7 @@ dependencies = [
"bytes", "bytes",
"fastrlp 0.3.1", "fastrlp 0.3.1",
"fastrlp 0.4.0", "fastrlp 0.4.0",
"num-bigint 0.4.6", "num-bigint",
"num-integer", "num-integer",
"num-traits", "num-traits",
"parity-scale-codec", "parity-scale-codec",
@@ -4578,7 +4589,7 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [ dependencies = [
"base16ct 0.2.0", "base16ct",
"der 0.7.10", "der 0.7.10",
"generic-array 0.14.7", "generic-array 0.14.7",
"pkcs8 0.10.2", "pkcs8 0.10.2",
@@ -4587,21 +4598,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "sec1"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
dependencies = [
"base16ct 1.0.0",
"ctutils",
"der 0.8.0",
"hybrid-array",
"serdect 0.4.3",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "secp256k1" name = "secp256k1"
version = "0.30.0" version = "0.30.0"
@@ -4759,17 +4755,17 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177"
dependencies = [ dependencies = [
"base16ct 0.2.0", "base16ct",
"serde", "serde",
] ]
[[package]] [[package]]
name = "serdect" name = "serdect"
version = "0.4.3" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53"
dependencies = [ dependencies = [
"base16ct 1.0.0", "base16ct",
"serde", "serde",
] ]
@@ -4815,17 +4811,6 @@ dependencies = [
"keccak 0.2.0", "keccak 0.2.0",
] ]
[[package]]
name = "sha3"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759"
dependencies = [
"digest 0.11.2",
"keccak 0.2.0",
"sponge-cursor",
]
[[package]] [[package]]
name = "sha3-asm" name = "sha3-asm"
version = "0.1.6" version = "0.1.6"
@@ -4836,17 +4821,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "shake"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a"
dependencies = [
"digest 0.11.2",
"keccak 0.2.0",
"sponge-cursor",
]
[[package]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@@ -4960,6 +4934,12 @@ 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"
@@ -4980,12 +4960,6 @@ dependencies = [
"der 0.8.0", "der 0.8.0",
] ]
[[package]]
name = "sponge-cursor"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
[[package]] [[package]]
name = "sqlite-wasm-rs" name = "sqlite-wasm-rs"
version = "0.5.3" version = "0.5.3"
@@ -5010,6 +4984,17 @@ 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"
@@ -5115,9 +5100,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "3.0.5" version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -5766,23 +5751,21 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "vsss-rs" name = "vsss-rs"
version = "6.0.1" version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bfc736cfd88115aedb95ba84bc2d428fe351e92a56f69fce090af301402d91" checksum = "6ec751bdcc8bda099e269b24cc6b4ad14f9ce8b0490c1599174070e792ecd70c"
dependencies = [ dependencies = [
"crypto-bigint 0.7.5", "crypto-bigint 0.5.5",
"elliptic-curve 0.14.1", "crypto-bigint 0.6.1",
"elliptic-curve",
"elliptic-curve-tools", "elliptic-curve-tools",
"ff 0.14.0", "generic-array 1.4.1",
"generic-array 1.4.2",
"hex", "hex",
"hybrid-array", "hybrid-array",
"num-bigint 0.5.1", "num",
"num-traits", "rand_core 0.6.4",
"rand_core 0.10.1",
"serde", "serde",
"sha3 0.12.0", "sha3 0.10.9",
"shake",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@@ -6474,18 +6457,18 @@ dependencies = [
[[package]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.9.0" version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [ dependencies = [
"zeroize_derive", "zeroize_derive",
] ]
[[package]] [[package]]
name = "zeroize_derive" name = "zeroize_derive"
version = "1.5.0" version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
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 = "3bbebac"} kameo = {git = "https://github.com/hdbg/kameo.git", rev = "17af90e3"}
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "3bbebac"} kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "17af90e3"}
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,7 +21,6 @@ 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 }
@@ -77,8 +76,6 @@ 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
unused_async_trait_impl = "allow" # too pedantic
# restriction lints # restriction lints
alloc_instead_of_core = "warn" alloc_instead_of_core = "warn"
@@ -109,7 +106,6 @@ 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,3 +26,5 @@ 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.cast_signed()); let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos as i64);
let challenge = authn::AuthChallenge { let challenge = authn::AuthChallenge {
nonce: *challenge nonce: *challenge
.random .random

View File

@@ -94,6 +94,7 @@ impl ArbiterClient {
} }
#[cfg(feature = "evm")] #[cfg(feature = "evm")]
#[expect(clippy::unused_async, reason = "false positive")]
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, ArbiterClientError> { pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, ArbiterClientError> {
todo!("fetch EVM wallet list from server") todo!("fetch EVM wallet list from server")
} }

View File

@@ -8,6 +8,7 @@ use rand::RngExt;
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client"; pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator"; pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator";
pub static GOVERNANCE_CONTEXT: &[u8] = b"arbiter_governance_vote";
const NONCE_SIZE: usize = 32; const NONCE_SIZE: usize = 32;
@@ -90,6 +91,11 @@ impl PublicKey {
self.0 self.0
.verify_with_context(&challenge, context, &signature.0) .verify_with_context(&challenge, context, &signature.0)
} }
#[must_use]
pub fn verify_message(&self, message: &[u8], context: &[u8], signature: &Signature) -> bool {
self.0.verify_with_context(message, context, &signature.0)
}
} }
impl Signature { impl Signature {

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<F>(f: F) -> Self fn new_inline_default<F>(f: F) -> Self
where where
Self: Sized, Self: Sized,
T: Default, T: Default,
@@ -36,6 +36,14 @@ 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

@@ -31,7 +31,6 @@ 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
@@ -43,6 +42,7 @@ pem = "3.0.6"
sha2.workspace = true sha2.workspace = true
hmac.workspace = true hmac.workspace = true
alloy.workspace = true alloy.workspace = true
prost.workspace = true
prost-types.workspace = true prost-types.workspace = true
arbiter-tokens-registry.path = "../arbiter-tokens-registry" arbiter-tokens-registry.path = "../arbiter-tokens-registry"
anyhow = "1.0.102" anyhow = "1.0.102"
@@ -51,7 +51,8 @@ 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 = "6.0.1" vsss-rs = "5.4.0"
rand_core = "0.6"
[dev-dependencies] [dev-dependencies]
proptest = "1.11.0" proptest = "1.11.0"

View File

@@ -37,8 +37,7 @@ create table if not exists tls_history (
create table if not exists arbiter_settings ( create table if not exists arbiter_settings (
id INTEGER not null PRIMARY KEY CHECK (id = 1), -- singleton row, id must be 1 id INTEGER not null PRIMARY KEY CHECK (id = 1), -- singleton row, id must be 1
root_key_id integer references root_key_history (id) on delete RESTRICT, -- if null, means wasn't bootstrapped yet root_key_id integer references root_key_history (id) on delete RESTRICT, -- if null, means wasn't bootstrapped yet
tls_id integer references tls_history (id) on delete RESTRICT, tls_id integer references tls_history (id) on delete RESTRICT
shamir_threshold integer
) STRICT; ) STRICT;
insert into arbiter_settings (id) values (1) on conflict do nothing; insert into arbiter_settings (id) values (1) on conflict do nothing;
@@ -57,7 +56,7 @@ create table if not exists operator (
share blob not null, share blob not null,
share_nonce blob not null, share_nonce blob not null,
share_salt blob not null, share_salt blob not null default (randomblob(32)),
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'))
@@ -217,3 +216,73 @@ create table if not exists integrity_envelope (
) STRICT; ) STRICT;
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id); create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);
create table if not exists proposal (
id integer not null primary key,
kind text not null,
payload blob not null,
initiator_id integer not null references operator_identity(id) on delete restrict,
created_at integer not null default(unixepoch('now')),
expires_at integer not null,
status text not null default 'pending'
check (status in ('pending', 'approved', 'rejected', 'expired'))
) STRICT;
create table if not exists proposal_vote (
id integer not null primary key,
proposal_id integer not null references proposal(id) on delete cascade,
operator_id integer not null references operator_identity(id) on delete restrict,
approve integer not null check (approve in (0, 1)),
signature blob not null,
voted_at integer not null default(unixepoch('now')),
unique (proposal_id, operator_id)
) STRICT;
create table if not exists proposal_result (
proposal_id integer not null primary key references proposal(id) on delete cascade,
data blob not null,
created_at integer not null default(unixepoch('now'))
) STRICT;
-- ===============================
-- Recovery Operators (§3.4/§3.5/§3.6)
-- ===============================
-- Encrypted Shamir shares for recovery operators (mirrors the `operator` table).
create table if not exists recovery_operator (
id integer not null primary key references recovery_operator_identity(id) on delete restrict,
share blob not null,
share_nonce blob not null,
share_salt blob not null,
created_at integer not null default(unixepoch('now')),
updated_at integer not null default(unixepoch('now'))
) STRICT;
create table if not exists recovery_operator_identity (
id integer not null primary key,
public_key blob not null unique,
created_at integer not null default(unixepoch('now')),
updated_at integer not null default(unixepoch('now'))
) STRICT;
-- One active wakeup request at a time. A request is pending when cancelled_at IS NULL
-- and requested_at + 14 days > now. It becomes active (recovery live) after 14 days.
create table if not exists recovery_wakeup_request (
id integer not null primary key,
requested_by integer not null references operator_identity(id) on delete restrict,
requested_at integer not null default(unixepoch('now')),
cancelled_by integer references operator_identity(id) on delete restrict,
cancelled_at integer
) STRICT;
-- Votes cast by recovery operators; only allowed on replace_operator proposals.
create table if not exists recovery_proposal_vote (
id integer not null primary key,
proposal_id integer not null references proposal(id) on delete cascade,
recovery_operator_id integer not null references recovery_operator_identity(id) on delete restrict,
approve integer not null check (approve in (0, 1)),
signature blob not null,
voted_at integer not null default(unixepoch('now')),
unique (proposal_id, recovery_operator_id)
) STRICT;

View File

@@ -1,164 +1,98 @@
use crate::{ use crate::db::{self, DatabasePool, schema};
actors::vault::events,
db::{self, 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::{ use kameo::{Actor, messages};
Actor, use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
actor::ActorRef,
messages,
prelude::{Context, Message},
};
use kameo_actors::message_bus::{MessageBus, Register};
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 tracing::warn; use thiserror::Error;
const TOKEN_LENGTH: usize = 64; const TOKEN_LENGTH: usize = 64;
async fn write_token_file(path: &Path, content: &str) -> Result<(), std::io::Error> { pub async fn generate_token() -> Result<String, std::io::Error> {
if let Some(parent) = path.parent() { let rng: StdRng = make_rng();
tokio::fs::create_dir_all(parent).await?;
} let token = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold(
tokio::fs::write(path, content.as_bytes()).await?; String::default(),
#[cfg(unix)] |mut accum, char| {
{ accum += char.to_string().as_str();
use std::os::unix::fs::PermissionsExt as _; accum
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?; },
} );
Ok(())
tokio::fs::write(home_path()?.join(BOOTSTRAP_PATH), token.as_str()).await?;
Ok(token)
} }
async fn remove_token_file(path: &Path) { #[derive(Error, Debug)]
match tokio::fs::remove_file(path).await {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => warn!(?error, ?path, "Failed to remove bootstrap token file"),
}
}
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, byte) in buf
.iter_mut()
.zip(UnwrapErr(SysRng).sample_iter(Alphanumeric))
{
*slot = byte;
}
}
let token = cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned());
write_token_file(path, &token).await?;
Ok(cell)
}
#[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
#[error("Database error: {0}")] #[error("Database error: {0}")]
Database(#[from] db::PoolError), Database(#[from] db::PoolError),
#[error("I/O error: {0}")] #[error("I/O error: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("Database query error: {0}")] #[error("Database query error: {0}")]
Query(#[from] diesel::result::Error), Query(#[from] diesel::result::Error),
} }
/// Custodian of the one-time bootstrap token. #[derive(Actor)]
///
/// The token authorises registering operator identities before the vault
/// exists. A Shamir committee needs every member registered before it can be
/// declared, so the token stays valid across several registrations and is
/// retired by the `Bootstrapped` event rather than by first use, whichever
/// bootstrap path fired it.
///
/// Every daemon start mints a fresh token and overwrites the file: a token
/// handed out by an earlier run is dead.
pub struct Bootstrapper { pub struct Bootstrapper {
token: Option<SafeCell<[u8; TOKEN_LENGTH]>>, token: Option<String>,
token_path: Option<PathBuf>,
events: ActorRef<MessageBus>,
}
impl Actor for Bootstrapper {
type Args = Self;
type Error = std::convert::Infallible;
async fn on_start(args: Self::Args, actor_ref: ActorRef<Self>) -> Result<Self, Self::Error> {
let _ = args
.events
.tell(Register(actor_ref.recipient::<events::Bootstrapped>()))
.await;
Ok(args)
}
} }
impl Bootstrapper { impl Bootstrapper {
pub async fn new(db: &db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> { pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
let path = home_path()?.join(BOOTSTRAP_PATH); let row_count: i64 = {
let mut conn = db.get().await?; let mut conn = db.get().await?;
let bootstrapped = schema::arbiter_settings::table schema::operator::table
.select(schema::arbiter_settings::root_key_id) .count()
.first::<Option<i32>>(&mut conn) .get_result(&mut conn)
.await? .await?
.is_some(); };
if bootstrapped {
// A token file can outlive the bootstrap that made it obsolete:
// the daemon may have been killed before the event was handled.
remove_token_file(&path).await;
return Ok(Self {
token: None,
token_path: None,
events,
});
}
Ok(Self { let token = if row_count == 0 {
token: Some(generate_token(&path).await?), let token = generate_token().await?;
token_path: Some(path), Some(token)
events, } else {
}) None
} };
async fn forget(&mut self) { Ok(Self { token })
self.token = None;
if let Some(path) = self.token_path.take() {
remove_token_file(&path).await;
}
} }
} }
impl Message<events::Bootstrapped> for Bootstrapper {
type Reply = ();
async fn handle(
&mut self,
_: events::Bootstrapped,
_ctx: &mut Context<Self, Self::Reply>,
) -> Self::Reply {
self.forget().await;
}
}
#[messages] #[messages]
impl Bootstrapper { impl Bootstrapper {
#[message] #[message]
pub fn verify_token(&mut self, token: Vec<u8>) -> bool { pub fn is_correct_token(&self, token: String) -> bool {
self.token.as_mut().is_some_and(|expected| { self.token.as_ref().is_some_and(|expected| {
expected.read_inline(|bytes| bool::from(bytes.as_ref().ct_eq(token.as_slice()))) let expected_bytes = expected.as_bytes();
let token_bytes = token.as_bytes();
let choice = expected_bytes.ct_eq(token_bytes);
bool::from(choice)
}) })
} }
#[message] #[message]
pub fn get_token(&mut self) -> Option<String> { pub fn consume_token(&mut self, token: String) -> bool {
self.token if self.is_correct_token(token) {
.as_mut() self.token = None;
.map(|cell| cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned())) true
} else {
false
}
}
}
#[messages]
impl Bootstrapper {
#[message]
pub fn get_token(&self) -> Option<String> {
self.token.clone()
} }
} }

View File

@@ -1,6 +1,6 @@
use crate::{ use crate::{
actors::vault::{CreateNew, Decrypt, Vault}, actors::vault::{CreateNew, Decrypt, Vault},
crypto::integrity::{self, Integrable}, crypto::integrity,
db::{ db::{
DatabaseError, DatabasePool, DatabaseError, DatabasePool,
models::{self, EvmWalletId}, models::{self, EvmWalletId},
@@ -25,35 +25,14 @@ 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),
@@ -85,12 +64,6 @@ 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>,
@@ -124,39 +97,20 @@ impl EvmActor {
let aead_id: i32 = self let aead_id: i32 = self
.vault .vault
.ask(CreateNew { .ask(CreateNew { plaintext })
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 = conn let wallet_id = insert_into(schema::evm_wallet::table)
.exclusive_transaction(async |conn| { .values(&models::NewEvmWallet {
let wallet_id: i32 = insert_into(schema::evm_wallet::table) address: address.as_slice().to_vec(),
.values(&models::NewEvmWallet { aead_encrypted_id: aead_id,
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)
}) })
.await?; .returning(schema::evm_wallet::id)
.get_result(&mut conn)
.await
.map_err(DatabaseError::from)?;
Ok((wallet_id, address)) Ok((wallet_id, address))
} }
@@ -206,14 +160,23 @@ impl EvmActor {
} }
#[message] #[message]
pub async fn useragent_delete_grant( pub async fn operator_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
&mut self, let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
grant_id: i32,
) -> Result<(), Error> { let affected = diesel::update(schema::evm_basic_grant::table)
self.engine .filter(schema::evm_basic_grant::id.eq(grant_id))
.revoke_grant(grant_id) .set(schema::evm_basic_grant::revoked_at.eq(models::SqliteTimestamp::now()))
.execute(&mut conn)
.await .await
.map_err(Error::from) .map_err(DatabaseError::from)?;
if affected == 0 {
return Err(Error::Database(DatabaseError::from(
diesel::result::Error::NotFound,
)));
}
Ok(())
} }
#[message] #[message]
@@ -287,51 +250,16 @@ 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.to_raw(),
)
.await
.map_err(|e| {
error!(?e, ?wallet.id, "EVM wallet integrity check failed");
SignTransactionError::Internal
})?;
drop(conn);
if attestation != integrity::AttestationStatus::Attested {
error!(
?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,9 +11,7 @@ use kameo::{
prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef}, prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef},
reply::ReplySender, reply::ReplySender,
}; };
use std::{ops::ControlFlow, time::Duration}; use std::ops::ControlFlow;
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(30);
pub struct Args { pub struct Args {
pub client: ClientProfile, pub client: ClientProfile,
@@ -66,14 +64,6 @@ 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)
} }
@@ -114,14 +104,4 @@ 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,8 +20,6 @@ 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>,
} }
@@ -29,7 +27,6 @@ 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,
} }
} }
@@ -51,7 +48,6 @@ 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",
@@ -79,28 +75,14 @@ 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(), client_id, actor = "FlowCoordinator", event = "client.connected"); info!(id = %actor.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,7 +1,8 @@
use crate::{ use crate::{
actors::{ actors::{
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator, bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
operator_registry::OperatorRegistry, vault::Vault, vault_coordinator::VaultCoordinator, operator_registry::OperatorRegistry, proposal_manager::ProposalManager, vault::Vault,
vault_coordinator::VaultCoordinator,
}, },
db, db,
}; };
@@ -14,6 +15,7 @@ pub mod bootstrap;
pub mod evm; pub mod evm;
pub mod flow_coordinator; pub mod flow_coordinator;
pub mod operator_registry; pub mod operator_registry;
pub mod proposal_manager;
pub mod vault; pub mod vault;
pub mod vault_coordinator; pub mod vault_coordinator;
@@ -21,20 +23,21 @@ pub mod vault_coordinator;
pub enum SpawnError { pub enum SpawnError {
#[error("Failed to spawn Bootstrapper actor")] #[error("Failed to spawn Bootstrapper actor")]
Bootstrapper(#[from] bootstrap::Error), Bootstrapper(#[from] bootstrap::Error),
#[error("Failed to spawn Vault actor")] #[error("Failed to spawn Vault actor")]
Vault(#[from] vault::Error), Vault(#[from] vault::Error),
#[error("Failed to spawn VaultCoordinator actor")]
VaultCoordinator(#[from] vault_coordinator::Error),
} }
/// Long-lived actors that are shared across all connections and handle global state and operations
#[derive(Clone)] #[derive(Clone)]
pub struct GlobalActors { pub struct GlobalActors {
pub vault: ActorRef<Vault>, pub vault: ActorRef<Vault>,
pub vault_coordinator: ActorRef<VaultCoordinator>,
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>,
} }
@@ -44,22 +47,30 @@ impl GlobalActors {
} }
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> { pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
let events = Self::spawn_message_bus(); let message_bus = Self::spawn_message_bus();
let vault = Vault::spawn(Vault::new(db.clone(), events.clone()).await?); let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
let bootstrapper = Bootstrapper::spawn(Bootstrapper::new(&db, events.clone()).await?);
let vault_coordinator =
VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault.clone()));
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: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
evm: EvmActor::spawn(EvmActor::new(vault.clone(), db.clone())), proposal_manager: ProposalManager::spawn(ProposalManager::new(
vault, db,
key_holder.clone(),
evm.clone(),
vault_coordinator.clone(),
)),
vault: key_holder,
vault_coordinator, 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, events: message_bus,
evm,
}) })
} }
} }

View File

@@ -20,8 +20,8 @@ impl Actor for OperatorRegistry {
type Error = Infallible; type Error = Infallible;
fn on_start(args: Self::Args, _: ActorRef<Self>) -> impl Future<Output = Result<Self, Self::Error>> { async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
std::future::ready(Ok(args)) Ok(args)
} }
async fn on_link_died( async fn on_link_died(

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +6,10 @@ use crate::{
}, },
db::{ db::{
self, self,
custody::CustodyRecord,
models::{self, RootKeyHistory, RootKeyHistoryId}, models::{self, RootKeyHistory, RootKeyHistoryId},
schema::{self}, schema,
}, },
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use chrono::Utc; use chrono::Utc;
@@ -24,7 +22,7 @@ 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, warn}; use tracing::{error, info};
pub mod events { pub mod events {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -47,8 +45,6 @@ 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,
@@ -62,14 +58,8 @@ pub enum Error {
#[error("Database transaction error: {0}")] #[error("Database transaction error: {0}")]
DatabaseTransaction(#[from] diesel::result::Error), DatabaseTransaction(#[from] diesel::result::Error),
#[error("Custody storage error: {0}")]
Custody(#[from] db::custody::Error),
#[error("Broken database")] #[error("Broken database")]
BrokenDatabase, BrokenDatabase,
#[error("Integrity key version mismatch: envelope uses key {envelope:?}, current key is {current:?}")]
KeyVersionMismatch { envelope: RootKeyHistoryId, current: RootKeyHistoryId },
} }
struct Unsealed { struct Unsealed {
@@ -89,8 +79,6 @@ enum State {
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.
@@ -100,10 +88,8 @@ 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 = {
@@ -123,15 +109,10 @@ impl Vault {
} }
}; };
Ok(Self { Ok(Self { db, state, events })
db,
state,
events,
unseal_failures: 0,
})
} }
// Exclusive transaction to avoid race condtions if multiple vaults write // Exclusive transaction to avoid race conditions 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, pool: &db::DatabasePool,
@@ -176,36 +157,37 @@ impl Vault {
State::Sealed { .. } => Err(Error::Sealed), State::Sealed { .. } => Err(Error::Sealed),
} }
} }
}
/// Create the root key and take the vault into the unsealed state. #[messages]
impl Vault {
#[message] #[message]
pub async fn bootstrap( pub async fn bootstrap(&mut self, mut seal_key: KeyCell) -> Result<(), Error> {
&mut self, if !matches!(&self.state, State::Unbootstrapped) {
mut seal_key: KeyCell,
custody: Option<CustodyRecord>,
) -> Result<(), Error> {
if !matches!(self.state, State::Unbootstrapped) {
return Err(Error::AlreadyBootstrapped); return Err(Error::AlreadyBootstrapped);
} }
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();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| { // Generate salt (kept for schema compat)
let root_key_reader = reader.as_slice(); let root_key_salt = v1::generate_salt();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|rk| {
seal_key seal_key
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader) .encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, rk.as_slice())
.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 = insert_into(schema::root_key_history::table)
@@ -215,7 +197,7 @@ impl Vault {
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: v1::generate_salt().to_vec(), salt: root_key_salt.to_vec(),
}) })
.returning(schema::root_key_history::id) .returning(schema::root_key_history::id)
.get_result(&mut *conn) .get_result(&mut *conn)
@@ -226,11 +208,9 @@ impl Vault {
.execute(&mut *conn) .execute(&mut *conn)
.await?; .await?;
if let Some(record) = custody.as_ref() { Result::<_, diesel::result::Error>::Ok(RootKeyHistoryId::from_raw(
db::custody::write_record(&mut *conn, record).await?; root_key_history_id,
} ))
Result::<_, Error>::Ok(RootKeyHistoryId::from_raw(root_key_history_id))
}) })
.await?; .await?;
@@ -247,62 +227,46 @@ impl Vault {
#[message] #[message]
pub async fn try_unseal(&mut self, mut seal_key: KeyCell) -> Result<(), Error> { pub async fn try_unseal(&mut self, mut seal_key: KeyCell) -> 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 KDF work // We don't want to hold connection while doing expensive 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 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
})?; })?;
if seal_key let mut root_key_bytes = SafeCell::new(current_key.ciphertext.clone());
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key) seal_key
.is_err() .decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key_bytes)
{ .map_err(|err| {
self.unseal_failures += 1; error!(?err, "Failed to unseal root key: invalid seal key");
if self.unseal_failures >= MAX_UNSEAL_ATTEMPTS { Error::InvalidKey
error!( })?;
attempts = self.unseal_failures,
"Vault locked: maximum failed unseal attempts reached" let root_key = KeyCell::try_from(root_key_bytes).map_err(|()| {
); error!("Broken database: invalid encryption key size");
} else { Error::BrokenDatabase
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: KeyCell::try_from(root_key).map_err(|err| { root_key,
error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase
})?,
}); });
info!("Vault unsealed successfully"); info!("Vault unsealed successfully");
@@ -311,10 +275,79 @@ impl Vault {
Ok(()) Ok(())
} }
/// Decrypts an AEAD entry. The `aad` must match the value used at encryption time; /// Re-encrypts the root key with `new_seal_key` and records a new root_key_history row.
/// a mismatch causes authentication failure, preventing cross-wallet key swaps. /// Called after a Shamir re-key so the old seal key is no longer sufficient to unseal.
#[message] #[message]
pub async fn decrypt(&mut self, aead_id: i32, aad: Vec<u8>) -> Result<SafeCell<Vec<u8>>, Error> { pub async fn rekey_root_key(&mut self, mut new_seal_key: KeyCell) -> Result<(), Error> {
let Unsealed {
root_key,
root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?;
let new_nonce = Nonce::default();
let new_salt = v1::generate_salt();
let new_ciphertext: Vec<u8> = root_key.0.read_inline(|rk| {
new_seal_key
.encrypt(&new_nonce, v1::ROOT_KEY_TAG, rk.as_slice())
.map_err(|err| {
error!(?err, "Fatal rekey error");
Error::Encryption(err)
})
})?;
let data_encryption_nonce = Nonce::default();
let mut conn = self.db.get().await?;
let new_root_key_history_id: i32 = conn
.transaction(async |conn| {
let new_id = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: new_ciphertext,
tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: new_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce.to_vec(),
schema_version: 1,
salt: new_salt.to_vec(),
})
.returning(schema::root_key_history::id)
.get_result::<i32>(&mut *conn)
.await?;
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(new_id))
.execute(&mut *conn)
.await?;
Result::<_, diesel::result::Error>::Ok(new_id)
})
.await?;
*root_key_history_id = RootKeyHistoryId::from_raw(new_root_key_history_id);
info!("Vault root key rekeyed successfully");
Ok(())
}
#[message]
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 = {
@@ -336,15 +369,13 @@ 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, &aad, &mut output)?; root_key.decrypt_in_place(&nonce, v1::TAG, &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>>, aad: Vec<u8>) -> Result<i32, Error> { pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> {
let Unsealed { let Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
@@ -356,7 +387,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, &aad, &mut *ciphertext_buffer)?; root_key.encrypt_in_place(&nonce, v1::TAG, &mut *ciphertext_buffer)?;
let ciphertext = std::mem::take(ciphertext_buffer); let ciphertext = std::mem::take(ciphertext_buffer);
@@ -416,10 +447,7 @@ 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 Err(Error::KeyVersionMismatch { return Ok(false);
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| {
@@ -431,25 +459,12 @@ impl Vault {
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)]
mod tests { mod tests {
use crate::actors::GlobalActors; use crate::actors::GlobalActors;
use arbiter_crypto::safecell::SafeCellHandle as _;
use super::*; use super::*;
@@ -457,8 +472,7 @@ 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();
let seal_key = KeyCell::from([0u8; 32]); actor.bootstrap(KeyCell::from([0u8; 32])).await.unwrap();
actor.bootstrap(seal_key, None).await.unwrap();
actor actor
} }
@@ -467,13 +481,12 @@ 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)
@@ -493,7 +506,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()), b"test-aad".to_vec()) .create_new(SafeCell::new(b"post-interleave".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,107 +1,75 @@
//! Coordinates the multi-operator ceremonies that create and open the vault. use std::collections::HashMap;
//!
//! The coordinator collects one passphrase per committee member, then hands the
//! assembled material to [`Vault`] in a single message. It owns no Diesel code:
//! everything it reads or writes goes through the [`db::custody`] functions.
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use argon2::RECOMMENDED_SALT_LEN; use diesel::{ExpressionMethods as _, QueryDsl};
use kameo::{Actor, actor::ActorRef, error::SendError, messages}; use diesel_async::RunQueryDsl;
use rand::rngs::SysRng; use kameo::{Actor, actor::ActorRef, messages};
use rand_core::{Rng as _, UnwrapErr}; use rand_core::{OsRng, RngCore as _};
use tracing::error;
use crate::{ use crate::{
actors::vault::{self, Bootstrap, TryUnseal, Vault}, actors::vault::{Bootstrap, RekeyRootKey, TryUnseal, Vault},
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir}, crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir, shamir::shamir_threshold},
db::{ db::{self, models, schema},
self,
custody::{CustodyRecord, EncryptedShare},
models::OperatorId,
},
}; };
const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
#[error("An ordinary committee is already being coordinated")] #[error("Already coordinating a bootstrap")]
AlreadyBootstrapping, AlreadyBootstrapping,
#[error("An unseal is already being coordinated")] #[error("Already coordinating an unseal")]
AlreadyUnsealing, AlreadyUnsealing,
#[error("Bootstrap is not in progress")] #[error("Rekey not in progress")]
NotRekeying,
#[error("Bootstrap not in progress")]
NotBootstrapping, NotBootstrapping,
#[error("The operator already contributed")] #[error("Unseal not in progress")]
NotUnsealing,
#[error("Operator already contributed")]
DuplicateContribution, DuplicateContribution,
#[error("The ordinary committee cannot be empty")] #[error("Operator not found in database")]
EmptyCommittee, OperatorNotFound,
#[error("Two-operator committees are unsupported")] #[error("Invalid passphrase (decryption failed)")]
UnsupportedCommittee,
#[error(
"The ordinary committee cannot exceed {} members",
shamir::MAX_COMMITTEE_SIZE
)]
CommitteeTooLarge,
#[error("Invalid passphrase")]
InvalidPassphrase, InvalidPassphrase,
#[error("Broken database")]
BrokenDatabase,
#[error("Shamir error: {0}")] #[error("Shamir error: {0}")]
Shamir(String), Shamir(String),
#[error("Database connection error: {0}")] #[error("Database connection error: {0}")]
DatabaseConnection(#[from] db::PoolError), DatabaseConnection(#[from] db::PoolError),
#[error("Custody storage error: {0}")] #[error("Database query error: {0}")]
Custody(#[from] db::custody::Error), DatabaseQuery(#[from] diesel::result::Error),
#[error("Encryption error")] #[error("Encryption error")]
Encryption, Encryption,
#[error("The vault is already bootstrapped")]
AlreadyBootstrapped,
#[error("Vault error")] #[error("Vault error")]
Vault, VaultError,
} #[error("Two-operator vaults require at least one recovery share")]
TwoOperatorsRequireRecovery,
/// Passphrases gathered so far, in contribution order. #[error("Broken database")]
/// BrokenDatabase,
/// A `Vec` rather than a map because [`SafeCell`] values are neither cloneable
/// nor hashable, and a committee holds at most
/// [`shamir::MAX_COMMITTEE_SIZE`] of them.
#[derive(Default)]
struct Contributions(Vec<(OperatorId, SafeCell<Vec<u8>>)>);
impl Contributions {
fn contains(&self, operator_id: OperatorId) -> bool {
self.0.iter().any(|(id, _)| *id == operator_id)
}
fn put(&mut self, operator_id: OperatorId, passphrase: SafeCell<Vec<u8>>) {
match self.0.iter_mut().find(|(id, _)| *id == operator_id) {
Some(slot) => slot.1 = passphrase,
None => self.0.push((operator_id, passphrase)),
}
}
const fn len(&self) -> usize {
self.0.len()
}
fn operators(&self) -> Vec<OperatorId> {
self.0.iter().map(|(id, _)| *id).collect()
}
} }
// Passphrases stored as plain Vec<u8> (not SafeCell) so CoordinatorState is Sync.
// They are ephemeral and dropped immediately after use.
enum CoordinatorState { enum CoordinatorState {
Idle, Idle,
Bootstrapping { Bootstrapping {
/// The operator that declared the committee. Only they may re-declare
/// it, which is the way out of a ceremony the others never finish.
declarer: OperatorId,
declared_count: usize, declared_count: usize,
contributions: Contributions, recovery_count: usize,
retryable: bool, passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
}, },
Unsealing { Unsealing {
threshold: usize, threshold: usize,
contributions: Contributions, ordinary_passphrases: HashMap<i32, Vec<u8>>,
retryable: bool, 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>>,
}, },
} }
@@ -122,257 +90,632 @@ impl VaultCoordinator {
} }
} }
/// Explain why a committee size was rejected. const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
const fn committee_error(declared_count: usize) -> Error {
match declared_count {
0 => Error::EmptyCommittee,
2 => Error::UnsupportedCommittee,
_ => Error::CommitteeTooLarge,
}
}
fn encrypt_share( fn encrypt_share(
passphrase: &mut SafeCell<Vec<u8>>, passphrase_bytes: Vec<u8>,
share: &[u8], share: &[u8],
) -> Result<EncryptedShare, Error> { ) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), Error> {
let mut salt = [0u8; RECOMMENDED_SALT_LEN]; let mut share_salt = vec![0u8; 32];
UnwrapErr(SysRng).fill_bytes(&mut salt); 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 nonce = Nonce::default();
let ciphertext = derive_key(passphrase, &salt) let encrypted_share = share_seal_key
.encrypt(&nonce, SHARE_AAD, share) .encrypt(&nonce, SHARE_AAD, share)
.map_err(|_| Error::Encryption)?; .map_err(|_| Error::Encryption)?;
Ok(EncryptedShare { Ok((encrypted_share, nonce.to_vec(), share_salt))
ciphertext,
nonce: nonce.to_vec(),
salt: salt.to_vec(),
})
} }
fn decrypt_share( fn decrypt_share(
passphrase: &mut SafeCell<Vec<u8>>, passphrase_bytes: Vec<u8>,
share: EncryptedShare, encrypted_share: Vec<u8>,
) -> Result<SafeCell<Vec<u8>>, Error> { share_nonce_bytes: &[u8],
let nonce = Nonce::try_from(share.nonce.as_slice()).map_err(|()| Error::BrokenDatabase)?; 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 buffer = SafeCell::new(share.ciphertext); let mut passphrase_cell = SafeCell::new(passphrase_bytes);
derive_key(passphrase, &share.salt) let mut share_seal_key = derive_key(&mut passphrase_cell, share_salt);
.decrypt_in_place(&nonce, SHARE_AAD, &mut buffer)
let mut share_buffer = SafeCell::new(encrypted_share);
share_seal_key
.decrypt_in_place(&nonce, SHARE_AAD, &mut share_buffer)
.map_err(|_| Error::InvalidPassphrase)?; .map_err(|_| Error::InvalidPassphrase)?;
Ok(buffer) Ok(share_buffer.read().clone())
} }
const fn bootstrap_error(error: &SendError<Bootstrap, vault::Error>) -> Error { /// §3.4: Split the seal key across ordinary + recovery operators.
match error { /// Threshold = `shamir_threshold(ordinary_count)`; total shares = ordinary + recovery.
SendError::HandlerError(vault::Error::AlreadyBootstrapped) => Error::AlreadyBootstrapped, /// When `ordinary_count` == 1 (threshold = 1), vsss-rs does not support a proper split,
_ => Error::Vault, /// so each share is the seal key itself — any single participant can reconstruct.
}
}
/// Build the custody record and hand it to the vault, which stores it in the
/// same transaction as the root key.
async fn finalize_bootstrap( async fn finalize_bootstrap(
vault: &ActorRef<Vault>, db: db::DatabasePool,
contributions: &mut Contributions, vault: ActorRef<Vault>,
ordinary_passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let total = contributions.len(); let ordinary_count = ordinary_passphrases.len();
let threshold = shamir::shamir_threshold(total).ok_or_else(|| committee_error(total))?; let recovery_count = recovery_passphrases.len();
let total = ordinary_count + recovery_count;
let threshold = shamir_threshold(ordinary_count);
let mut seal_key = KeyCell::new_secure_random(); let mut seal_key_bytes = [0u8; 32];
let mut shares = shamir::split_key(threshold, total, &mut seal_key, UnwrapErr(SysRng)) OsRng.fill_bytes(&mut seal_key_bytes);
.map_err(|error| Error::Shamir(error.to_string()))?;
if shares.len() < total { // threshold == 1 means any single share reconstructs the key (degenerate split).
return Err(Error::Shamir("missing share for operator".to_owned())); // 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)
let mut encrypted = Vec::with_capacity(total); .map_err(|e| Error::Shamir(e.to_string()))?
for ((operator_id, passphrase), share) in contributions.0.iter_mut().zip(shares.iter_mut()) { } else {
let share = share.read_inline(|share| encrypt_share(passphrase, share))?; std::iter::repeat_with(|| seal_key_bytes.to_vec()).take(total).collect()
encrypted.push((*operator_id, share));
}
vault
.ask(Bootstrap {
seal_key,
custody: Some(CustodyRecord {
threshold,
shares: encrypted,
}),
})
.await
.map_err(|error| bootstrap_error(&error))
}
/// Reconstruct the seal key from the contributed passphrases and unseal.
async fn finalize_unseal(
db: &db::DatabasePool,
vault: &ActorRef<Vault>,
threshold: usize,
contributions: &mut Contributions,
) -> Result<(), Error> {
let stored = {
let mut conn = db.get().await?;
db::custody::shares(&mut conn, &contributions.operators()).await?
}; };
let mut plaintext = Vec::with_capacity(stored.len()); let seal_key = KeyCell::from(seal_key_bytes);
for ((_, passphrase), share) in contributions.0.iter_mut().zip(stored) {
plaintext.push(decrypt_share(passphrase, share)?); 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?;
} }
let seal_key = shamir::combine_shares(threshold, &mut plaintext) for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
.map_err(|error| Error::Shamir(error.to_string()))?; 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 vault
.ask(TryUnseal { seal_key }) .ask(RekeyRootKey { new_seal_key })
.await .await
.map_err(|_| Error::Vault) .map_err(|err| {
error!(?err, "Vault rekey failed");
Error::VaultError
})?;
Ok(())
} }
#[messages] #[messages]
impl VaultCoordinator { impl VaultCoordinator {
/// Announce how many operators will contribute to the bootstrap. /// Phase 1 of multi-operator bootstrap: declare the committee size.
///
/// The declaring operator may re-declare to restart the ceremony; that is
/// the only way to release a committee whose members never all show up.
#[message] #[message]
pub fn start_bootstrap( #[expect(clippy::unused_async, reason = "kameo requires messages to be async")]
pub async fn start_bootstrap(
&mut self, &mut self,
operator_id: OperatorId, operator_id: i32,
declared_count: usize, declared_count: usize,
recovery_count: usize,
) -> Result<(), Error> { ) -> Result<(), Error> {
if shamir::shamir_threshold(declared_count).is_none() { let _ = operator_id; // fixme!: any authenticated operator may announce the committee size. the first call wins
return Err(committee_error(declared_count)); if !matches!(self.state, CoordinatorState::Idle) {
return Err(Error::AlreadyBootstrapping);
} }
if declared_count == 2 && recovery_count == 0 {
match &self.state { return Err(Error::TwoOperatorsRequireRecovery);
CoordinatorState::Unsealing { .. } => return Err(Error::AlreadyUnsealing),
CoordinatorState::Bootstrapping { declarer, .. } if *declarer != operator_id => {
return Err(Error::AlreadyBootstrapping);
}
CoordinatorState::Bootstrapping { .. } | CoordinatorState::Idle => {}
} }
self.state = CoordinatorState::Bootstrapping { self.state = CoordinatorState::Bootstrapping {
declarer: operator_id,
declared_count, declared_count,
contributions: Contributions::default(), recovery_count,
retryable: false, passphrases: HashMap::new(),
recovery_passphrases: HashMap::new(),
}; };
Ok(()) 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] #[message]
pub async fn contribute_bootstrap( pub async fn contribute_bootstrap(
&mut self, &mut self,
operator_id: OperatorId, operator_id: i32,
passphrase: SafeCell<Vec<u8>>, mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> { ) -> Result<bool, Error> {
let CoordinatorState::Bootstrapping { let CoordinatorState::Bootstrapping {
declared_count, declared_count,
contributions, recovery_count,
retryable, passphrases,
.. recovery_passphrases,
} = &mut self.state } = &mut self.state
else { else {
return Err(Error::NotBootstrapping); return Err(Error::NotBootstrapping);
}; };
if contributions.contains(operator_id) && !*retryable { if passphrases.contains_key(&operator_id) {
return Err(Error::DuplicateContribution); return Err(Error::DuplicateContribution);
} }
contributions.put(operator_id, passphrase);
*retryable = false;
if contributions.len() < *declared_count { 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); return Ok(false);
} }
let state = std::mem::replace(&mut self.state, CoordinatorState::Idle);
let CoordinatorState::Bootstrapping { let CoordinatorState::Bootstrapping {
declarer, passphrases,
declared_count, recovery_passphrases,
mut contributions,
.. ..
} = state } = std::mem::replace(&mut self.state, CoordinatorState::Idle)
else { else {
unreachable!("state was matched as Bootstrapping above") unreachable!()
}; };
match finalize_bootstrap(&self.vault, &mut contributions).await { finalize_bootstrap(
Ok(()) => Ok(true), self.db.clone(),
Err(error) => { self.vault.clone(),
self.state = CoordinatorState::Bootstrapping { passphrases,
declarer, recovery_passphrases,
declared_count, )
contributions, .await?;
retryable: true, Ok(true)
};
Err(error)
}
}
} }
/// 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] #[message]
pub async fn contribute_unseal( pub async fn contribute_unseal(
&mut self, &mut self,
operator_id: OperatorId, operator_id: i32,
passphrase: SafeCell<Vec<u8>>, mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> { ) -> Result<bool, Error> {
if matches!(self.state, CoordinatorState::Idle) { self.ensure_unsealing_state().await?;
let threshold = {
let mut conn = self.db.get().await?;
db::custody::threshold(&mut conn).await?
};
self.state = CoordinatorState::Unsealing {
threshold,
contributions: Contributions::default(),
retryable: false,
};
}
let CoordinatorState::Unsealing { let CoordinatorState::Unsealing {
threshold, threshold,
contributions, ordinary_passphrases,
retryable, recovery_passphrases,
} = &mut self.state } = &mut self.state
else { else {
return Err(Error::AlreadyBootstrapping); return Err(Error::NotUnsealing);
}; };
if contributions.contains(operator_id) && !*retryable { if ordinary_passphrases.contains_key(&operator_id) {
return Err(Error::DuplicateContribution); return Err(Error::DuplicateContribution);
} }
contributions.put(operator_id, passphrase);
*retryable = false;
if contributions.len() < *threshold { 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); return Ok(false);
} }
let state = std::mem::replace(&mut self.state, CoordinatorState::Idle); 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 { let CoordinatorState::Unsealing {
threshold, threshold,
mut contributions, ordinary_passphrases,
.. recovery_passphrases,
} = state } = &mut self.state
else { else {
unreachable!("state was matched as Unsealing above") return Err(Error::NotUnsealing);
}; };
match finalize_unseal(&self.db, &self.vault, threshold, &mut contributions).await { if recovery_passphrases.contains_key(&recovery_operator_id) {
Ok(()) => Ok(true), return Err(Error::DuplicateContribution);
Err(error) => {
self.state = CoordinatorState::Unsealing {
threshold,
contributions,
retryable: true,
};
Err(error)
}
} }
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

@@ -62,10 +62,11 @@ mod tests {
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 mut password = SafeCell::new(PASSWORD.to_vec());
let mut 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(&mut password, &salt);
let mut key2 = derive_key(&mut password, &salt); let mut key2 = derive_key(&mut 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();

View File

@@ -192,9 +192,7 @@ 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( Err(SendError::HandlerError(vault::Error::Sealed)) => Ok(AttestationStatus::Unavailable),
vault::Error::Sealed | vault::Error::KeyVersionMismatch { .. },
)) => Ok(AttestationStatus::Unavailable),
Err(_) => Err(Error::VaultSend), Err(_) => Err(Error::VaultSend),
} }
} }
@@ -215,10 +213,8 @@ mod tests {
GlobalActors, GlobalActors,
vault::{Bootstrap, Vault}, vault::{Bootstrap, Vault},
}, },
crypto::KeyCell,
db::{self, schema}, db::{self, schema},
}; };
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 {
@@ -237,8 +233,7 @@ mod tests {
); );
actor actor
.ask(Bootstrap { .ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]), seal_key: crate::crypto::KeyCell::from([0u8; 32]),
custody: None,
}) })
.await .await
.unwrap(); .unwrap();
@@ -334,47 +329,4 @@ 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

@@ -21,10 +21,9 @@ impl From<SafeCell<Key>> for KeyCell {
Self(value) Self(value)
} }
} }
impl From<[u8; 32]> for KeyCell { impl From<[u8; 32]> for KeyCell {
fn from(bytes: [u8; 32]) -> Self { fn from(bytes: [u8; 32]) -> Self {
let cell = SafeCell::new_inline(|key: &mut Key| { let cell = SafeCell::new_inline_default(|key: &mut Key| {
key.copy_from_slice(&bytes); key.copy_from_slice(&bytes);
}); });
Self(cell) Self(cell)
@@ -39,7 +38,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(|cell_write: &mut Key| { let cell = SafeCell::new_inline_default(|cell_write: &mut Key| {
cell_write.copy_from_slice(&value); cell_write.copy_from_slice(&value);
}); });
Ok(Self(cell)) Ok(Self(cell))
@@ -48,7 +47,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(|key_buffer: &mut Key| { let key = SafeCell::new_inline_default(|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);
@@ -69,7 +68,6 @@ impl KeyCell {
let buffer = buffer.as_mut(); let buffer = buffer.as_mut();
cipher.encrypt_in_place(nonce, associated_data, buffer) cipher.encrypt_in_place(nonce, associated_data, buffer)
} }
pub fn decrypt_in_place( pub fn decrypt_in_place(
&mut self, &mut self,
nonce: &Nonce, nonce: &Nonce,
@@ -105,10 +103,7 @@ impl KeyCell {
} }
} }
/// Derive a fixed-length key from a passphrase using Argon2id. /// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
///
/// The passphrase is borrowed so that callers can keep it in protected memory
/// and reuse it across retries instead of handing over a copy.
pub fn derive_key(password: &mut SafeCell<Vec<u8>>, salt: &[u8]) -> KeyCell { pub fn derive_key(password: &mut SafeCell<Vec<u8>>, salt: &[u8]) -> KeyCell {
let params = { let params = {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@@ -151,7 +146,7 @@ mod tests {
let salt = generate_salt(); let salt = generate_salt();
let mut key = derive_key(&mut password, &salt); let mut key = derive_key(&mut password, &salt);
let nonce = Nonce(*b"unique nonce 123 1231233"); 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,221 +1,41 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use rand_core::CryptoRng;
use vsss_rs::Gf256; use vsss_rs::Gf256;
use crate::crypto::KeyCell;
/// GF(256) addresses shares by a non-zero byte, so no committee can exceed 255.
pub const MAX_COMMITTEE_SIZE: usize = 255;
/// Errors returned by Shamir split/combine operations.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ShamirError { pub enum ShamirError {
#[error("failed to split key: {0}")] #[error("Failed to split key: {0}")]
Split(String), Split(String),
#[error("failed to combine shares: {0}")] #[error("Failed to combine shares: {0}")]
Combine(String), Combine(String),
} }
/// Return the required threshold for a Shamir share pool of `committee_size`. /// 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`...].
/// A pool of two is rejected: a majority of two is two, which gives each holder
/// a veto over every unseal without giving either one recovery. That rejects no
/// supported committee, because a two-operator vault must carry at least one
/// recovery share and so never splits into a pool of two -- see
/// `docs/ARCHITECTURE.md` 3.9.
#[expect(
clippy::integer_division,
reason = "majority thresholds use integer arithmetic"
)]
#[must_use]
pub const fn shamir_threshold(committee_size: usize) -> Option<usize> {
match committee_size {
0 | 2 => None,
size if size > MAX_COMMITTEE_SIZE => None,
1 => Some(1),
size => Some(size / 2 + 1),
}
}
/// Split a seal key into `total` shares, `threshold` of which reconstruct it.
pub fn split_key( pub fn split_key(
threshold: usize, threshold: usize,
total: usize, total: usize,
key: &mut KeyCell, key: &[u8; 32],
rng: impl CryptoRng, rng: impl rand_core::RngCore + rand_core::CryptoRng,
) -> Result<Vec<SafeCell<Vec<u8>>>, ShamirError> { ) -> Result<Vec<Vec<u8>>, ShamirError> {
if total == 0 || threshold == 0 || threshold > total || total == 2 || total > MAX_COMMITTEE_SIZE Gf256::split_array(threshold, total, key.as_slice(), rng)
{ .map_err(|e| ShamirError::Split(format!("{e:?}")))
return Err(ShamirError::Split(
"unsupported committee parameters".to_owned(),
));
}
// Nothing to interpolate when one share suffices.
if threshold == 1 {
return Ok(key.0.read_inline(|key| {
std::iter::repeat_with(|| SafeCell::new(key.as_slice().to_vec()))
.take(total)
.collect()
}));
}
key.0.read_inline(|key| {
let key: &[u8; 32] = key
.as_slice()
.try_into()
.map_err(|_| ShamirError::Split("unexpected seal key length".to_owned()))?;
Gf256::split_array(threshold, total, key, rng)
.map(|shares| shares.into_iter().map(SafeCell::new).collect())
.map_err(|error| ShamirError::Split(format!("{error:?}")))
})
} }
/// Combine shares back into the seal key. /// Returns the minimum number of shares required to reconstruct the secret
/// /// for a committee of `n` operators.
/// `threshold` comes from storage rather than from the shapes of the shares: #[must_use]
/// a one-of-one committee stores the key verbatim, and telling that apart by pub const fn shamir_threshold(n: usize) -> usize {
/// share length alone would misread any Shamir share that happened to be key match n {
/// sized. 0 => panic!("No operators"),
pub fn combine_shares( 1 => 1,
threshold: usize, 2 => 2,
shares: &mut [SafeCell<Vec<u8>>], n => n / 2 + 1,
) -> Result<KeyCell, ShamirError> {
if threshold == 0 {
return Err(ShamirError::Combine("threshold is zero".to_owned()));
}
if shares.len() < threshold {
return Err(ShamirError::Combine(
"not enough shares supplied".to_owned(),
));
}
// Mirror of the one-of-one case in [`split_key`]: the share is the key.
if threshold == 1 {
let share = shares
.first_mut()
.ok_or_else(|| ShamirError::Combine("no shares supplied".to_owned()))?;
return reconstructed_key(share.read_inline(|share| SafeCell::new(share.clone())));
}
let mut gathered = SafeCell::new(Vec::with_capacity(shares.len()));
for share in shares.iter_mut() {
share.read_inline(|share| {
gathered.write_inline(|gathered| gathered.push(share.clone()));
});
}
let combined = gathered.read_inline(|gathered| {
Gf256::combine_array(gathered.as_slice())
.map(SafeCell::new)
.map_err(|error| ShamirError::Combine(format!("{error:?}")))
})?;
reconstructed_key(combined)
}
fn reconstructed_key(bytes: SafeCell<Vec<u8>>) -> Result<KeyCell, ShamirError> {
KeyCell::try_from(bytes)
.map_err(|()| ShamirError::Combine("unexpected reconstructed key length".to_owned()))
}
#[cfg(test)]
mod tests {
use super::{MAX_COMMITTEE_SIZE, combine_shares, shamir_threshold, split_key};
use crate::crypto::KeyCell;
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use rand::rngs::SysRng;
use rand_core::UnwrapErr;
use rstest::rstest;
fn key_bytes(mut key: KeyCell) -> [u8; 32] {
key.0.read_inline(|key| {
let mut bytes = [0u8; 32];
bytes.copy_from_slice(key.as_slice());
bytes
})
}
fn select(shares: &mut [SafeCell<Vec<u8>>], indexes: &[usize]) -> Vec<SafeCell<Vec<u8>>> {
indexes
.iter()
.filter_map(|index| {
shares
.get_mut(*index)
.map(|share| share.read_inline(|share| SafeCell::new(share.clone())))
})
.collect()
}
#[rstest]
#[case(&[0, 1])]
#[case(&[0, 2])]
#[case(&[1, 2])]
fn threshold_shares_reconstruct_fixed_key(#[case] indexes: &[usize]) {
let expected = [9_u8; 32];
let mut key = KeyCell::from(expected);
let rng = UnwrapErr(SysRng);
let mut shares = split_key(2, 3, &mut key, rng).expect("split should succeed");
let mut selected = select(&mut shares, indexes);
let combined = combine_shares(2, &mut selected).expect("combine should succeed");
assert_eq!(key_bytes(combined), expected);
}
#[test]
fn one_of_one_round_trips_a_fixed_size_key() {
let expected = [7_u8; 32];
let mut key = KeyCell::from(expected);
let rng = UnwrapErr(SysRng);
let mut shares = split_key(1, 1, &mut key, rng).expect("split should succeed");
let combined = combine_shares(1, &mut shares).expect("combine should succeed");
assert_eq!(key_bytes(combined), expected);
}
#[test]
fn fewer_shares_than_threshold_is_rejected() {
let mut key = KeyCell::from([3_u8; 32]);
let rng = UnwrapErr(SysRng);
let mut shares = split_key(3, 5, &mut key, rng).expect("split should succeed");
let mut selected = select(&mut shares, &[0, 1]);
assert!(
combine_shares(3, &mut selected).is_err(),
"two of three shares must not reconstruct the key"
);
}
#[rstest]
#[case(0, None)]
#[case(1, Some(1))]
#[case(2, None)]
#[case(3, Some(2))]
#[case(4, Some(3))]
#[case(MAX_COMMITTEE_SIZE, Some(128))]
#[case(MAX_COMMITTEE_SIZE + 1, None)]
fn committee_threshold_is_a_majority(
#[case] committee_size: usize,
#[case] expected: Option<usize>,
) {
assert_eq!(shamir_threshold(committee_size), expected);
}
#[test]
fn oversized_committee_is_rejected_by_split() {
let mut key = KeyCell::from([1_u8; 32]);
let rng = UnwrapErr(SysRng);
assert!(
split_key(129, MAX_COMMITTEE_SIZE + 1, &mut key, rng).is_err(),
"committees above the GF(256) share limit must be rejected"
);
}
#[test]
fn two_operator_committee_is_explicitly_unsupported() {
let mut key = KeyCell::from([7_u8; 32]);
let rng = UnwrapErr(SysRng);
assert!(
split_key(2, 2, &mut key, rng).is_err(),
"two-operator committees must be rejected"
);
} }
} }
/// 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

@@ -1,134 +0,0 @@
//! Storage for Shamir custody material: the reconstruction threshold and the
//! per-operator encrypted shares of the vault seal key.
//!
//! Every query lives here so that the actors above hold no Diesel code of their
//! own. The functions borrow the caller's connection instead of taking one from
//! the pool, which lets the vault write custody material inside the same
//! transaction that stores the root key.
use std::collections::HashMap;
use diesel::{ExpressionMethods as _, QueryDsl, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::db::{
models::{OperatorId, SqliteTimestamp},
schema,
};
/// One Shamir share, encrypted under a key derived from its operator's passphrase.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncryptedShare {
pub ciphertext: Vec<u8>,
pub nonce: Vec<u8>,
pub salt: Vec<u8>,
}
/// Everything a bootstrap persists about custody, written as a single unit.
#[derive(Debug)]
pub struct CustodyRecord {
pub threshold: usize,
pub shares: Vec<(OperatorId, EncryptedShare)>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Database query error: {0}")]
Query(#[from] diesel::result::Error),
#[error("Stored committee threshold is missing or out of range")]
BrokenThreshold,
#[error("Custody settings row is missing")]
MissingSettings,
#[error("No share stored for operator {0:?}")]
MissingShare(OperatorId),
}
/// Persist threshold and shares on the caller's connection, joining any
/// transaction the caller has already opened.
pub async fn write_record(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
record: &CustodyRecord,
) -> Result<(), Error> {
let threshold = i32::try_from(record.threshold).map_err(|_| Error::BrokenThreshold)?;
let now = SqliteTimestamp::now();
for (operator_id, share) in &record.shares {
diesel::replace_into(schema::operator::table)
.values((
schema::operator::id.eq(Some(*operator_id)),
schema::operator::share.eq(&share.ciphertext),
schema::operator::share_nonce.eq(&share.nonce),
schema::operator::share_salt.eq(&share.salt),
schema::operator::created_at.eq(now.clone()),
schema::operator::updated_at.eq(now.clone()),
))
.execute(&mut *conn)
.await?;
}
let updated = diesel::update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::shamir_threshold.eq(Some(threshold)))
.execute(&mut *conn)
.await?;
if updated != 1 {
return Err(Error::MissingSettings);
}
Ok(())
}
/// Number of shares required to reconstruct the seal key.
pub async fn threshold(conn: &mut impl AsyncConnection<Backend = Sqlite>) -> Result<usize, Error> {
let stored: Option<i32> = schema::arbiter_settings::table
.select(schema::arbiter_settings::shamir_threshold)
.first(conn)
.await?;
stored
.and_then(|value| usize::try_from(value).ok())
.filter(|threshold| *threshold > 0)
.ok_or(Error::BrokenThreshold)
}
/// One row of the share query: operator id, ciphertext, nonce, salt.
type ShareRow = (Option<OperatorId>, Vec<u8>, Vec<u8>, Vec<u8>);
/// Load the shares of `operators` in one query, in the order requested.
pub async fn shares(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
operators: &[OperatorId],
) -> Result<Vec<EncryptedShare>, Error> {
let wanted: Vec<Option<OperatorId>> = operators.iter().copied().map(Some).collect();
let rows: Vec<ShareRow> = schema::operator::table
.filter(schema::operator::id.eq_any(wanted))
.select((
schema::operator::id,
schema::operator::share,
schema::operator::share_nonce,
schema::operator::share_salt,
))
.load(conn)
.await?;
let mut found: HashMap<OperatorId, EncryptedShare> = rows
.into_iter()
.filter_map(|(id, ciphertext, nonce, salt)| {
id.map(|id| {
(
id,
EncryptedShare {
ciphertext,
nonce,
salt,
},
)
})
})
.collect();
operators
.iter()
.map(|id| found.remove(id).ok_or(Error::MissingShare(*id)))
.collect()
}

View File

@@ -8,7 +8,6 @@ use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use thiserror::Error; use thiserror::Error;
use tracing::info; use tracing::info;
pub mod custody;
pub mod models; pub mod models;
pub mod schema; pub mod schema;
@@ -155,39 +154,3 @@ pub async fn create_test_pool() -> DatabasePool {
.await .await
.expect("Failed to create test database pool") .expect("Failed to create test database pool")
} }
#[cfg(test)]
mod tests {
use diesel::{ExpressionMethods as _, result::DatabaseErrorKind};
use diesel_async::RunQueryDsl as _;
use super::*;
#[tokio::test]
async fn operator_share_salt_must_be_supplied_by_application() {
let pool = create_test_pool().await;
let mut conn = pool.get().await.expect("pool connection");
let operator_id = diesel::insert_into(schema::operator_identity::table)
.values(schema::operator_identity::public_key.eq(vec![1]))
.returning(schema::operator_identity::id)
.get_result::<i32>(&mut conn)
.await
.expect("insert operator identity");
let error = diesel::insert_into(schema::operator::table)
.values((
schema::operator::id.eq(operator_id),
schema::operator::share.eq(vec![2]),
schema::operator::share_nonce.eq(vec![3]),
))
.execute(&mut conn)
.await
.expect_err("operator insert without an application-generated salt must fail");
assert!(matches!(
error,
diesel::result::Error::DatabaseError(DatabaseErrorKind::NotNullViolation, _)
));
}
}

View File

@@ -15,10 +15,11 @@ use restructed::Models;
pub mod types { pub mod types {
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use diesel::{ use diesel::{
backend::Backend,
deserialize::{FromSql, FromSqlRow}, deserialize::{FromSql, FromSqlRow},
expression::AsExpression, expression::AsExpression,
serialize::{IsNull, ToSql}, serialize::{IsNull, ToSql},
sql_types::Integer, sql_types::{Integer, Text},
sqlite::{Sqlite, SqliteType}, sqlite::{Sqlite, SqliteType},
}; };
@@ -61,7 +62,7 @@ pub mod types {
impl FromSql<Integer, Sqlite> for SqliteTimestamp { impl FromSql<Integer, Sqlite> for SqliteTimestamp {
fn from_sql( fn from_sql(
mut bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>, mut bytes: <Sqlite as Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> { ) -> diesel::deserialize::Result<Self> {
let Some(SqliteType::Long) = bytes.value_type() else { let Some(SqliteType::Long) = bytes.value_type() else {
return Err(format!( return Err(format!(
@@ -110,18 +111,6 @@ pub mod types {
ToSql::<Integer, Sqlite>::to_sql(&self.0, out) ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
} }
} }
impl arbiter_crypto::hashing::Hashable for $name {
fn hash<H: arbiter_crypto::hashing::Digest>(&self, hasher: &mut H) {
arbiter_crypto::hashing::Hashable::hash(&self.0, hasher);
}
}
impl crate::crypto::integrity::v1::IntoId for $name {
fn into_id(self) -> Vec<u8> {
crate::crypto::integrity::v1::IntoId::into_id(self.0)
}
}
}; };
} }
@@ -153,6 +142,45 @@ pub mod types {
declare_id!(TlsHistoryId); declare_id!(TlsHistoryId);
declare_id!(EvmWalletId); declare_id!(EvmWalletId);
declare_id!(ClientId); declare_id!(ClientId);
#[derive(Debug, Clone, PartialEq, Eq, AsExpression, FromSqlRow)]
#[diesel(sql_type = Text)]
pub enum ProposalStatus {
Pending,
Approved,
Rejected,
Expired,
}
impl ToSql<Text, Sqlite> for ProposalStatus {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
let s: &str = match self {
Self::Pending => "pending",
Self::Approved => "approved",
Self::Rejected => "rejected",
Self::Expired => "expired",
};
<str as ToSql<Text, Sqlite>>::to_sql(s, out)
}
}
impl FromSql<Text, Sqlite> for ProposalStatus {
fn from_sql(
bytes: <Sqlite as Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
match s.as_str() {
"pending" => Ok(Self::Pending),
"approved" => Ok(Self::Approved),
"rejected" => Ok(Self::Rejected),
"expired" => Ok(Self::Expired),
other => Err(format!("Unknown proposal status: {other}").into()),
}
}
}
} }
pub use types::*; pub use types::*;
@@ -215,7 +243,6 @@ pub struct ArbiterSettings {
pub id: i32, pub id: i32,
pub root_key_id: Option<i32>, // references root_key_history.id pub root_key_id: Option<i32>, // references root_key_history.id
pub tls_id: Option<i32>, // references tls_history.id pub tls_id: Option<i32>, // references tls_history.id
pub shamir_threshold: Option<i32>,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
@@ -451,3 +478,68 @@ pub struct IntegrityEnvelope {
pub signed_at: SqliteTimestamp, pub signed_at: SqliteTimestamp,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }
#[derive(Debug, Queryable, Selectable, Identifiable)]
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
pub struct Proposal {
pub id: i32,
pub kind: String,
pub payload: Vec<u8>,
pub initiator_id: i32,
pub created_at: SqliteTimestamp,
pub expires_at: SqliteTimestamp,
pub status: ProposalStatus,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
pub struct NewProposal {
pub kind: String,
pub payload: Vec<u8>,
pub initiator_id: i32,
// status defaults to 'pending' at the DB layer
pub expires_at: SqliteTimestamp,
}
#[derive(Debug, Queryable, Selectable, Identifiable)]
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
pub struct ProposalVote {
pub id: i32,
pub proposal_id: i32,
pub operator_id: i32,
pub approve: bool,
pub signature: Vec<u8>,
pub voted_at: SqliteTimestamp,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
pub struct NewProposalVote {
pub proposal_id: i32,
pub operator_id: i32,
pub approve: bool,
pub signature: Vec<u8>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::proposal_result, check_for_backend(Sqlite))]
pub struct NewProposalResult {
pub proposal_id: i32,
pub data: Vec<u8>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::recovery_proposal_vote, check_for_backend(Sqlite))]
pub struct NewRecoveryProposalVote {
pub proposal_id: i32,
pub recovery_operator_id: i32,
pub approve: bool,
pub signature: Vec<u8>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::recovery_wakeup_request, check_for_backend(Sqlite))]
pub struct NewRecoveryWakeupRequest {
pub requested_by: i32,
}

View File

@@ -17,7 +17,6 @@ diesel::table! {
id -> Integer, id -> Integer,
root_key_id -> Nullable<Integer>, root_key_id -> Nullable<Integer>,
tls_id -> Nullable<Integer>, tls_id -> Nullable<Integer>,
shamir_threshold -> Nullable<Integer>,
} }
} }
@@ -173,6 +172,78 @@ diesel::table! {
} }
} }
diesel::table! {
proposal (id) {
id -> Integer,
kind -> Text,
payload -> Binary,
initiator_id -> Integer,
created_at -> Integer,
expires_at -> Integer,
status -> Text,
}
}
diesel::table! {
proposal_result (proposal_id) {
proposal_id -> Integer,
data -> Binary,
created_at -> Integer,
}
}
diesel::table! {
recovery_operator (id) {
id -> Integer,
share -> Binary,
share_nonce -> Binary,
share_salt -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
recovery_operator_identity (id) {
id -> Integer,
public_key -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
recovery_wakeup_request (id) {
id -> Integer,
requested_by -> Integer,
requested_at -> Integer,
cancelled_by -> Nullable<Integer>,
cancelled_at -> Nullable<Integer>,
}
}
diesel::table! {
recovery_proposal_vote (id) {
id -> Integer,
proposal_id -> Integer,
recovery_operator_id -> Integer,
approve -> Bool,
signature -> Binary,
voted_at -> Integer,
}
}
diesel::table! {
proposal_vote (id) {
id -> Integer,
proposal_id -> Integer,
operator_id -> Integer,
approve -> Bool,
signature -> Binary,
voted_at -> Integer,
}
}
diesel::table! { diesel::table! {
program_client (id) { program_client (id) {
id -> Integer, id -> Integer,
@@ -226,9 +297,22 @@ diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id));
diesel::joinable!(evm_wallet_access -> program_client (client_id)); diesel::joinable!(evm_wallet_access -> program_client (client_id));
diesel::joinable!(operator -> operator_identity (id)); diesel::joinable!(operator -> operator_identity (id));
diesel::joinable!(program_client -> client_metadata (metadata_id)); diesel::joinable!(program_client -> client_metadata (metadata_id));
diesel::joinable!(proposal -> operator_identity (initiator_id));
diesel::joinable!(proposal_result -> proposal (proposal_id));
diesel::joinable!(proposal_vote -> proposal (proposal_id));
diesel::joinable!(proposal_vote -> operator_identity (operator_id));
diesel::joinable!(recovery_operator -> recovery_operator_identity (id));
diesel::joinable!(recovery_proposal_vote -> proposal (proposal_id));
diesel::joinable!(recovery_proposal_vote -> recovery_operator_identity (recovery_operator_id));
diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
aead_encrypted, aead_encrypted,
proposal_result,
recovery_operator,
recovery_operator_identity,
recovery_wakeup_request,
recovery_proposal_vote,
arbiter_settings, arbiter_settings,
client_metadata, client_metadata,
client_metadata_history, client_metadata_history,
@@ -246,6 +330,8 @@ diesel::allow_tables_to_appear_in_same_query!(
operator, operator,
operator_identity, operator_identity,
program_client, program_client,
proposal,
proposal_vote,
root_key_history, root_key_history,
tls_history, tls_history,
); );

View File

@@ -1,34 +1,28 @@
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, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
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, VolumeRateLimit, SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer, token_transfers::TokenTransfer,
}, },
}; };
use alloy::{ use alloy::{
consensus::TxEip1559, consensus::TxEip1559,
primitives::{Address, TxKind, U256}, primitives::{TxKind, U256},
}; };
use chrono::Utc; use chrono::Utc;
use diesel::{ use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
ExpressionMethods as _, OptionalExtension, QueryDsl as _, QueryResult, SelectableHelper, use diesel_async::{AsyncConnection, RunQueryDsl};
insert_into, sqlite::Sqlite, update, use kameo::actor::ActorRef;
};
pub mod abi; pub mod abi;
pub mod safe_signer; pub mod safe_signer;
@@ -278,151 +272,6 @@ 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>,
@@ -505,12 +354,8 @@ mod tests {
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::KeyCell;
use crate::crypto::integrity;
use crate::db::{ use crate::db::{
self, DatabaseConnection, self, DatabaseConnection,
models::{ models::{
@@ -519,10 +364,8 @@ mod tests {
}, },
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::{
CombinedSettings, EvalContext, EvalViolation, Policy, SharedGrantSettings, EvalContext, EvalViolation, SharedGrantSettings, TransactionRateLimit,
TransactionRateLimit, VolumeRateLimit,
}; };
use super::check_shared_constraints; use super::check_shared_constraints;
@@ -554,7 +397,6 @@ 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,
@@ -763,116 +605,4 @@ 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: KeyCell::from([0u8; 32]),
custody: None,
})
.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: EvmWalletId::from_raw(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,7 +144,6 @@ 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>,
@@ -159,7 +158,6 @@ 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

@@ -80,7 +80,6 @@ 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

@@ -98,7 +98,6 @@ 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(|w: &mut [u8; 32]| { let mut cell = SafeCell::new_inline_default(|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")
.cast_unsigned(), as u64,
random: challenge.nonce.to_vec(), random: challenge.nonce.to_vec(),
}) })
} }

View File

@@ -1,10 +1,11 @@
use crate::{ use crate::{
grpc::request_tracker::RequestTracker, grpc::request_tracker::RequestTracker,
peers::operator::{OperatorConnection, OperatorSession, OutOfBand}, peers::operator::{OutOfBand, OperatorConnection, OperatorSession},
}; };
use arbiter_proto::{ use arbiter_proto::{
proto::operator::{ proto::operator::{
OperatorRequest, OperatorResponse, operator_request::Payload as OperatorRequestPayload, OperatorRequest, OperatorResponse,
operator_request::Payload as OperatorRequestPayload,
operator_response::Payload as OperatorResponsePayload, operator_response::Payload as OperatorResponsePayload,
}, },
transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi}, transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi},
@@ -18,6 +19,7 @@ use tracing::{error, info, warn};
mod auth; mod auth;
mod evm; mod evm;
mod governance;
mod inbound; mod inbound;
mod outbound; mod outbound;
mod sdk_client; mod sdk_client;
@@ -110,13 +112,11 @@ async fn dispatch_inner(
OperatorRequestPayload::Vault(req) => vault::dispatch(actor, req).await, OperatorRequestPayload::Vault(req) => vault::dispatch(actor, req).await,
OperatorRequestPayload::Evm(req) => evm::dispatch(actor, req).await, OperatorRequestPayload::Evm(req) => evm::dispatch(actor, req).await,
OperatorRequestPayload::SdkClient(req) => sdk_client::dispatch(actor, req).await, OperatorRequestPayload::SdkClient(req) => sdk_client::dispatch(actor, req).await,
OperatorRequestPayload::Governance(_) => {
Err(Status::permission_denied(stringify!(Governance)))
}
OperatorRequestPayload::Auth(..) => { OperatorRequestPayload::Auth(..) => {
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")
.cast_unsigned(), as u64,
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.map(String::into_bytes), bootstrap_token,
}) })
} }
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => { AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => {

View File

@@ -217,11 +217,6 @@ 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

@@ -0,0 +1,155 @@
use crate::{
actors::proposal_manager::{Error as ProposalError, ProposalKind, VoteOutcome},
peers::operator::{
OperatorSession,
session::handlers::{HandleCastVote, HandleCreateProposal, HandleQueryPending},
},
};
use arbiter_proto::proto::operator::{
governance::{
self as proto_gov, CreateProposalRequest, QueryPendingRequest, QueryPendingResponse,
VoteOutcome as ProtoVoteOutcome, create_proposal_request::Kind as ProtoKind,
request::Payload as GovRequestPayload, response::Payload as GovResponsePayload,
},
operator_response::Payload as OperatorResponsePayload,
};
use kameo::actor::ActorRef;
use tonic::Status;
use tracing::warn;
const fn wrap(payload: GovResponsePayload) -> OperatorResponsePayload {
OperatorResponsePayload::Governance(proto_gov::Response {
payload: Some(payload),
})
}
pub(super) async fn dispatch(
actor: &ActorRef<OperatorSession>,
req: proto_gov::Request,
) -> Result<Option<OperatorResponsePayload>, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument(
"Missing governance request payload",
));
};
match payload {
GovRequestPayload::Create(req) => handle_create(actor, req).await,
GovRequestPayload::Vote(req) => handle_vote(actor, req).await,
GovRequestPayload::Query(QueryPendingRequest {}) => handle_query(actor).await,
}
}
async fn handle_create(
actor: &ActorRef<OperatorSession>,
req: CreateProposalRequest,
) -> Result<Option<OperatorResponsePayload>, Status> {
let kind = match req.kind {
Some(ProtoKind::ApproveSdkClient(p)) => ProposalKind::ApproveSdkClient {
client_id: p.client_id,
},
Some(ProtoKind::GrantWalletAccess(p)) => ProposalKind::GrantWalletAccess {
wallet_id: p.wallet_id,
client_id: p.client_id,
},
Some(ProtoKind::ApproveServerUpdate(_)) => ProposalKind::ApproveServerUpdate,
Some(ProtoKind::ReplaceOperator(p)) => ProposalKind::ReplaceOperator {
old_operator_id: p.old_operator_id,
new_pubkey: p.new_pubkey,
},
Some(ProtoKind::UpdateShamirParameters(p)) => ProposalKind::UpdateShamirParameters {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "new_n is always a small operator count"
)]
new_n: p.new_n as u8,
},
Some(ProtoKind::ApprovePersistentGrant(p)) => {
use prost::Message as _;
ProposalKind::ApprovePersistentGrant {
payload_bytes: p.encode_to_vec(),
}
}
Some(ProtoKind::ApproveOneOffTransaction(p)) => {
use prost::Message as _;
ProposalKind::ApproveOneOffTransaction {
payload_bytes: p.encode_to_vec(),
}
}
None => return Err(Status::invalid_argument("Missing proposal kind")),
};
let ttl_secs = req.ttl_secs.map(i64::from);
let proposal_id = actor
.ask(HandleCreateProposal { kind, ttl_secs })
.await
.map_err(|e| {
warn!(?e, "create_proposal failed");
Status::internal("Failed to create proposal")
})?;
Ok(Some(wrap(GovResponsePayload::Created(
proto_gov::CreateProposalResponse { proposal_id },
))))
}
async fn handle_vote(
actor: &ActorRef<OperatorSession>,
req: proto_gov::CastVoteRequest,
) -> Result<Option<OperatorResponsePayload>, Status> {
let result = actor
.ask(HandleCastVote {
proposal_id: req.proposal_id,
approve: req.approve,
signature: req.signature,
})
.await;
let outcome = match result {
Ok(VoteOutcome::Pending) => ProtoVoteOutcome::Pending,
Ok(VoteOutcome::QuorumApproved) => ProtoVoteOutcome::Approved,
Ok(VoteOutcome::QuorumRejected) => ProtoVoteOutcome::Rejected,
Err(kameo::error::SendError::HandlerError(ProposalError::AlreadyVoted)) => {
return Err(Status::invalid_argument("Already voted on this proposal"));
}
Err(kameo::error::SendError::HandlerError(ProposalError::InvalidSignature)) => {
return Err(Status::invalid_argument("Invalid vote signature"));
}
Err(kameo::error::SendError::HandlerError(ProposalError::ProposalNotFound)) => {
return Err(Status::not_found("Proposal not found"));
}
Err(e) => {
warn!(?e, "cast_vote failed");
return Err(Status::internal("Failed to cast vote"));
}
};
Ok(Some(wrap(GovResponsePayload::Voted(
proto_gov::VoteResponse {
outcome: outcome.into(),
},
))))
}
async fn handle_query(
actor: &ActorRef<OperatorSession>,
) -> Result<Option<OperatorResponsePayload>, Status> {
let summaries = actor.ask(HandleQueryPending {}).await.unwrap_or_default();
let proposals = summaries
.into_iter()
.map(|s| proto_gov::ProposalSummary {
id: s.id,
kind: s.kind,
initiator_id: s.initiator_id,
expires_at: s.expires_at.0.timestamp(),
approve_count: s.approve_count,
reject_count: s.reject_count,
})
.collect();
Ok(Some(wrap(GovResponsePayload::Pending(
QueryPendingResponse { proposals },
))))
}

View File

@@ -86,7 +86,6 @@ 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()

View File

@@ -1,12 +1,20 @@
use crate::{ use crate::{
actors::vault::VaultState, actors::vault::VaultState,
peers::operator::{OperatorSession, session::handlers::HandleQueryVaultState}, peers::operator::{
OperatorSession,
session::handlers::{
HandleContributeRecoveryRekeyPassphrase, HandleContributeRekeyPassphrase,
HandleQueryVaultState,
},
},
}; };
use arbiter_proto::{ use arbiter_proto::{
proto::operator::{ proto::operator::{
operator_response::Payload as OperatorResponsePayload, operator_response::Payload as OperatorResponsePayload,
vault::{ vault::{
self as proto_vault, request::Payload as VaultRequestPayload, self as proto_vault,
rekey::{self as proto_rekey, RekeyResult as ProtoRekeyResult},
request::Payload as VaultRequestPayload,
response::Payload as VaultResponsePayload, response::Payload as VaultResponsePayload,
}, },
}, },
@@ -33,14 +41,60 @@ 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::Unseal(_) VaultRequestPayload::Rekey(req) => handle_rekey(actor, req).await,
| VaultRequestPayload::Bootstrap(_) VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => {
| VaultRequestPayload::Rekey(_) => 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",
)), ))
}
} }
} }
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,8 +1,8 @@
use crate::{ use crate::{
crypto::shamir,
grpc::{Convert, TryConvert}, grpc::{Convert, TryConvert},
peers::operator::vault_gate::{ peers::operator::vault_gate::{
self as vault_gate, HandleBootstrapEncryptedKey, HandleContributeBootstrapPassphrase, self as vault_gate, HandleBootstrapEncryptedKey, HandleContributeBootstrapPassphrase,
HandleContributeRecoveryBootstrapPassphrase, HandleContributeRecoveryUnsealPassphrase,
HandleContributeUnsealPassphrase, HandleDeclareCommittee, HandleHandshake, HandleContributeUnsealPassphrase, HandleDeclareCommittee, HandleHandshake,
HandleUnsealEncryptedKey, HandleUnsealEncryptedKey,
}, },
@@ -53,7 +53,9 @@ impl TryConvert for VaultRequestPayload {
Self::QueryState(()) => Ok(vault_gate::Inbound::HandleVaultState), Self::QueryState(()) => Ok(vault_gate::Inbound::HandleVaultState),
Self::Unseal(req) => req.try_convert(), Self::Unseal(req) => req.try_convert(),
Self::Bootstrap(req) => req.try_convert(), Self::Bootstrap(req) => req.try_convert(),
Self::Rekey(_) => Err(Status::unimplemented("Vault re-key is not available")), Self::Rekey(_) => Err(Status::permission_denied(
"Rekey requires an authenticated session",
)),
} }
} }
} }
@@ -77,16 +79,21 @@ 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(passphrase) => { Self::ContributePassphrase(cp) => Ok(
Ok(vault_gate::Inbound::HandleContributeUnsealPassphrase( vault_gate::Inbound::HandleContributeUnsealPassphrase(
HandleContributeUnsealPassphrase { HandleContributeUnsealPassphrase {
passphrase: passphrase.passphrase, passphrase: cp.passphrase,
}, },
)) ),
} ),
Self::ContributeRecoveryPassphrase(_) => Err(Status::unimplemented( Self::ContributeRecoveryPassphrase(crp) => Ok(
"Recovery operator contributions are not available", vault_gate::Inbound::HandleContributeRecoveryUnsealPassphrase(
)), HandleContributeRecoveryUnsealPassphrase {
recovery_operator_id: crp.recovery_operator_id,
passphrase: crp.passphrase,
},
),
),
} }
} }
} }
@@ -134,35 +141,27 @@ impl TryConvert for BootstrapRequestPayload {
fn try_convert(self) -> Result<vault_gate::Inbound, Status> { fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
match self { match self {
Self::EncryptedKey(key) => key.try_convert(), Self::EncryptedKey(key) => key.try_convert(),
Self::DeclareCommittee(dc) => { Self::DeclareCommittee(dc) => Ok(
if dc.recovery_count != 0 { vault_gate::Inbound::HandleDeclareCommittee(HandleDeclareCommittee {
return Err(Status::unimplemented( count: dc.count as usize,
"Recovery operator contributions are not available", recovery_count: dc.recovery_count as usize,
)); }),
} ),
let count = usize::try_from(dc.count) Self::ContributePassphrase(cp) => Ok(
.ok() vault_gate::Inbound::HandleContributeBootstrapPassphrase(
.filter(|count| *count <= shamir::MAX_COMMITTEE_SIZE)
.ok_or_else(|| {
Status::invalid_argument(format!(
"Committee count must not exceed {}",
shamir::MAX_COMMITTEE_SIZE
))
})?;
Ok(vault_gate::Inbound::HandleDeclareCommittee(
HandleDeclareCommittee { count },
))
}
Self::ContributePassphrase(cp) => {
Ok(vault_gate::Inbound::HandleContributeBootstrapPassphrase(
HandleContributeBootstrapPassphrase { HandleContributeBootstrapPassphrase {
passphrase: cp.passphrase, passphrase: cp.passphrase,
}, },
)) ),
} ),
Self::ContributeRecoveryPassphrase(_) => Err(Status::unimplemented( Self::ContributeRecoveryPassphrase(crp) => Ok(
"Recovery operator contributions are not available", vault_gate::Inbound::HandleContributeRecoveryBootstrapPassphrase(
)), HandleContributeRecoveryBootstrapPassphrase {
recovery_operator_id: crp.recovery_operator_id,
passphrase: crp.passphrase,
},
),
),
} }
} }
} }

View File

@@ -1,5 +1,5 @@
use crate::{ use crate::{
actors::{vault::VaultState, vault_coordinator}, actors::vault::VaultState,
grpc::{Convert, TryConvert}, grpc::{Convert, TryConvert},
peers::operator::vault_gate::{self as vault_gate}, peers::operator::vault_gate::{self as vault_gate},
}; };
@@ -34,26 +34,6 @@ const fn wrap_unseal_response(payload: UnsealResponsePayload) -> OperatorRespons
})) }))
} }
/// Ceremony errors are the operator's own doing far more often than ours, so
/// they travel back as a specific status instead of a blanket internal error.
fn ceremony_status(error: &vault_coordinator::Error) -> Status {
match error {
vault_coordinator::Error::AlreadyBootstrapping
| vault_coordinator::Error::AlreadyUnsealing
| vault_coordinator::Error::NotBootstrapping
| vault_coordinator::Error::DuplicateContribution => {
Status::failed_precondition(error.to_string())
}
vault_coordinator::Error::EmptyCommittee
| vault_coordinator::Error::UnsupportedCommittee
| vault_coordinator::Error::CommitteeTooLarge => {
Status::invalid_argument(error.to_string())
}
vault_coordinator::Error::InvalidPassphrase => Status::unauthenticated(error.to_string()),
_ => Status::internal("Vault ceremony failed"),
}
}
fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> OperatorResponsePayload { fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> OperatorResponsePayload {
wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response { wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response {
result: result.into(), result: result.into(),
@@ -132,14 +112,7 @@ impl TryConvert for vault_gate::Outbound {
} }
Self::HandleDeclareCommittee(result) => { Self::HandleDeclareCommittee(result) => {
let proto_result = match result { let proto_result = match result {
Ok(()) => ProtoBootstrapResult::AwaitingContributions, Ok(()) => ProtoBootstrapResult::Success,
Err(vault_gate::Error::Ceremony(
vault_coordinator::Error::AlreadyBootstrapped,
)) => ProtoBootstrapResult::AlreadyBootstrapped,
Err(vault_gate::Error::Ceremony(err)) => {
warn!(?err, "declare committee failed");
return Err(ceremony_status(&err));
}
Err(err) => { Err(err) => {
warn!(?err, "declare committee failed"); warn!(?err, "declare committee failed");
return Err(Status::internal("Failed to declare committee")); return Err(Status::internal("Failed to declare committee"));
@@ -151,17 +124,21 @@ impl TryConvert for vault_gate::Outbound {
let proto_result = match result { let proto_result = match result {
Ok(true) => ProtoBootstrapResult::Success, Ok(true) => ProtoBootstrapResult::Success,
Ok(false) => ProtoBootstrapResult::AwaitingContributions, Ok(false) => ProtoBootstrapResult::AwaitingContributions,
Err(vault_gate::Error::Ceremony(
vault_coordinator::Error::AlreadyBootstrapped,
)) => ProtoBootstrapResult::AlreadyBootstrapped,
Err(vault_gate::Error::Ceremony(err)) => {
warn!(?err, "contribute bootstrap passphrase failed");
return Err(ceremony_status(&err));
}
Err(err) => { Err(err) => {
warn!(?err, "contribute bootstrap passphrase failed"); 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( return Err(Status::internal(
"Failed to contribute bootstrap passphrase", "Failed to contribute recovery bootstrap passphrase",
)); ));
} }
}; };
@@ -180,6 +157,21 @@ impl TryConvert for vault_gate::Outbound {
proto_result.into(), proto_result.into(),
))) )))
} }
Self::HandleContributeRecoveryUnsealPassphrase(result) => {
let proto_result = match result {
Ok(true) => ProtoUnsealResult::Success,
Ok(false) => ProtoUnsealResult::AwaitingContributions,
Err(err) => {
warn!(?err, "contribute recovery unseal passphrase failed");
return Err(Status::internal(
"Failed to contribute recovery unseal passphrase",
));
}
};
Ok(wrap_unseal_response(UnsealResponsePayload::Result(
proto_result.into(),
)))
}
} }
} }
} }

View File

@@ -8,7 +8,7 @@ use crate::{
crypto::integrity::{self, AttestationStatus}, crypto::integrity::{self, AttestationStatus},
db::{ db::{
self, self,
models::ProgramClientMetadata, models::{ProgramClientMetadata, SqliteTimestamp},
schema::program_client, schema::program_client,
}, },
}; };
@@ -18,13 +18,14 @@ 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, dsl::insert_into, update,
}; };
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, warn}; use tracing::error;
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum Error { pub enum Error {
@@ -210,47 +211,71 @@ async fn insert_client(
.await .await
} }
/// Compares stored metadata against what a reconnecting client presents. async fn sync_client_metadata(
/// 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,
presented: &ClientMetadata, metadata: &ClientMetadata,
) -> Result<(), Error> { ) -> Result<(), Error> {
use crate::db::schema::client_metadata; use crate::db::schema::{client_metadata, client_metadata_history};
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
})?; })?;
let current: ProgramClientMetadata = program_client::table conn.exclusive_transaction(async |conn| {
.find(client_id) let (current_metadata_id, current): (i32, ProgramClientMetadata) = program_client::table
.inner_join(client_metadata::table) .find(client_id)
.select(ProgramClientMetadata::as_select()) .inner_join(client_metadata::table)
.first(&mut conn) .select((
.await program_client::metadata_id,
.map_err(|e| { ProgramClientMetadata::as_select(),
error!(error = ?e, "Database error"); ))
Error::DatabaseOperationFailed .first(&mut *conn)
})?; .await?;
let changed = current.name != presented.name let unchanged = current.name == metadata.name
|| current.description != presented.description && current.description == metadata.description
|| current.version != presented.version; && current.version == metadata.version;
if unchanged {
return Ok(());
}
if changed { insert_into(client_metadata_history::table)
warn!( .values((
client_id, client_metadata_history::metadata_id.eq(current_metadata_id),
stored_name = %current.name, client_metadata_history::client_id.eq(client_id),
presented_name = %presented.name, ))
"reconnecting client presented different metadata; ignoring - metadata is frozen after operator approval" .execute(&mut *conn)
); .await?;
}
Ok(()) let metadata_id = insert_into(client_metadata::table)
.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>(
@@ -299,7 +324,6 @@ 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(
@@ -313,6 +337,8 @@ 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 { client_id: args.client_id, actor: this }) .ask(RegisterClient { 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<Vec<u8>>, bootstrap_token: Option<String>,
}, },
AuthChallengeSolution { AuthChallengeSolution {
signature: Vec<u8>, signature: Vec<u8>,

View File

@@ -3,8 +3,8 @@ use super::{
Error, Error,
}; };
use crate::{ use crate::{
actors::bootstrap::VerifyToken, actors::bootstrap::ConsumeToken,
db::{DatabasePool, models::OperatorId, schema::operator_identity}, db::{DatabasePool, schema::operator_identity},
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,18 +14,19 @@ use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use tracing::error; use tracing::error;
pub(super) struct ChallengeRequest { pub(crate) struct ChallengeRequest {
pub(super) pubkey: authn::PublicKey, pub(crate) pubkey: authn::PublicKey,
pub(super) bootstrap_token: Option<Vec<u8>>, pub(crate) bootstrap_token: Option<String>,
} }
pub struct ChallengeContext { pub struct ChallengeContext {
pub(super) challenge: AuthChallenge, pub challenge: AuthChallenge,
pub(super) pubkey: authn::PublicKey, pub pubkey: authn::PublicKey,
pub bootstrap_token: Option<String>,
} }
pub(super) struct ChallengeSolution { pub(crate) struct ChallengeSolution {
pub(super) solution: Vec<u8>, pub(crate) solution: Vec<u8>,
} }
smlang::statemachine!( smlang::statemachine!(
@@ -37,10 +38,7 @@ smlang::statemachine!(
} }
); );
async fn get_client_id( async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<Option<i32>, Error> {
db: &DatabasePool,
pubkey: &authn::PublicKey,
) -> Result<Option<OperatorId>, Error> {
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::internal("Database unavailable") Error::internal("Database unavailable")
@@ -49,7 +47,7 @@ async fn get_client_id(
operator_identity::table operator_identity::table
.filter(operator_identity::public_key.eq(pubkey.to_bytes())) .filter(operator_identity::public_key.eq(pubkey.to_bytes()))
.select(operator_identity::id) .select(operator_identity::id)
.first::<OperatorId>(&mut conn) .first::<i32>(&mut conn)
.await .await
.optional() .optional()
.map_err(|e| { .map_err(|e| {
@@ -58,14 +56,14 @@ async fn get_client_id(
}) })
} }
async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<OperatorId, Error> { async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i32, Error> {
let pubkey_bytes = pubkey.to_bytes(); let pubkey_bytes = pubkey.to_bytes();
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::internal("Database unavailable") Error::internal("Database unavailable")
})?; })?;
let id: OperatorId = diesel::insert_into(operator_identity::table) let id: i32 = diesel::insert_into(operator_identity::table)
.values((operator_identity::public_key.eq(pubkey_bytes),)) .values((operator_identity::public_key.eq(pubkey_bytes),))
.returning(operator_identity::id) .returning(operator_identity::id)
.get_result(&mut conn) .get_result(&mut conn)
@@ -81,16 +79,11 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<Op
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 { Self { conn, transport }
conn,
transport,
bootstrap_token: None,
}
} }
} }
@@ -115,8 +108,6 @@ where
} }
} }
self.bootstrap_token = bootstrap_token;
let challenge = AuthChallenge::generate(&mut rand::rng()); let challenge = AuthChallenge::generate(&mut rand::rng());
self.transport self.transport
@@ -129,12 +120,20 @@ where
Error::Transport Error::Transport
})?; })?;
Ok(ChallengeContext { challenge, pubkey }) Ok(ChallengeContext {
challenge,
pubkey,
bootstrap_token,
})
} }
async fn verify_solution( async fn verify_solution(
&mut self, &mut self,
ChallengeContext { challenge, pubkey }: &ChallengeContext, 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(|()| {
@@ -153,13 +152,15 @@ where
} }
// Resolve client id: bootstrap (consume token + register) or lookup // Resolve client id: bootstrap (consume token + register) or lookup
let id = match self.bootstrap_token.take() { let id = match bootstrap_token {
Some(token) => { Some(token) => {
let token_ok: bool = self let token_ok: bool = self
.conn .conn
.actors .actors
.bootstrapper .bootstrapper
.ask(VerifyToken { token }) .ask(ConsumeToken {
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

@@ -4,7 +4,7 @@ use crate::{
vault::{GetState, Vault}, vault::{GetState, Vault},
}, },
crypto::integrity::{self, AttestationStatus, Integrable}, crypto::integrity::{self, AttestationStatus, Integrable},
db::{DatabaseError, DatabasePool, models::OperatorId}, db::{DatabaseError, DatabasePool},
peers::client::ClientProfile, peers::client::ClientProfile,
}; };
use arbiter_crypto::authn; use arbiter_crypto::authn;
@@ -25,7 +25,7 @@ pub mod vault_gate;
#[derive(Debug, Clone, Hashable)] #[derive(Debug, Clone, Hashable)]
pub struct Credentials { pub struct Credentials {
pub id: OperatorId, pub id: i32,
pub pubkey: authn::PublicKey, pub pubkey: authn::PublicKey,
} }
@@ -180,6 +180,7 @@ where
Ok(OperatorSession::spawn(OperatorSession::new( Ok(OperatorSession::spawn(OperatorSession::new(
props.clone(), props.clone(),
creds.clone(),
oob_sender, oob_sender,
))) )))
} }

View File

@@ -2,17 +2,14 @@ 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::{IsClientConnected, client_connect_approval::ClientApprovalAnswer}, flow_coordinator::client_connect_approval::ClientApprovalAnswer,
vault::VaultState, vault::VaultState,
}, },
db::{ db::models::{
models::{ EvmWalletAccess, EvmWalletId, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
EvmWalletAccess, EvmWalletId, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
},
schema::program_client,
}, },
evm::policies::{Grant, SpecificGrant}, evm::policies::{Grant, SpecificGrant},
}; };
@@ -22,16 +19,13 @@ 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, info, warn}; use tracing::error;
#[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,
} }
@@ -128,22 +122,23 @@ impl OperatorSession {
} }
#[message] #[message]
pub(crate) fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), GrantMutationError> { pub(crate) async fn handle_grant_delete(
// match self &mut self,
// .props grant_id: i32,
// .actors ) -> Result<(), GrantMutationError> {
// .evm match self
// .ask(OperatorDeleteGrant { grant_id }) .props
// .await .actors
// { .evm
// Ok(()) => Ok(()), .ask(OperatorDeleteGrant { grant_id })
// Err(err) => { .await
// error!(?err, "EVM grant delete failed"); {
// Err(GrantMutationError::Internal) Ok(()) => Ok(()),
// } Err(err) => {
// } error!(?err, "EVM grant delete failed");
let _ = grant_id; Err(GrantMutationError::Internal)
todo!() }
}
} }
#[message] #[message]
@@ -153,30 +148,6 @@ 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
@@ -232,7 +203,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::id.eq(entry)) .filter(evm_wallet_access::wallet_id.eq(entry))
.execute(&mut *conn) .execute(&mut *conn)
.await?; .await?;
} }
@@ -247,8 +218,9 @@ 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 = crate::db::schema::evm_wallet_access::table let access_entries = evm_wallet_access::table
.select(EvmWalletAccess::as_select()) .select(EvmWalletAccess::as_select())
.load::<_>(&mut conn) .load::<_>(&mut conn)
.await?; .await?;
@@ -285,30 +257,6 @@ 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(())
} }
@@ -332,141 +280,101 @@ impl OperatorSession {
} }
} }
#[cfg(test)] #[messages]
mod tests { impl OperatorSession {
use crate::db::{self, models::{EvmWalletId, NewEvmWalletAccess}, schema::evm_wallet_access}; #[message]
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper}; pub(crate) async fn handle_create_proposal(
use diesel_async::{AsyncConnection, RunQueryDsl}; &mut self,
kind: crate::actors::proposal_manager::ProposalKind,
/// Regression test: revocation must delete by access-entry `id`, not by `wallet_id`. ttl_secs: Option<i64>,
/// ) -> Result<i32, Error> {
/// Before the fix, revoking `entry_id=1` would delete all rows where `wallet_id=1`, use crate::actors::proposal_manager::CreateProposal;
/// wiping out every client's access to wallet #1. let initiator_id = self.credentials.id;
#[tokio::test] self.props
async fn revoke_deletes_by_entry_id_not_wallet_id() { .actors
use crate::db::models::EvmWalletAccess; .proposal_manager
.ask(CreateProposal { kind, initiator_id, ttl_secs })
let pool = db::create_test_pool().await; .await
let mut conn = pool.get().await.expect("pool connection"); .map_err(|e| {
error!(?e, "create_proposal failed");
// Insert two access entries for the same wallet but different clients. Error::internal("Failed to create proposal")
// 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: EvmWalletId::from_raw(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: EvmWalletId::from_raw(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");
} }
/// Regression test: when `entry_id` and `wallet_id` differ, only the correct row is removed. #[message]
/// pub(crate) async fn handle_cast_vote(
/// This specifically catches the case where `entry.id=5` and `wallet_id=1` are different values; &mut self,
/// the old bug would delete by `wallet_id`, potentially matching a completely different entry. proposal_id: i32,
#[tokio::test] approve: bool,
async fn revoke_with_mismatched_wallet_and_entry_ids() { signature: Vec<u8>,
use crate::db::models::EvmWalletAccess; ) -> Result<crate::actors::proposal_manager::VoteOutcome, crate::actors::proposal_manager::Error> {
use crate::actors::proposal_manager::CastVote;
let pool = db::create_test_pool().await; let operator_id = self.credentials.id;
let mut conn = pool.get().await.expect("pool connection"); self.props
.actors
// Insert entries to force auto-increment IDs to diverge from wallet_ids. .proposal_manager
// We'll insert 5 placeholder entries first so that the real entry gets id=6. .ask(CastVote { proposal_id, operator_id, approve, signature })
for i in 1_i32..=5 { .await
diesel::insert_into(evm_wallet_access::table) .map_err(|err| match err {
.values(NewEvmWalletAccess { SendError::HandlerError(e) => e,
wallet_id: EvmWalletId::from_raw(99), _ => crate::actors::proposal_manager::Error::ExecutionFailed("actor unavailable".to_owned()),
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: EvmWalletId::from_raw(1),
client_id: 1,
}) })
.returning(EvmWalletAccess::as_select()) }
.get_result(&mut *conn)
#[message]
pub(crate) async fn handle_query_pending(
&mut self,
) -> Vec<crate::actors::proposal_manager::ProposalSummary> {
use crate::actors::proposal_manager::QueryPending;
let operator_id = self.credentials.id;
self.props
.actors
.proposal_manager
.ask(QueryPending { operator_id })
.await .await
.expect("insert target"); .unwrap_or_default()
}
// Sanity: target.id != target.wallet_id }
assert_ne!(
target.id, target.wallet_id.to_raw(), #[messages]
"test prerequisite: id and wallet_id must differ" impl OperatorSession {
); #[message]
pub(crate) async fn handle_contribute_rekey_passphrase(
// Revoke by entry id. &mut self,
conn.transaction(async |conn| { passphrase: Vec<u8>,
diesel::delete(evm_wallet_access::table) ) -> Result<bool, Error> {
.filter(evm_wallet_access::id.eq(target.id)) use crate::actors::vault_coordinator::ContributeRekey;
.execute(&mut *conn) use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
.await
}) let operator_id = self.credentials.id;
.await self.props
.expect("revoke target"); .actors
.vault_coordinator
let remaining = evm_wallet_access::table .ask(ContributeRekey {
.filter(evm_wallet_access::id.eq(target.id)) operator_id,
.count() passphrase: SafeCell::new(passphrase),
.get_result::<i64>(&mut *conn) })
.await .await
.expect("count target"); .map_err(|_| Error::internal("VaultCoordinator unavailable"))
assert_eq!(remaining, 0, "target must be deleted by its entry id"); }
// Placeholders for wallet_id=99 must be untouched. #[message]
let placeholders = evm_wallet_access::table pub(crate) async fn handle_contribute_recovery_rekey_passphrase(
.filter(evm_wallet_access::wallet_id.eq(99)) &mut self,
.count() recovery_operator_id: i32,
.get_result::<i64>(&mut *conn) passphrase: Vec<u8>,
.await ) -> Result<bool, Error> {
.expect("count placeholders"); use crate::actors::vault_coordinator::ContributeRecoveryRekey;
assert_eq!(placeholders, 5, "unrelated entries must survive"); use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
self.props
.actors
.vault_coordinator
.ask(ContributeRecoveryRekey {
recovery_operator_id,
passphrase: SafeCell::new(passphrase),
})
.await
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
} }
} }

View File

@@ -1,14 +1,16 @@
use super::{OutOfBand, OperatorConnection}; use super::{Credentials, OutOfBand, OperatorConnection};
use crate::{ use crate::{
actors::{ actors::{
flow_coordinator::{GetConnectedClientIds, client_connect_approval::{ClientApprovalAnswer, ClientApprovalController}}, operator_registry::ConnectOperator, flow_coordinator::client_connect_approval::ClientApprovalController,
}, peers::client::ClientProfile, operator_registry::ConnectOperator,
},
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, HashSet}}; use std::{borrow::Cow, collections::HashMap};
use thiserror::Error; use thiserror::Error;
use tracing::error; use tracing::error;
@@ -49,24 +51,21 @@ 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, sender: Box<dyn Sender<OutOfBand>>) -> Self { pub(crate) fn new(props: OperatorConnection, credentials: Credentials, sender: Box<dyn Sender<OutOfBand>>) -> Self {
Self { Self {
props, props,
credentials,
sender, sender,
pending_client_approvals: HashMap::default(), pending_client_approvals: HashMap::default(),
approved_client_ids: HashSet::default(),
} }
} }
} }
@@ -91,7 +90,6 @@ 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;
} }
@@ -110,7 +108,7 @@ impl Actor for OperatorSession {
type Error = Error; type Error = Error;
async fn on_start(mut args: Self::Args, this: ActorRef<Self>) -> Result<Self, Self::Error> { async fn on_start(args: Self::Args, this: ActorRef<Self>) -> Result<Self, Self::Error> {
args.props args.props
.actors .actors
.operator_registry .operator_registry
@@ -125,16 +123,6 @@ 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,9 +3,12 @@ use crate::{
actors::{ actors::{
GlobalActors, GlobalActors,
vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events}, vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events},
vault_coordinator::{self, ContributeBootstrap, ContributeUnseal, StartBootstrap}, vault_coordinator::{
ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal,
ContributeUnseal, StartBootstrap,
},
}, },
crypto::{KeyCell, integrity}, crypto::{KeyCell, integrity::{self}},
db::DatabasePool, db::DatabasePool,
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
@@ -18,6 +21,9 @@ 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)]
@@ -26,29 +32,17 @@ 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,
#[error("Vault ceremony failed: {0}")]
Ceremony(#[from] vault_coordinator::Error),
#[error("Internal error: {0}")] #[error("Internal error: {0}")]
Internal(String), Internal(String),
} }
impl Error { impl Error {
fn internal(message: impl Into<String>) -> Self { fn internal(message: impl Into<String>) -> Self {
Self::Internal(message.into()) Self::Internal(message.into())
} }
/// Preserve the coordinator's own error so the operator learns why a
/// ceremony was refused instead of reading "internal error".
fn ceremony<M>(error: SendError<M, vault_coordinator::Error>) -> Self {
match error {
SendError::HandlerError(inner) => Self::Ceremony(inner),
_ => Self::internal("VaultCoordinator unavailable"),
}
}
} }
pub struct HandshakeResponse { pub struct HandshakeResponse {
@@ -82,6 +76,7 @@ impl VaultGate {
impl Actor for VaultGate { impl Actor for VaultGate {
type Args = Self; type Args = Self;
type Error = (); type Error = ();
async fn on_start( async fn on_start(
@@ -110,29 +105,28 @@ impl VaultGate {
nonce: &[u8], nonce: &[u8],
ciphertext: &[u8], ciphertext: &[u8],
associated_data: &[u8], associated_data: &[u8],
) -> Result<SafeCell<Vec<u8>>, ()> { ) -> Result<KeyCell, ()> {
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| {
cipher.decrypt_in_place(nonce, associated_data, write_handle) cipher.decrypt_in_place(nonce, associated_data, write_handle)
}); });
match decryption_result { match decryption_result {
Ok(()) => Ok(key_buffer), Ok(()) => KeyCell::try_from(key_buffer).map_err(|()| {
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(())
} }
} }
} }
fn key_cell(buffer: SafeCell<Vec<u8>>) -> Result<KeyCell, Error> {
KeyCell::try_from(buffer).map_err(|()| Error::InvalidKey)
}
} }
#[messages] #[messages(enum)]
impl VaultGate { impl VaultGate {
#[message] #[message]
pub fn handle_handshake( pub fn handle_handshake(
@@ -141,11 +135,14 @@ impl VaultGate {
) -> Result<HandshakeResponse, Error> { ) -> Result<HandshakeResponse, Error> {
let ephemeral_secret = EphemeralSecret::random(); let ephemeral_secret = EphemeralSecret::random();
let public_key = PublicKey::from(&ephemeral_secret); let public_key = PublicKey::from(&ephemeral_secret);
let secret = ephemeral_secret.diffie_hellman(&client_pubkey); let secret = ephemeral_secret.diffie_hellman(&client_pubkey);
self.state = State::ReadyForExchange { self.state = State::ReadyForExchange {
server_key: public_key, server_key: public_key,
secret, secret,
}; };
Ok(HandshakeResponse { Ok(HandshakeResponse {
server_pubkey: public_key, server_pubkey: public_key,
}) })
@@ -161,17 +158,22 @@ impl VaultGate {
let State::ReadyForExchange { secret, .. } = &self.state else { let State::ReadyForExchange { secret, .. } = &self.state else {
return Err(Error::State); return Err(Error::State);
}; };
let seal_key = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
.map_err(|()| Error::InvalidKey)
.and_then(Self::key_cell)?;
match self.actors.vault.ask(TryUnseal { seal_key }).await { let Ok(seal_key) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) else {
return Err(Error::InvalidKey);
};
match self
.actors
.vault
.ask(TryUnseal { seal_key })
.await
{
Ok(()) => { Ok(()) => {
info!("Successfully unsealed key with client-provided key"); info!("Successfully unsealed key with client-provided key");
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)
@@ -193,17 +195,15 @@ impl VaultGate {
let State::ReadyForExchange { secret, .. } = &self.state else { let State::ReadyForExchange { secret, .. } = &self.state else {
return Err(Error::State); return Err(Error::State);
}; };
let seal_key = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
.map_err(|()| Error::InvalidKey) let Ok(seal_key) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) else {
.and_then(Self::key_cell)?; return Err(Error::InvalidKey);
};
match self match self
.actors .actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap { seal_key })
seal_key,
custody: None,
})
.await .await
{ {
Ok(()) => { Ok(()) => {
@@ -226,23 +226,31 @@ impl VaultGate {
#[message] #[message]
pub async fn handle_vault_state(&mut self) -> Result<VaultState, Error> { pub async fn handle_vault_state(&mut self) -> Result<VaultState, Error> {
self.actors let answer = self
.actors
.vault .vault
.ask(GetState {}) .ask(GetState {})
.await .await
.map_err(|_| Error::internal("failed to query vault")) .map_err(|_| Error::internal("failed to query vault"))?;
Ok(answer)
} }
#[message] #[message]
pub async fn handle_declare_committee(&mut self, count: usize) -> Result<(), Error> { pub async fn handle_declare_committee(
&mut self,
count: usize,
recovery_count: usize,
) -> Result<(), Error> {
self.actors self.actors
.vault_coordinator .vault_coordinator
.ask(StartBootstrap { .ask(StartBootstrap {
operator_id: self.auth_creds.id, operator_id: self.auth_creds.id,
declared_count: count, declared_count: count,
recovery_count,
}) })
.await .await
.map_err(Error::ceremony) .map_err(|_| Error::internal("VaultCoordinator unavailable"))
} }
#[message] #[message]
@@ -250,14 +258,32 @@ impl VaultGate {
&mut self, &mut self,
passphrase: Vec<u8>, passphrase: Vec<u8>,
) -> Result<bool, Error> { ) -> Result<bool, Error> {
let passphrase_cell = SafeCell::new(passphrase);
self.actors self.actors
.vault_coordinator .vault_coordinator
.ask(ContributeBootstrap { .ask(ContributeBootstrap {
operator_id: self.auth_creds.id, operator_id: self.auth_creds.id,
passphrase: SafeCell::new(passphrase), passphrase: passphrase_cell,
}) })
.await .await
.map_err(Error::ceremony) .map_err(|_| Error::internal("VaultCoordinator unavailable"))
}
#[message]
pub async fn handle_contribute_recovery_bootstrap_passphrase(
&mut self,
recovery_operator_id: i32,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
let passphrase_cell = SafeCell::new(passphrase);
self.actors
.vault_coordinator
.ask(ContributeRecoveryBootstrap {
recovery_operator_id,
passphrase: passphrase_cell,
})
.await
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
} }
#[message] #[message]
@@ -265,14 +291,32 @@ impl VaultGate {
&mut self, &mut self,
passphrase: Vec<u8>, passphrase: Vec<u8>,
) -> Result<bool, Error> { ) -> Result<bool, Error> {
let passphrase_cell = SafeCell::new(passphrase);
self.actors self.actors
.vault_coordinator .vault_coordinator
.ask(ContributeUnseal { .ask(ContributeUnseal {
operator_id: self.auth_creds.id, operator_id: self.auth_creds.id,
passphrase: SafeCell::new(passphrase), passphrase: passphrase_cell,
}) })
.await .await
.map_err(Error::ceremony) .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"))
} }
} }
@@ -326,75 +370,3 @@ impl Message<events::Unsealed> for VaultGate {
ctx.stop(); ctx.stop();
} }
} }
pub enum Inbound {
HandleHandshake(HandleHandshake),
HandleUnsealEncryptedKey(HandleUnsealEncryptedKey),
HandleBootstrapEncryptedKey(HandleBootstrapEncryptedKey),
HandleVaultState,
HandleDeclareCommittee(HandleDeclareCommittee),
HandleContributeBootstrapPassphrase(HandleContributeBootstrapPassphrase),
HandleContributeUnsealPassphrase(HandleContributeUnsealPassphrase),
}
pub enum Outbound {
HandleHandshake(Result<HandshakeResponse, Error>),
HandleUnsealEncryptedKey(Result<(), Error>),
HandleBootstrapEncryptedKey(Result<(), Error>),
HandleVaultState(Result<VaultState, Error>),
HandleDeclareCommittee(Result<(), Error>),
HandleContributeBootstrapPassphrase(Result<bool, Error>),
HandleContributeUnsealPassphrase(Result<bool, Error>),
}
impl Message<Inbound> for VaultGate {
type Reply = Result<Outbound, Error>;
async fn handle(
&mut self,
msg: Inbound,
_ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
) -> Self::Reply {
match msg {
Inbound::HandleHandshake(message) => Ok(Outbound::HandleHandshake(
self.handle_handshake(message.client_pubkey),
)),
Inbound::HandleUnsealEncryptedKey(message) => Ok(Outbound::HandleUnsealEncryptedKey(
self.handle_unseal_encrypted_key(
message.nonce,
message.ciphertext,
message.associated_data,
)
.await,
)),
Inbound::HandleBootstrapEncryptedKey(message) => {
Ok(Outbound::HandleBootstrapEncryptedKey(
self.handle_bootstrap_encrypted_key(
message.nonce,
message.ciphertext,
message.associated_data,
)
.await,
))
}
Inbound::HandleVaultState => {
Ok(Outbound::HandleVaultState(self.handle_vault_state().await))
}
Inbound::HandleDeclareCommittee(message) => Ok(Outbound::HandleDeclareCommittee(
self.handle_declare_committee(message.count).await,
)),
Inbound::HandleContributeBootstrapPassphrase(message) => {
Ok(Outbound::HandleContributeBootstrapPassphrase(
self.handle_contribute_bootstrap_passphrase(message.passphrase)
.await,
))
}
Inbound::HandleContributeUnsealPassphrase(message) => {
Ok(Outbound::HandleContributeUnsealPassphrase(
self.handle_contribute_unseal_passphrase(message.passphrase)
.await,
))
}
}
}
}

View File

@@ -6,7 +6,7 @@ use arbiter_proto::{
}; };
use arbiter_server::{ use arbiter_server::{
actors::{GlobalActors, vault::Bootstrap}, actors::{GlobalActors, vault::Bootstrap},
crypto::{KeyCell, integrity}, crypto::integrity,
db::{self, schema}, db::{self, schema},
peers::client::{ClientConnection, ClientCredentials, auth, connect_client}, peers::client::{ClientConnection, ClientCredentials, auth, connect_client},
}; };
@@ -97,8 +97,7 @@ async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors {
actors actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]), seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]),
custody: None,
}) })
.await .await
.unwrap(); .unwrap();
@@ -264,7 +263,7 @@ pub async fn metadata_unchanged_does_not_append_history() {
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
pub async fn metadata_frozen_after_approval_ignores_reconnect_changes() { pub async fn metadata_change_appends_history_and_repoints_binding() {
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());
@@ -285,7 +284,6 @@ pub async fn metadata_frozen_after_approval_ignores_reconnect_changes() {
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(),
@@ -312,7 +310,6 @@ pub async fn metadata_frozen_after_approval_ignores_reconnect_changes() {
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)
@@ -338,19 +335,15 @@ pub async fn metadata_frozen_after_approval_ignores_reconnect_changes() {
.first::<(String, Option<String>, Option<String>)>(&mut conn) .first::<(String, Option<String>, Option<String>)>(&mut conn)
.await .await
.unwrap(); .unwrap();
assert_eq!( assert_eq!(metadata_count, 2);
metadata_count, 1, assert_eq!(history_count, 1);
"frozen: no new metadata row on reconnect"
);
assert_eq!(history_count, 0, "frozen: no history entry on reconnect");
assert_eq!( assert_eq!(
current, current,
( (
"client".to_owned(), "client".to_owned(),
Some("old".to_owned()), Some("new".to_owned()),
Some("1.0.0".to_owned()) Some("2.0.0".to_owned())
), )
"frozen: original metadata must be preserved"
); );
} }
} }

View File

@@ -2,11 +2,9 @@
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_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},
crypto::KeyCell,
db::{self, schema}, db::{self, schema},
}; };
@@ -20,7 +18,7 @@ pub(crate) async fn bootstrapped_vault(db: &db::DatabasePool) -> Vault {
.await .await
.unwrap(); .unwrap();
actor actor
.bootstrap(KeyCell::from([0u8; 32]), None) .bootstrap(arbiter_server::crypto::KeyCell::from([0u8; 32]))
.await .await
.unwrap(); .unwrap();
actor actor

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,8 @@ use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT};
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},
crypto::{KeyCell, integrity}, crypto::integrity,
db::{self, models::OperatorId, schema}, db::{self, schema},
peers::operator::{self, Credentials, OperatorConnection, auth, vault_gate}, peers::operator::{self, Credentials, OperatorConnection, auth, vault_gate},
}; };
@@ -151,6 +151,13 @@ impl Sender<auth::Inbound> for StartTestTransport {
pub async fn bootstrap_token_auth() { pub async fn bootstrap_token_auth() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap(); let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.vault
.ask(Bootstrap {
seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]),
})
.await
.unwrap();
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
let (mut server_transport, mut test_transport) = ChannelTransport::new(); let (mut server_transport, mut test_transport) = ChannelTransport::new();
@@ -164,7 +171,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.into_bytes()), bootstrap_token: Some(token),
}) })
.await .await
.unwrap(); .unwrap();
@@ -221,7 +228,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(b"invalid_token".to_vec()), bootstrap_token: Some("invalid_token".to_owned()),
}) })
.await .await
.unwrap(); .unwrap();
@@ -265,8 +272,7 @@ pub async fn challenge_auth() {
actors actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]), seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]),
custody: None,
}) })
.await .await
.unwrap(); .unwrap();
@@ -276,10 +282,10 @@ pub async fn challenge_auth() {
{ {
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let id: OperatorId = insert_into(schema::operator_identity::table) let id: i32 = insert_into(schema::operator_identity::table)
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),)) .values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::operator_identity::id) .returning(schema::operator_identity::id)
.get_result::<OperatorId>(&mut conn) .get_result(&mut conn)
.await .await
.unwrap(); .unwrap();
integrity::sign_entity( integrity::sign_entity(
@@ -352,8 +358,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
actors actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]), seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]),
custody: None,
}) })
.await .await
.unwrap(); .unwrap();
@@ -392,7 +397,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 @ auth::Outbound::AuthSuccess => panic!("Expected AuthChallenge, got {other:?}"), other => panic!("Expected AuthChallenge, got {other:?}"),
}, },
Err(err) => panic!("Expected Ok response, got Err({err:?})"), Err(err) => panic!("Expected Ok response, got Err({err:?})"),
}; };
@@ -426,8 +431,7 @@ pub async fn challenge_auth_rejects_invalid_signature() {
actors actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]), seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]),
custody: None,
}) })
.await .await
.unwrap(); .unwrap();
@@ -437,10 +441,10 @@ pub async fn challenge_auth_rejects_invalid_signature() {
{ {
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let id: OperatorId = insert_into(schema::operator_identity::table) let id: i32 = insert_into(schema::operator_identity::table)
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),)) .values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::operator_identity::id) .returning(schema::operator_identity::id)
.get_result::<OperatorId>(&mut conn) .get_result(&mut conn)
.await .await
.unwrap(); .unwrap();
integrity::sign_entity( integrity::sign_entity(
@@ -499,92 +503,3 @@ pub async fn challenge_auth_rejects_invalid_signature() {
Err(auth::Error::InvalidChallengeSolution) Err(auth::Error::InvalidChallengeSolution)
)); ));
} }
/// The bootstrap token authorises registering committee members *before* the
/// vault exists. Once any bootstrap path succeeds it must stop working, or its
/// holder could keep minting operator identities until the next restart.
#[tokio::test]
#[test_log::test]
pub async fn bootstrap_token_rejected_after_bootstrap() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
actors
.vault
.ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]),
custody: None,
})
.await
.unwrap();
// `Bootstrapped` travels through the message bus, so the token disappears
// a couple of actor turns after the bootstrap call returns.
let mut retired = false;
for _ in 0..100 {
if actors.bootstrapper.ask(GetToken).await.unwrap().is_none() {
retired = true;
break;
}
tokio::task::yield_now().await;
}
assert!(
retired,
"the bootstrap token must be retired once the vault is bootstrapped"
);
let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = OperatorConnection::new(db_for_task, actors);
auth::authenticate(&mut props, &mut server_transport).await
});
let new_key = MlDsa87::key_gen(&mut rand::rng());
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(),
bootstrap_token: Some(token.into_bytes()),
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(auth::Outbound::AuthChallenge { challenge }) => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = sign_operator_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes(),
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive auth result");
assert!(
matches!(response, Err(auth::Error::InvalidBootstrapToken)),
"a spent bootstrap token must not authorise a new identity, got {response:?}"
);
assert!(task.await.unwrap().is_err(), "authentication must fail");
let mut conn = db.get().await.unwrap();
let registered: i64 = schema::operator_identity::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(
registered, 0,
"no identity may be registered after the bootstrap"
);
}

View File

@@ -4,8 +4,7 @@ use arbiter_server::{
GlobalActors, GlobalActors,
vault::{Bootstrap, Seal}, vault::{Bootstrap, Seal},
}, },
crypto::KeyCell, db,
db::{self, models::OperatorId},
peers::operator::{ peers::operator::{
Credentials, Credentials,
vault_gate::{ vault_gate::{
@@ -20,7 +19,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; 32],
) -> ( ) -> (
db::DatabasePool, db::DatabasePool,
kameo::actor::ActorRef<VaultGate>, kameo::actor::ActorRef<VaultGate>,
@@ -32,8 +31,7 @@ async fn setup_sealed_gate(
actors actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key: KeyCell::from(seal_key), seal_key: arbiter_server::crypto::KeyCell::from(*seal_key),
custody: None,
}) })
.await .await
.unwrap(); .unwrap();
@@ -41,10 +39,7 @@ async fn setup_sealed_gate(
let (promotion_tx, promotion_rx) = oneshot::channel(); let (promotion_tx, promotion_rx) = oneshot::channel();
let pubkey = authn::SigningKey::generate().public_key(); let pubkey = authn::SigningKey::generate().public_key();
let auth_creds = Credentials { let auth_creds = Credentials { id: 1, pubkey };
id: OperatorId::from_raw(1),
pubkey,
};
let gate = VaultGate::spawn(VaultGate::new(auth_creds, actors, db.clone(), promotion_tx)); let gate = VaultGate::spawn(VaultGate::new(auth_creds, actors, db.clone(), promotion_tx));
(db, gate, promotion_rx) (db, gate, promotion_rx)
@@ -52,7 +47,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], key_to_send: &[u8; 32],
) -> 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);
@@ -85,10 +80,10 @@ 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 = [7u8; 32]; let seal_key = b"test-seal-key-padded-to-32bytes!";
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;
let response = gate.ask(encrypted_key).await; let response = gate.ask(encrypted_key).await;
assert!(matches!(response, Ok(()))); assert!(matches!(response, Ok(())));
@@ -97,10 +92,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 = [7u8; 32]; let seal_key = b"test-seal-key-padded-to-32bytes!";
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, &[8u8; 32]).await; let encrypted_key = client_dh_encrypt(&gate, b"wrong-key-padded-to-32-bytes!!!!").await;
let response = gate.ask(encrypted_key).await; let response = gate.ask(encrypted_key).await;
assert!(matches!( assert!(matches!(
@@ -114,7 +109,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 = [7u8; 32]; let seal_key = b"test-seal-key-padded-to-32bytes!";
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();
@@ -145,11 +140,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 = [9u8; 32]; let seal_key = b"real-seal-key-padded-to-32bytes!";
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, &[8u8; 32]).await; let encrypted_key = client_dh_encrypt(&gate, b"wrong-key-padded-to-32-bytes!!!!").await;
let response = gate.ask(encrypted_key).await; let response = gate.ask(encrypted_key).await;
assert!(matches!( assert!(matches!(
@@ -161,7 +156,7 @@ pub async fn unseal_retry_after_invalid_key() {
} }
{ {
let encrypted_key = client_dh_encrypt(&gate, &seal_key).await; let encrypted_key = client_dh_encrypt(&gate, seal_key).await;
let response = gate.ask(encrypted_key).await; let response = gate.ask(encrypted_key).await;
assert!(matches!(response, Ok(()))); assert!(matches!(response, Ok(())));

View File

@@ -2,8 +2,6 @@ mod common;
#[path = "vault/concurrency.rs"] #[path = "vault/concurrency.rs"]
mod concurrency; mod concurrency;
#[path = "vault/custody.rs"]
mod custody;
#[path = "vault/lifecycle.rs"] #[path = "vault/lifecycle.rs"]
mod lifecycle; mod lifecycle;
#[path = "vault/storage.rs"] #[path = "vault/storage.rs"]

View File

@@ -5,7 +5,6 @@ use arbiter_server::{
GlobalActors, GlobalActors,
vault::{CreateNew, Error, Vault}, vault::{CreateNew, Error, Vault},
}, },
crypto::KeyCell,
db::{self, models, schema}, db::{self, models, schema},
}; };
@@ -15,8 +14,6 @@ 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,
@@ -30,7 +27,6 @@ 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();
@@ -124,7 +120,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()), TEST_AAD.to_vec()) .create_new(SafeCell::new(b"should fail".to_vec()))
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, Error::DatabaseTransaction(_))); assert!(matches!(err, Error::DatabaseTransaction(_)));
@@ -170,12 +166,12 @@ async fn decrypt_roundtrip_after_high_concurrency() {
.await .await
.unwrap(); .unwrap();
decryptor decryptor
.try_unseal(KeyCell::from([0u8; 32])) .try_unseal(arbiter_server::crypto::KeyCell::from([0u8; 32]))
.await .await
.unwrap(); .unwrap();
for (id, plaintext) in expected { for (id, plaintext) in expected {
let mut decrypted = decryptor.decrypt(id, TEST_AAD.to_vec()).await.unwrap(); let mut decrypted = decryptor.decrypt(id).await.unwrap();
assert_eq!(*decrypted.read(), plaintext); assert_eq!(*decrypted.read(), plaintext);
} }
} }

View File

@@ -1,387 +0,0 @@
//! End-to-end coverage for the Shamir custody ceremonies.
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_server::{
actors::{
GlobalActors,
vault::{Bootstrap, Error as VaultError, GetState, Seal, Vault, VaultState},
vault_coordinator::{ContributeBootstrap, ContributeUnseal, StartBootstrap},
},
crypto::{KeyCell, shamir},
db::{
self,
custody::{CustodyRecord, EncryptedShare},
models::OperatorId,
schema,
},
};
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
/// Register `count` operator identities so committee members satisfy the
/// foreign key from `operator` to `operator_identity`.
async fn register_operators(db: &db::DatabasePool, count: usize) -> Vec<OperatorId> {
let mut conn = db.get().await.unwrap();
let mut ids = Vec::with_capacity(count);
for index in 0..count {
let pubkey = vec![u8::try_from(index).unwrap(); 32];
let id: OperatorId = diesel::insert_into(schema::operator_identity::table)
.values((schema::operator_identity::public_key.eq(pubkey),))
.returning(schema::operator_identity::id)
.get_result(&mut conn)
.await
.unwrap();
ids.push(id);
}
ids
}
async fn stored_share_count(db: &db::DatabasePool) -> i64 {
let mut conn = db.get().await.unwrap();
schema::operator::table
.count()
.get_result(&mut conn)
.await
.unwrap()
}
async fn stored_threshold(db: &db::DatabasePool) -> Option<i32> {
let mut conn = db.get().await.unwrap();
schema::arbiter_settings::table
.select(schema::arbiter_settings::shamir_threshold)
.first(&mut conn)
.await
.unwrap()
}
fn passphrase(seed: u8) -> SafeCell<Vec<u8>> {
SafeCell::new(vec![seed; 16])
}
/// The happy path: three operators bootstrap, and any two of them reopen the
/// vault after it is sealed.
#[tokio::test]
#[test_log::test]
async fn committee_of_three_unseals_with_two_passphrases() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let operators = register_operators(&db, 3).await;
actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: 3,
})
.await
.unwrap();
for (index, operator_id) in operators.iter().enumerate() {
let finished = actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: *operator_id,
passphrase: passphrase(u8::try_from(index).unwrap()),
})
.await
.unwrap();
assert_eq!(
finished,
index == 2,
"the ceremony finishes only on the last contribution"
);
}
assert_eq!(
actors.vault.ask(GetState {}).await.unwrap(),
VaultState::Unsealed
);
assert_eq!(stored_share_count(&db).await, 3);
assert_eq!(stored_threshold(&db).await, Some(2));
actors.vault.ask(Seal {}).await.unwrap();
let first = actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: operators[0],
passphrase: passphrase(0),
})
.await
.unwrap();
assert!(!first, "one of two shares must not unseal");
let second = actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: operators[2],
passphrase: passphrase(2),
})
.await
.unwrap();
assert!(second, "the threshold contribution should unseal the vault");
assert_eq!(
actors.vault.ask(GetState {}).await.unwrap(),
VaultState::Unsealed
);
}
/// Shares that describe a seal key the vault never adopted would make the vault
/// permanently un-unsealable, so a refused bootstrap must leave the table empty.
#[tokio::test]
#[test_log::test]
async fn refused_bootstrap_stores_no_shares() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let operators = register_operators(&db, 1).await;
// Another path bootstraps the vault first; the ceremony now has nowhere to go.
actors
.vault
.ask(Bootstrap {
seal_key: KeyCell::from([4u8; 32]),
custody: None,
})
.await
.unwrap();
actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: 1,
})
.await
.unwrap();
let error = actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: operators[0],
passphrase: passphrase(1),
})
.await
.expect_err("bootstrapping an already bootstrapped vault must fail");
assert!(
format!("{error:?}").contains("AlreadyBootstrapped"),
"expected AlreadyBootstrapped, got {error:?}"
);
assert_eq!(
stored_share_count(&db).await,
0,
"a refused bootstrap must not leave shares behind"
);
assert_eq!(
stored_threshold(&db).await,
None,
"a refused bootstrap must not leave a threshold behind"
);
}
/// A failing custody write must take the whole bootstrap down with it: a vault
/// that kept its root key but lost the shares could never be unsealed again.
#[tokio::test]
#[test_log::test]
async fn custody_write_failure_rolls_back_bootstrap() {
let db = db::create_test_pool().await;
let operators = register_operators(&db, 1).await;
let record = CustodyRecord {
threshold: 1,
shares: operators
.into_iter()
.map(|operator_id| {
(
operator_id,
EncryptedShare {
ciphertext: vec![1; 32],
nonce: vec![2; 24],
salt: vec![3; 16],
},
)
})
.collect(),
};
let mut vault = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
// The threshold update is the last statement of the custody write, so the
// trigger fails the transaction once the share row is already in place.
let mut conn = db.get().await.unwrap();
diesel::sql_query(
"CREATE TRIGGER fail_custody_threshold BEFORE UPDATE OF shamir_threshold ON arbiter_settings BEGIN SELECT RAISE(ABORT, 'forced custody failure'); END;",
)
.execute(&mut conn)
.await
.unwrap();
drop(conn);
let error = vault
.bootstrap(KeyCell::from([4u8; 32]), Some(record))
.await
.expect_err("a failing custody write must fail the bootstrap");
assert!(
matches!(error, VaultError::Custody(_)),
"expected a custody error, got {error:?}"
);
assert_eq!(vault.get_state(), VaultState::Unbootstrapped);
assert_eq!(stored_share_count(&db).await, 0);
assert_eq!(stored_threshold(&db).await, None);
let mut conn = db.get().await.unwrap();
let root_count: i64 = schema::root_key_history::table
.count()
.get_result(&mut conn)
.await
.unwrap();
let root_key_id: Option<i32> = schema::arbiter_settings::table
.select(schema::arbiter_settings::root_key_id)
.first(&mut conn)
.await
.unwrap();
assert_eq!(root_count, 0, "the root key write must roll back as well");
assert_eq!(root_key_id, None);
}
#[tokio::test]
#[test_log::test]
async fn oversized_and_degenerate_committees_are_rejected() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let operators = register_operators(&db, 1).await;
for (count, expected) in [
(0_usize, "EmptyCommittee"),
(2, "UnsupportedCommittee"),
(shamir::MAX_COMMITTEE_SIZE + 1, "CommitteeTooLarge"),
(usize::MAX, "CommitteeTooLarge"),
] {
let error = actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: count,
})
.await
.expect_err("committee size must be rejected");
assert!(
format!("{error:?}").contains(expected),
"expected {expected} for count {count}, got {error:?}"
);
}
}
/// A committee whose members never all show up would otherwise wedge the
/// coordinator until restart. Only the operator that declared it may reset it.
#[tokio::test]
#[test_log::test]
async fn only_the_declarer_may_restart_a_stalled_committee() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let operators = register_operators(&db, 3).await;
actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: 3,
})
.await
.unwrap();
actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: operators[0],
passphrase: passphrase(0),
})
.await
.unwrap();
let error = actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[1],
declared_count: 3,
})
.await
.expect_err("a bystander must not reset someone else's ceremony");
assert!(
format!("{error:?}").contains("AlreadyBootstrapping"),
"expected AlreadyBootstrapping, got {error:?}"
);
actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: 1,
})
.await
.expect("the declarer may restart the ceremony");
let finished = actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: operators[0],
passphrase: passphrase(0),
})
.await
.unwrap();
assert!(
finished,
"the restarted one-operator ceremony should complete"
);
assert_eq!(stored_share_count(&db).await, 1);
}
/// A wrong passphrase must not unseal, and the operator must be able to retry.
#[tokio::test]
#[test_log::test]
async fn wrong_passphrase_is_rejected_and_retryable() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let operators = register_operators(&db, 1).await;
actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: 1,
})
.await
.unwrap();
actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: operators[0],
passphrase: passphrase(7),
})
.await
.unwrap();
actors.vault.ask(Seal {}).await.unwrap();
let error = actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: operators[0],
passphrase: passphrase(8),
})
.await
.expect_err("a wrong passphrase must not unseal");
assert!(
format!("{error:?}").contains("InvalidPassphrase"),
"expected InvalidPassphrase, got {error:?}"
);
let unsealed = actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: operators[0],
passphrase: passphrase(7),
})
.await
.expect("the operator may retry with the correct passphrase");
assert!(unsealed, "the retry should unseal the vault");
}

View File

@@ -3,30 +3,30 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_server::{ use arbiter_server::{
actors::{ actors::{
GlobalActors, GlobalActors,
vault::{Error, Vault}, vault::{Error, GetState, Vault, VaultState},
}, vault_coordinator::{
crypto::{ ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal,
KeyCell, Error as CoordinatorError, StartBootstrap, VaultCoordinator,
encryption::v1::{Nonce, ROOT_KEY_TAG}, },
}, },
crypto::{KeyCell, encryption::v1::{Nonce, ROOT_KEY_TAG}},
db::{self, models, schema}, db::{self, models, schema},
}; };
use diesel::{QueryDsl, SelectableHelper}; use diesel::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::actor::Spawn as _;
const TEST_AAD: &[u8] = b"test-aad";
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn bootstrap() { async fn test_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 = KeyCell::from([0u8; 32]);
actor.bootstrap(seal_key, None).await.unwrap(); actor.bootstrap(seal_key).await.unwrap();
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let row: models::RootKeyHistory = schema::root_key_history::table let row: models::RootKeyHistory = schema::root_key_history::table
@@ -44,25 +44,25 @@ async fn bootstrap() {
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn bootstrap_rejects_double() { async fn test_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 = KeyCell::from([0u8; 32]);
let err = actor.bootstrap(seal_key2, None).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 create_new_before_bootstrap_fails() { async fn test_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()), TEST_AAD.to_vec()) .create_new(SafeCell::new(b"data".to_vec()))
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, Error::NotBootstrapped)); assert!(matches!(err, Error::NotBootstrapped));
@@ -70,19 +70,19 @@ async fn create_new_before_bootstrap_fails() {
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn decrypt_before_bootstrap_fails() { async fn test_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, TEST_AAD.to_vec()).await.unwrap_err(); let err = actor.decrypt(1).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 new_restores_sealed_state() { async fn test_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 +90,19 @@ async fn 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, TEST_AAD.to_vec()).await.unwrap_err(); let err = actor2.decrypt(1).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 unseal_correct_password() { async fn test_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()), TEST_AAD.to_vec()) .create_new(SafeCell::new(plaintext.to_vec()))
.await .await
.unwrap(); .unwrap();
drop(actor); drop(actor);
@@ -113,19 +113,19 @@ async fn unseal_correct_password() {
let seal_key = KeyCell::from([0u8; 32]); let seal_key = KeyCell::from([0u8; 32]);
actor.try_unseal(seal_key).await.unwrap(); actor.try_unseal(seal_key).await.unwrap();
let mut decrypted = actor.decrypt(aead_id, TEST_AAD.to_vec()).await.unwrap(); let mut decrypted = actor.decrypt(aead_id).await.unwrap();
assert_eq!(*decrypted.read(), plaintext); assert_eq!(*decrypted.read(), plaintext);
} }
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn unseal_wrong_then_correct_password() { async fn test_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()), TEST_AAD.to_vec()) .create_new(SafeCell::new(plaintext.to_vec()))
.await .await
.unwrap(); .unwrap();
drop(actor); drop(actor);
@@ -141,6 +141,130 @@ async fn unseal_wrong_then_correct_password() {
let good_key = KeyCell::from([0u8; 32]); let good_key = KeyCell::from([0u8; 32]);
actor.try_unseal(good_key).await.unwrap(); actor.try_unseal(good_key).await.unwrap();
let mut decrypted = actor.decrypt(aead_id, TEST_AAD.to_vec()).await.unwrap(); let mut decrypted = actor.decrypt(aead_id).await.unwrap();
assert_eq!(*decrypted.read(), plaintext); assert_eq!(*decrypted.read(), plaintext);
} }
#[tokio::test]
#[test_log::test]
async fn two_operator_vault_requires_recovery_share() {
let db = db::create_test_pool().await;
let bus = GlobalActors::spawn_message_bus();
let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap());
let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db, vault_ref));
let err = coordinator
.ask(StartBootstrap {
operator_id: 1,
declared_count: 2,
recovery_count: 0,
})
.await
.unwrap_err();
assert!(
matches!(
err,
kameo::error::SendError::HandlerError(CoordinatorError::TwoOperatorsRequireRecovery)
),
"expected TwoOperatorsRequireRecovery, got {err:?}"
);
}
/// §3.4: Bootstrap with 1 ordinary + 1 recovery operator produces a valid 1-of-2 Shamir split.
/// Both ordinary and recovery shares are stored; the vault can be unsealed with either one.
#[tokio::test]
#[test_log::test]
async fn recovery_share_stored_and_used_for_unseal() {
let db = db::create_test_pool().await;
let bus = GlobalActors::spawn_message_bus();
let vault_ref = Vault::spawn(Vault::new(db.clone(), bus).await.unwrap());
let coordinator = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref.clone()));
// Register one ordinary operator and one recovery operator in the DB
let ordinary_id: i32 = {
let mut conn = db.get().await.unwrap();
insert_into(schema::operator_identity::table)
.values(schema::operator_identity::public_key.eq(vec![1u8; 32]))
.returning(schema::operator_identity::id)
.get_result(&mut conn)
.await
.unwrap()
};
let recovery_id: i32 = {
let mut conn = db.get().await.unwrap();
insert_into(schema::recovery_operator_identity::table)
.values(schema::recovery_operator_identity::public_key.eq(vec![2u8; 32]))
.returning(schema::recovery_operator_identity::id)
.get_result(&mut conn)
.await
.unwrap()
};
// Declare committee: 1 ordinary + 1 recovery
coordinator
.ask(StartBootstrap {
operator_id: ordinary_id,
declared_count: 1,
recovery_count: 1,
})
.await
.unwrap();
// Recovery operator contributes first — bootstrap should not finalize yet
let done = coordinator
.ask(ContributeRecoveryBootstrap {
recovery_operator_id: recovery_id,
passphrase: SafeCell::new(b"recovery-pass".to_vec()),
})
.await
.unwrap();
assert!(!done, "should not finalize with only recovery passphrase");
// Ordinary operator contributes — now bootstrap finalizes
let done = coordinator
.ask(ContributeBootstrap {
operator_id: ordinary_id,
passphrase: SafeCell::new(b"ordinary-pass".to_vec()),
})
.await
.unwrap();
assert!(done, "should finalize once all contributors are in");
// After bootstrap, vault is Unsealed (seal key still in memory).
let state = vault_ref.ask(GetState {}).await.unwrap();
assert_eq!(state, VaultState::Unsealed);
// Verify recovery_operator row was created
let recovery_share_count: i64 = {
let mut conn = db.get().await.unwrap();
schema::recovery_operator::table
.count()
.get_result(&mut conn)
.await
.unwrap()
};
assert_eq!(recovery_share_count, 1);
// Simulate restart: drop vault and coordinator, create fresh vault (comes up Sealed).
drop(coordinator);
drop(vault_ref);
let bus2 = GlobalActors::spawn_message_bus();
let vault_ref2 = Vault::spawn(Vault::new(db.clone(), bus2).await.unwrap());
let state = vault_ref2.ask(GetState {}).await.unwrap();
assert_eq!(state, VaultState::Sealed);
// §3.5: Unseal using ONLY the recovery operator share (threshold = shamir_threshold(1) = 1).
let coordinator2 = VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault_ref2.clone()));
let done = coordinator2
.ask(ContributeRecoveryUnseal {
recovery_operator_id: recovery_id,
passphrase: SafeCell::new(b"recovery-pass".to_vec()),
})
.await
.unwrap();
assert!(done, "recovery share alone should satisfy threshold");
let state = vault_ref2.ask(GetState {}).await.unwrap();
assert_eq!(state, VaultState::Unsealed);
}

View File

@@ -10,47 +10,45 @@ 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 create_decrypt_roundtrip() { async fn test_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()), TEST_AAD.to_vec()) .create_new(SafeCell::new(plaintext.to_vec()))
.await .await
.unwrap(); .unwrap();
let mut decrypted = actor.decrypt(aead_id, TEST_AAD.to_vec()).await.unwrap(); let mut decrypted = actor.decrypt(aead_id).await.unwrap();
assert_eq!(*decrypted.read(), plaintext); assert_eq!(*decrypted.read(), plaintext);
} }
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
async fn decrypt_nonexistent_returns_not_found() { async fn test_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, TEST_AAD.to_vec()).await.unwrap_err(); let err = actor.decrypt(9999).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 ciphertext_differs_across_entries() { async fn test_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()), TEST_AAD.to_vec()) .create_new(SafeCell::new(plaintext.to_vec()))
.await .await
.unwrap(); .unwrap();
let id2 = actor let id2 = actor
.create_new(SafeCell::new(plaintext.to_vec()), TEST_AAD.to_vec()) .create_new(SafeCell::new(plaintext.to_vec()))
.await .await
.unwrap(); .unwrap();
@@ -70,22 +68,22 @@ async fn ciphertext_differs_across_entries() {
assert_ne!(row1.ciphertext, row2.ciphertext); assert_ne!(row1.ciphertext, row2.ciphertext);
let mut d1 = actor.decrypt(id1, TEST_AAD.to_vec()).await.unwrap(); let mut d1 = actor.decrypt(id1).await.unwrap();
let mut d2 = actor.decrypt(id2, TEST_AAD.to_vec()).await.unwrap(); let mut d2 = actor.decrypt(id2).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 nonce_never_reused() { async fn test_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()), TEST_AAD.to_vec()) .create_new(SafeCell::new(format!("secret {i}").into_bytes()))
.await .await
.unwrap(); .unwrap();
} }
@@ -139,7 +137,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()), TEST_AAD.to_vec()) .create_new(SafeCell::new(b"must fail".to_vec()))
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, Error::BrokenDatabase)); assert!(matches!(err, Error::BrokenDatabase));
@@ -147,7 +145,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()), TEST_AAD.to_vec()) .create_new(SafeCell::new(b"decrypt target".to_vec()))
.await .await
.unwrap(); .unwrap();
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
@@ -158,6 +156,6 @@ async fn broken_db_nonce_format_fails_closed() {
.unwrap(); .unwrap();
drop(conn); drop(conn);
let err = actor.decrypt(id, TEST_AAD.to_vec()).await.unwrap_err(); let err = actor.decrypt(id).await.unwrap_err();
assert!(matches!(err, Error::BrokenDatabase)); assert!(matches!(err, Error::BrokenDatabase));
} }