Compare commits

..

100 Commits

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

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

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

109
AGENTS.md
View File

@@ -1,16 +1,13 @@
# 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
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
- **`useragent/`** — Flutter app (desktop + mobile + web targets) with a Rust core via `flutter_rust_bridge`
- **`protobufs/`** — Protocol Buffer definitions shared between server and clients
- **`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`
- **`operator/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
- **`protobufs/`** — Protocol Buffer definitions shared between server and client
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
```
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/`)
@@ -29,14 +26,10 @@ Key versions live in `mise.toml` (currently Rust 1.95.0 with clippy, Flutter 3.4
| Crate | Purpose |
|---|---|
| `arbiter-proto` | Generated gRPC stubs + protobuf types (`tonic-prost-build`); also `ArbiterUrl`, `home_path()`, `BOOTSTRAP_PATH` |
| `arbiter-crypto` | Shared crypto primitives: `authn` (ML-DSA), `safecell` (hardened memory), `hashing::Hashable`, re-exported `x-wing` |
| `arbiter-macros` | `#[derive(Hashable)]` — canonical hashing of structs for the DB integrity layer |
| `arbiter-server` | Main daemon — actors, peers, DB, EVM policy engine, gRPC service implementation |
| `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.
| `arbiter-proto` | Generated gRPC stubs + protobuf types; compiled from `protobufs/*.proto` via `tonic-prost-build` |
| `arbiter-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation |
| `arbiter-operator` | Rust client library for the operator side of the gRPC protocol |
| `arbiter-client` | Rust client library for SDK clients |
### Common Commands
@@ -49,78 +42,54 @@ cargo build
# Run the server daemon
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
# Run a single test
cargo nextest run <test_name>
# Lint (CI runs it with -D warnings)
cargo clippy --all -- -D warnings
# Lint
cargo clippy
# Security audit
cargo audit
# Supply-chain review (config in server/supply-chain/)
cargo vet
# Check unused dependencies
cargo shear
# Mutation testing
cargo mutants
# Run snapshot tests and update snapshots
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
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
- **`Vault`** — encrypted root key and the Sealed/Unsealed state machine; on unseal decrypts the root key into a `memsafe`-backed `SafeCell`
- **`FlowCoordinator`** — cross-connection flow between operators and SDK clients
- **`OperatorRegistry`** — tracks currently connected operators
- **`EvmActor`** — EVM transaction policy enforcement and signing
- **`events`** — a `kameo_actors::MessageBus` (`DeliveryStrategy::Guaranteed`) for cross-actor notifications
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
- **`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`** — Coordinates cross-connection flow between operators and SDK clients.
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
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.
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.
**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()`.
**Cryptography:**
- Authentication: **ML-DSA-87** (post-quantum, `arbiter-crypto::authn::v1`), challenge-response with per-peer nonce tracking
- Encryption at rest: XChaCha20-Poly1305, versioned modules (`crypto/encryption/v1.rs`) with a `schema_version` column for transparent migration on unseal
- Authentication: ed25519 (challenge-response, nonce-tracked per peer)
- Encryption at rest: XChaCha20-Poly1305 (versioned via `scheme` field for transparent migration on unseal)
- Password KDF: Argon2
- Unseal transport: X25519 ephemeral key exchange (`peers/operator/vault_gate/`); `x-wing` (hybrid PQ KEM) is available via `arbiter-crypto`
- TLS: self-signed certificate (rustls + aws-lc-rs, `prefer-post-quantum`), fingerprint distributed via `ArbiterUrl`
- Unseal transport: X25519 ephemeral key exchange
- 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. `ArbiterUrl` encodes host, port, CA cert and bootstrap token into a single shareable string (printed to console on first run).
**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).
### 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
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
```sh
@@ -131,8 +100,6 @@ diesel migration generate <name> --migration-dir crates/arbiter-server/migration
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
**`#[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.
## 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
```sh
cd useragent
cd operator
# Run the app
# Run the app (macOS or Windows)
flutter run
# Regenerate Rust↔Dart bindings after editing rust/src/api/
mise run codegen # flutter_rust_bridge_codegen generate
# Regenerate Rust↔Dart signal bindings
rinf gen
# Analyze Dart code (also run in CI)
# Analyze Dart code
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.

View File

@@ -0,0 +1,31 @@
Extension Discovery Cache
=========================
This folder is used by `package:extension_discovery` to cache lists of
packages that contains extensions for other packages.
DO NOT USE THIS FOLDER
----------------------
* Do not read (or rely) the contents of this folder.
* Do write to this folder.
If you're interested in the lists of extensions stored in this folder use the
API offered by package `extension_discovery` to get this information.
If this package doesn't work for your use-case, then don't try to read the
contents of this folder. It may change, and will not remain stable.
Use package `extension_discovery`
---------------------------------
If you want to access information from this folder.
Feel free to delete this folder
-------------------------------
Files in this folder act as a cache, and the cache is discarded if the files
are older than the modification time of `.dart_tool/package_config.json`.
Hence, it should never be necessary to clear this cache manually, if you find a
need to do please file a bug.

View File

@@ -0,0 +1 @@
{"version":2,"entries":[{"package":"app","rootUri":"../","packageUri":"lib/"}]}

View File

@@ -0,0 +1,178 @@
{
"configVersion": 2,
"packages": [
{
"name": "async",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/async-2.13.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "boolean_selector",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "characters",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/characters-1.4.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "clock",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/clock-1.1.2",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "collection",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/collection-1.19.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "cupertino_icons",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/cupertino_icons-1.0.8",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "fake_async",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/fake_async-1.3.3",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "flutter",
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter",
"packageUri": "lib/",
"languageVersion": "3.8"
},
{
"name": "flutter_lints",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/flutter_lints-6.0.0",
"packageUri": "lib/",
"languageVersion": "3.8"
},
{
"name": "flutter_test",
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/packages/flutter_test",
"packageUri": "lib/",
"languageVersion": "3.8"
},
{
"name": "leak_tracker",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker-11.0.2",
"packageUri": "lib/",
"languageVersion": "3.2"
},
{
"name": "leak_tracker_flutter_testing",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_flutter_testing-3.0.10",
"packageUri": "lib/",
"languageVersion": "3.2"
},
{
"name": "leak_tracker_testing",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/leak_tracker_testing-3.0.2",
"packageUri": "lib/",
"languageVersion": "3.2"
},
{
"name": "lints",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/lints-6.1.0",
"packageUri": "lib/",
"languageVersion": "3.8"
},
{
"name": "matcher",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/matcher-0.12.17",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "material_color_utilities",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/material_color_utilities-0.11.1",
"packageUri": "lib/",
"languageVersion": "2.17"
},
{
"name": "meta",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/meta-1.17.0",
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "path",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/path-1.9.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "sky_engine",
"rootUri": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable/bin/cache/pkg/sky_engine",
"packageUri": "lib/",
"languageVersion": "3.8"
},
{
"name": "source_span",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/source_span-1.10.2",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "stack_trace",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stack_trace-1.12.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "stream_channel",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/stream_channel-2.1.4",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "string_scanner",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/string_scanner-1.4.1",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "term_glyph",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/term_glyph-1.2.2",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "test_api",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/test_api-0.7.7",
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "vector_math",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vector_math-2.2.0",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "vm_service",
"rootUri": "file:///Users/kaska/.pub-cache/hosted/pub.dev/vm_service-15.0.2",
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "app",
"rootUri": "../",
"packageUri": "lib/",
"languageVersion": "3.10"
}
],
"generator": "pub",
"generatorVersion": "3.10.8",
"flutterRoot": "file:///Users/kaska/.local/share/mise/installs/flutter/3.38.9-stable",
"flutterVersion": "3.38.9",
"pubCache": "file:///Users/kaska/.pub-cache"
}

View File

@@ -0,0 +1,230 @@
{
"roots": [
"app"
],
"packages": [
{
"name": "app",
"version": "1.0.0+1",
"dependencies": [
"cupertino_icons",
"flutter"
],
"devDependencies": [
"flutter_lints",
"flutter_test"
]
},
{
"name": "flutter_lints",
"version": "6.0.0",
"dependencies": [
"lints"
]
},
{
"name": "flutter_test",
"version": "0.0.0",
"dependencies": [
"clock",
"collection",
"fake_async",
"flutter",
"leak_tracker_flutter_testing",
"matcher",
"meta",
"path",
"stack_trace",
"stream_channel",
"test_api",
"vector_math"
]
},
{
"name": "cupertino_icons",
"version": "1.0.8",
"dependencies": []
},
{
"name": "flutter",
"version": "0.0.0",
"dependencies": [
"characters",
"collection",
"material_color_utilities",
"meta",
"sky_engine",
"vector_math"
]
},
{
"name": "lints",
"version": "6.1.0",
"dependencies": []
},
{
"name": "stream_channel",
"version": "2.1.4",
"dependencies": [
"async"
]
},
{
"name": "meta",
"version": "1.17.0",
"dependencies": []
},
{
"name": "collection",
"version": "1.19.1",
"dependencies": []
},
{
"name": "leak_tracker_flutter_testing",
"version": "3.0.10",
"dependencies": [
"flutter",
"leak_tracker",
"leak_tracker_testing",
"matcher",
"meta"
]
},
{
"name": "vector_math",
"version": "2.2.0",
"dependencies": []
},
{
"name": "stack_trace",
"version": "1.12.1",
"dependencies": [
"path"
]
},
{
"name": "clock",
"version": "1.1.2",
"dependencies": []
},
{
"name": "fake_async",
"version": "1.3.3",
"dependencies": [
"clock",
"collection"
]
},
{
"name": "path",
"version": "1.9.1",
"dependencies": []
},
{
"name": "matcher",
"version": "0.12.17",
"dependencies": [
"async",
"meta",
"stack_trace",
"term_glyph",
"test_api"
]
},
{
"name": "test_api",
"version": "0.7.7",
"dependencies": [
"async",
"boolean_selector",
"collection",
"meta",
"source_span",
"stack_trace",
"stream_channel",
"string_scanner",
"term_glyph"
]
},
{
"name": "sky_engine",
"version": "0.0.0",
"dependencies": []
},
{
"name": "material_color_utilities",
"version": "0.11.1",
"dependencies": [
"collection"
]
},
{
"name": "characters",
"version": "1.4.0",
"dependencies": []
},
{
"name": "async",
"version": "2.13.0",
"dependencies": [
"collection",
"meta"
]
},
{
"name": "leak_tracker_testing",
"version": "3.0.2",
"dependencies": [
"leak_tracker",
"matcher",
"meta"
]
},
{
"name": "leak_tracker",
"version": "11.0.2",
"dependencies": [
"clock",
"collection",
"meta",
"path",
"vm_service"
]
},
{
"name": "term_glyph",
"version": "1.2.2",
"dependencies": []
},
{
"name": "string_scanner",
"version": "1.4.1",
"dependencies": [
"source_span"
]
},
{
"name": "source_span",
"version": "1.10.2",
"dependencies": [
"collection",
"path",
"term_glyph"
]
},
{
"name": "boolean_selector",
"version": "2.1.2",
"dependencies": [
"source_span",
"string_scanner"
]
},
{
"name": "vm_service",
"version": "15.0.2",
"dependencies": []
}
],
"configVersion": 1
}

1
app/.dart_tool/version Normal file
View File

@@ -0,0 +1 @@
3.38.9

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"
[[tools.rust]]
version = "1.98.1"
version = "1.95.0"
backend = "core:rust"
[tools.rust.options]
components = "clippy,rust-analyzer"

View File

@@ -4,7 +4,7 @@
"cargo:cargo-vet" = "0.10.2"
flutter = "3.41.7-stable"
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-nextest" = "0.9.133"
"cargo:cargo-shear" = "latest"
@@ -22,5 +22,3 @@ run = '''
dart pub global activate protoc_plugin && \
protoc --dart_out=grpc:useragent/lib/proto --proto_path=protobufs/ $(find protobufs -name '*.proto' | sort)
'''
[tasks.generate_schema]

View File

@@ -4,28 +4,25 @@ package arbiter.operator;
import "operator/auth.proto";
import "operator/evm.proto";
import "operator/governance.proto";
import "operator/sdk_client.proto";
import "operator/vault/vault.proto";
message OperatorRequest {
int32 id = 16;
oneof payload {
auth.Request auth = 1;
vault.Request vault = 2;
evm.Request evm = 3;
sdk_client.Request sdk_client = 4;
governance.Request governance = 5;
auth.Request auth = 1;
vault.Request vault = 2;
evm.Request evm = 3;
sdk_client.Request sdk_client = 4;
}
}
message OperatorResponse {
optional int32 id = 16;
oneof payload {
auth.Response auth = 1;
vault.Response vault = 2;
evm.Response evm = 3;
sdk_client.Response sdk_client = 4;
governance.Response governance = 5;
auth.Response auth = 1;
vault.Response vault = 2;
evm.Response evm = 3;
sdk_client.Response sdk_client = 4;
}
}

View File

@@ -1,136 +0,0 @@
syntax = "proto3";
package arbiter.operator.governance;
message Request {
oneof payload {
CreateProposalRequest create = 1;
CastVoteRequest vote = 2;
QueryPendingRequest query = 3;
}
}
message CreateProposalRequest {
oneof kind {
ApproveSdkClientPayload approve_sdk_client = 1;
GrantWalletAccessPayload grant_wallet_access = 3;
ApproveServerUpdatePayload approve_server_update = 4;
ReplaceOperatorPayload replace_operator = 5;
UpdateShamirParametersPayload update_shamir_parameters = 6;
ApprovePersistentGrantPayload approve_persistent_grant = 7;
ApproveOneOffTransactionPayload approve_one_off_transaction = 8;
}
optional uint32 ttl_secs = 2;
}
message ReplaceOperatorPayload {
int32 old_operator_id = 1;
bytes new_pubkey = 2;
}
message UpdateShamirParametersPayload {
uint32 new_n = 1;
}
message ApproveServerUpdatePayload {}
message ApproveSdkClientPayload {
int32 client_id = 1;
}
message GrantWalletAccessPayload {
int32 wallet_id = 1;
int32 client_id = 2;
}
message CastVoteRequest {
int32 proposal_id = 1;
bool approve = 2;
bytes signature = 3;
}
message QueryPendingRequest {}
message Response {
oneof payload {
CreateProposalResponse created = 1;
VoteResponse voted = 2;
QueryPendingResponse pending = 3;
}
}
message CreateProposalResponse {
int32 proposal_id = 1;
}
message VoteResponse {
VoteOutcome outcome = 1;
}
enum VoteOutcome {
VOTE_OUTCOME_UNSPECIFIED = 0;
VOTE_OUTCOME_PENDING = 1;
VOTE_OUTCOME_APPROVED = 2;
VOTE_OUTCOME_REJECTED = 3;
}
message ProposalSummary {
int32 id = 1;
string kind = 2;
int32 initiator_id = 3;
int64 expires_at = 4;
int64 approve_count = 5;
int64 reject_count = 6;
}
message QueryPendingResponse {
repeated ProposalSummary proposals = 1;
}
message TransactionRateLimitProto {
uint32 count = 1;
int64 window_secs = 2;
}
message VolumeLimitProto {
bytes max_volume = 1;
int64 window_secs = 2;
}
message EtherTransferSpecProto {
repeated bytes targets = 1;
VolumeLimitProto limit = 2;
}
message TokenTransferSpecProto {
bytes token_contract = 1;
optional bytes target = 2;
repeated VolumeLimitProto volume_limits = 3;
}
message ApproveOneOffTransactionPayload {
int32 client_id = 1;
bytes wallet_address = 2;
uint64 chain_id = 3;
uint64 nonce = 4;
uint64 gas_limit = 5;
bytes max_fee_per_gas = 6;
bytes max_priority_fee_per_gas = 7;
bytes to = 8;
bytes value = 9;
bytes input = 10;
}
message ApprovePersistentGrantPayload {
int32 wallet_access_id = 1;
uint64 chain_id = 2;
optional int64 valid_from_secs = 3;
optional int64 valid_until_secs = 4;
optional bytes max_gas_fee_per_gas = 5;
optional bytes max_priority_fee_per_gas = 6;
optional TransactionRateLimitProto rate_limit = 7;
oneof specific {
EtherTransferSpecProto ether_transfer = 8;
TokenTransferSpecProto token_transfer = 9;
}
}

View File

@@ -8,35 +8,15 @@ message BootstrapEncryptedKey {
bytes associated_data = 3;
}
message DeclareCommittee {
uint32 count = 1;
uint32 recovery_count = 2;
}
message ContributePassphrase {
bytes passphrase = 1;
}
message ContributeRecoveryPassphrase {
int32 recovery_operator_id = 1;
bytes passphrase = 2;
}
enum BootstrapResult {
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
BOOTSTRAP_RESULT_SUCCESS = 1;
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
BOOTSTRAP_RESULT_INVALID_KEY = 3;
BOOTSTRAP_RESULT_AWAITING_CONTRIBUTIONS = 4;
}
message Request {
oneof payload {
BootstrapEncryptedKey encrypted_key = 2;
DeclareCommittee declare_committee = 3;
ContributePassphrase contribute_passphrase = 4;
ContributeRecoveryPassphrase contribute_recovery_passphrase = 5;
}
BootstrapEncryptedKey encrypted_key = 2;
}
message Response {

View File

@@ -1,30 +0,0 @@
syntax = "proto3";
package arbiter.operator.vault.rekey;
message ContributePassphrase {
bytes passphrase = 1;
}
message ContributeRecoveryPassphrase {
int32 recovery_operator_id = 1;
bytes passphrase = 2;
}
enum RekeyResult {
REKEY_RESULT_UNSPECIFIED = 0;
REKEY_RESULT_SUCCESS = 1;
REKEY_RESULT_AWAITING_CONTRIBUTIONS = 2;
REKEY_RESULT_NOT_IN_PROGRESS = 3;
}
message Request {
oneof payload {
ContributePassphrase contribute_passphrase = 1;
ContributeRecoveryPassphrase contribute_recovery_passphrase = 2;
}
}
message Response {
RekeyResult result = 1;
}

View File

@@ -15,29 +15,18 @@ message UnsealEncryptedKey {
bytes associated_data = 3;
}
message ContributePassphrase {
bytes passphrase = 1;
}
message ContributeRecoveryPassphrase {
int32 recovery_operator_id = 1;
bytes passphrase = 2;
}
enum UnsealResult {
UNSEAL_RESULT_UNSPECIFIED = 0;
UNSEAL_RESULT_SUCCESS = 1;
UNSEAL_RESULT_INVALID_KEY = 2;
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
UNSEAL_RESULT_AWAITING_CONTRIBUTIONS = 4;
UNSEAL_RESULT_LOCKED_OUT = 4;
}
message Request {
oneof payload {
UnsealStart start = 1;
UnsealEncryptedKey encrypted_key = 2;
ContributePassphrase contribute_passphrase = 3;
ContributeRecoveryPassphrase contribute_recovery_passphrase = 4;
UnsealStart start = 1;
UnsealEncryptedKey encrypted_key = 2;
}
}

View File

@@ -5,23 +5,20 @@ package arbiter.operator.vault;
import "google/protobuf/empty.proto";
import "shared/vault.proto";
import "operator/vault/bootstrap.proto";
import "operator/vault/rekey.proto";
import "operator/vault/unseal.proto";
message Request {
oneof payload {
google.protobuf.Empty query_state = 1;
unseal.Request unseal = 2;
bootstrap.Request bootstrap = 3;
rekey.Request rekey = 4;
unseal.Request unseal = 2;
bootstrap.Request bootstrap = 3;
}
}
message Response {
oneof payload {
arbiter.shared.VaultState state = 1;
unseal.Response unseal = 2;
bootstrap.Response bootstrap = 3;
rekey.Response rekey = 4;
arbiter.shared.VaultState state = 1;
unseal.Response unseal = 2;
bootstrap.Response bootstrap = 3;
}
}

View File

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

Binary file not shown.

298
server/Cargo.lock generated
View File

@@ -24,7 +24,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
"generic-array 0.14.7",
"generic-array",
]
[[package]]
@@ -504,7 +504,7 @@ dependencies = [
"async-trait",
"auto_impl",
"either",
"elliptic-curve 0.13.8",
"elliptic-curve",
"k256",
"thiserror",
]
@@ -787,7 +787,6 @@ dependencies = [
"tonic",
"tracing",
"tracing-subscriber",
"vsss-rs",
"x25519-dalek 2.0.1",
]
@@ -822,7 +821,7 @@ dependencies = [
"ark-serialize 0.3.0",
"ark-std 0.3.0",
"derivative",
"num-bigint 0.4.6",
"num-bigint",
"num-traits",
"paste",
"rustc_version 0.3.3",
@@ -842,7 +841,7 @@ dependencies = [
"derivative",
"digest 0.10.7",
"itertools 0.10.5",
"num-bigint 0.4.6",
"num-bigint",
"num-traits",
"paste",
"rustc_version 0.4.1",
@@ -863,7 +862,7 @@ dependencies = [
"digest 0.10.7",
"educe",
"itertools 0.13.0",
"num-bigint 0.4.6",
"num-bigint",
"num-traits",
"paste",
"zeroize",
@@ -905,7 +904,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20"
dependencies = [
"num-bigint 0.4.6",
"num-bigint",
"num-traits",
"quote",
"syn 1.0.109",
@@ -917,7 +916,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565"
dependencies = [
"num-bigint 0.4.6",
"num-bigint",
"num-traits",
"proc-macro2",
"quote",
@@ -930,7 +929,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3"
dependencies = [
"num-bigint 0.4.6",
"num-bigint",
"num-traits",
"proc-macro2",
"quote",
@@ -955,7 +954,7 @@ checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5"
dependencies = [
"ark-std 0.4.0",
"digest 0.10.7",
"num-bigint 0.4.6",
"num-bigint",
]
[[package]]
@@ -967,7 +966,7 @@ dependencies = [
"ark-std 0.5.0",
"arrayvec",
"digest 0.10.7",
"num-bigint 0.4.6",
"num-bigint",
]
[[package]]
@@ -1197,12 +1196,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base16ct"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
[[package]]
name = "base64"
version = "0.22.1"
@@ -1291,7 +1284,7 @@ version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array 0.14.7",
"generic-array",
]
[[package]]
@@ -1560,12 +1553,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1626,35 +1613,19 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array 0.14.7",
"generic-array",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-bigint"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
dependencies = [
"cpubits",
"ctutils",
"hybrid-array",
"num-traits",
"rand_core 0.10.1",
"serdect 0.4.3",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array 0.14.7",
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -1676,7 +1647,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
"subtle",
]
[[package]]
@@ -1839,7 +1809,7 @@ dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint 0.4.6",
"num-bigint",
"num-traits",
"rusticata-macros",
]
@@ -1958,7 +1928,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
dependencies = [
"generic-array 0.14.7",
"generic-array",
]
[[package]]
@@ -2036,9 +2006,9 @@ checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der 0.7.10",
"digest 0.10.7",
"elliptic-curve 0.13.8",
"elliptic-curve",
"rfc6979",
"serdect 0.2.0",
"serdect",
"signature 2.2.0",
"spki 0.7.3",
]
@@ -2070,52 +2040,20 @@ version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct 0.2.0",
"crypto-bigint 0.5.5",
"base16ct",
"crypto-bigint",
"digest 0.10.7",
"ff 0.13.1",
"generic-array 0.14.7",
"group 0.13.0",
"ff",
"generic-array",
"group",
"pkcs8 0.10.2",
"rand_core 0.6.4",
"sec1 0.7.3",
"serdect 0.2.0",
"sec1",
"serdect",
"subtle",
"zeroize",
]
[[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",
]
[[package]]
name = "elliptic-curve-tools"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0d5e534f103b079a71ef1d66c6e62c89413f1b62709ca11f744429b4afe5b4"
dependencies = [
"elliptic-curve 0.14.1",
"heapless",
"serde",
"serdect 0.4.3",
]
[[package]]
name = "enum-ordinalize"
version = "4.3.2"
@@ -2190,16 +2128,6 @@ dependencies = [
"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]]
name = "fiat-crypto"
version = "0.2.9"
@@ -2396,17 +2324,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "generic-array"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb130435a959a8d525e6bca66ff6c40981a300ee96d70e3ef56f046556d614a3"
dependencies = [
"rustversion",
"serde_core",
"typenum",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -2466,22 +2383,11 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff 0.13.1",
"ff",
"rand_core 0.6.4",
"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]]
name = "h2"
version = "0.4.13"
@@ -2501,15 +2407,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "hash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -2550,16 +2447,6 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "heapless"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4"
dependencies = [
"hash32",
"stable_deref_trait",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -2652,13 +2539,11 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.15"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
dependencies = [
"ctutils",
"serde",
"subtle",
"typenum",
"zeroize",
]
@@ -2924,7 +2809,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array 0.14.7",
"generic-array",
]
[[package]]
@@ -3061,17 +2946,17 @@ checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
dependencies = [
"cfg-if",
"ecdsa",
"elliptic-curve 0.13.8",
"elliptic-curve",
"once_cell",
"serdect 0.2.0",
"serdect",
"sha2 0.10.9",
"signature 2.2.0",
]
[[package]]
name = "kameo"
version = "0.22.2"
source = "git+https://github.com/hdbg/kameo.git?rev=3bbebac#3bbebac9f2a943be75588d80826e6bea45079d5f"
version = "0.20.0"
source = "git+https://github.com/hdbg/kameo.git?rev=805b417#805b41783fe90b54827ecad142b422c7a9b69b9a"
dependencies = [
"downcast-rs",
"dyn-clone",
@@ -3084,8 +2969,8 @@ dependencies = [
[[package]]
name = "kameo_actors"
version = "0.8.1"
source = "git+https://github.com/hdbg/kameo.git?rev=3bbebac#3bbebac9f2a943be75588d80826e6bea45079d5f"
version = "0.5.0"
source = "git+https://github.com/hdbg/kameo.git?rev=805b417#805b41783fe90b54827ecad142b422c7a9b69b9a"
dependencies = [
"futures",
"glob",
@@ -3096,13 +2981,14 @@ dependencies = [
[[package]]
name = "kameo_macros"
version = "0.21.1"
source = "git+https://github.com/hdbg/kameo.git?rev=3bbebac#3bbebac9f2a943be75588d80826e6bea45079d5f"
version = "0.20.0"
source = "git+https://github.com/hdbg/kameo.git?rev=805b417#805b41783fe90b54827ecad142b422c7a9b69b9a"
dependencies = [
"darling 0.23.0",
"heck",
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 2.0.117",
]
[[package]]
@@ -3446,17 +3332,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [
"num-integer",
"num-traits",
"serde",
]
[[package]]
name = "num-conv"
version = "0.2.1"
@@ -4351,7 +4226,7 @@ dependencies = [
"bytes",
"fastrlp 0.3.1",
"fastrlp 0.4.0",
"num-bigint 0.4.6",
"num-bigint",
"num-integer",
"num-traits",
"parity-scale-codec",
@@ -4578,26 +4453,11 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct 0.2.0",
"base16ct",
"der 0.7.10",
"generic-array 0.14.7",
"generic-array",
"pkcs8 0.10.2",
"serdect 0.2.0",
"subtle",
"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",
"serdect",
"subtle",
"zeroize",
]
@@ -4759,17 +4619,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177"
dependencies = [
"base16ct 0.2.0",
"serde",
]
[[package]]
name = "serdect"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
dependencies = [
"base16ct 1.0.0",
"base16ct",
"serde",
]
@@ -4815,17 +4665,6 @@ dependencies = [
"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]]
name = "sha3-asm"
version = "0.1.6"
@@ -4836,17 +4675,6 @@ dependencies = [
"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]]
name = "sharded-slab"
version = "0.1.7"
@@ -4980,12 +4808,6 @@ dependencies = [
"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]]
name = "sqlite-wasm-rs"
version = "0.5.3"
@@ -5113,17 +4935,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn-solidity"
version = "1.5.7"
@@ -5764,29 +5575,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsss-rs"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bfc736cfd88115aedb95ba84bc2d428fe351e92a56f69fce090af301402d91"
dependencies = [
"crypto-bigint 0.7.5",
"elliptic-curve 0.14.1",
"elliptic-curve-tools",
"ff 0.14.0",
"generic-array 1.4.2",
"hex",
"hybrid-array",
"num-bigint 0.5.1",
"num-traits",
"rand_core 0.10.1",
"serde",
"sha3 0.12.0",
"shake",
"subtle",
"zeroize",
]
[[package]]
name = "wait-timeout"
version = "0.2.1"

View File

@@ -12,8 +12,8 @@ base64 = "0.22.1"
chrono = { version = "0.4.44", features = ["serde"] }
futures = "0.3.32"
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "3bbebac"}
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "3bbebac"}
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
hmac = "0.13.0"
miette = { version = "7.6.0", features = ["fancy", "serde"] }
ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] }
@@ -78,7 +78,6 @@ pub_underscore_fields = "allow"
redundant_pub_crate = "allow"
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
alloc_instead_of_core = "warn"

View File

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

View File

@@ -23,10 +23,6 @@ pub mod proto {
tonic::include_proto!("arbiter.operator.evm");
}
pub mod governance {
tonic::include_proto!("arbiter.operator.governance");
}
pub mod sdk_client {
tonic::include_proto!("arbiter.operator.sdk_client");
}
@@ -38,10 +34,6 @@ pub mod proto {
tonic::include_proto!("arbiter.operator.vault.bootstrap");
}
pub mod rekey {
tonic::include_proto!("arbiter.operator.vault.rekey");
}
pub mod unseal {
tonic::include_proto!("arbiter.operator.vault.unseal");
}

View File

@@ -51,7 +51,6 @@ subtle = "2.6.1"
x25519-dalek.workspace = true
k256.workspace = true
kameo_actors.workspace = true
vsss-rs = "6.0.1"
[dev-dependencies]
proptest = "1.11.0"

View File

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

View File

@@ -1,160 +1,119 @@
use crate::{
actors::vault::events,
db::{self, schema},
};
use crate::db::{self, DatabasePool, schema};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_proto::{BOOTSTRAP_PATH, home_path};
use diesel::QueryDsl;
use diesel_async::RunQueryDsl;
use kameo::{
Actor,
actor::ActorRef,
messages,
prelude::{Context, Message},
};
use kameo_actors::message_bus::{MessageBus, Register};
use kameo::{Actor, messages};
use rand::{RngExt, distr::Alphanumeric, rngs::SysRng};
use rand_core::UnwrapErr;
use std::path::{Path, PathBuf};
use subtle::ConstantTimeEq as _;
use thiserror::Error;
use tracing::warn;
const TOKEN_LENGTH: usize = 64;
async fn write_token_file(path: &Path, content: &str) -> Result<(), std::io::Error> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(path, content.as_bytes()).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?;
}
Ok(())
}
async fn remove_token_file(path: &Path) {
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"),
}
Ok(())
}
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
for (slot, b) in buf
.iter_mut()
.zip(UnwrapErr(SysRng).sample_iter(Alphanumeric))
{
*slot = byte;
*slot = b;
}
}
let token = cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned());
write_token_file(path, &token).await?;
let token_str = cell.read_inline(|buf| String::from_utf8_lossy(buf.as_ref()).into_owned());
write_token_file(path, &token_str).await?;
Ok(cell)
}
#[derive(Debug, thiserror::Error)]
#[derive(Error, Debug)]
pub enum Error {
#[error("Database error: {0}")]
Database(#[from] db::PoolError),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Database query error: {0}")]
Query(#[from] diesel::result::Error),
}
/// Custodian of the one-time bootstrap token.
///
/// 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.
#[derive(Actor)]
pub struct Bootstrapper {
token: Option<SafeCell<[u8; TOKEN_LENGTH]>>,
token_path: Option<PathBuf>,
events: ActorRef<MessageBus>,
}
impl Actor for Bootstrapper {
type Args = Self;
type Error = std::convert::Infallible;
impl Bootstrapper {
pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
let row_count: i64 = {
let mut conn = db.get().await?;
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)
schema::operator_client::table
.count()
.get_result(&mut conn)
.await?
};
let (token, token_path) = if row_count == 0 {
let path = home_path()?.join(BOOTSTRAP_PATH);
let token = generate_token(&path).await?;
(Some(token), Some(path))
} else {
(None, None)
};
Ok(Self { token, token_path })
}
}
impl Bootstrapper {
pub async fn new(db: &db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
let path = home_path()?.join(BOOTSTRAP_PATH);
let mut conn = db.get().await?;
let bootstrapped = schema::arbiter_settings::table
.select(schema::arbiter_settings::root_key_id)
.first::<Option<i32>>(&mut conn)
.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 {
token: Some(generate_token(&path).await?),
token_path: Some(path),
events,
fn is_correct_token(&mut self, token: &[u8]) -> bool {
self.token.as_mut().is_some_and(|expected| {
expected.read_inline(|exp| bool::from(exp.as_ref().ct_eq(token)))
})
}
async fn forget(&mut self) {
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]
impl Bootstrapper {
#[message]
pub fn verify_token(&mut self, token: Vec<u8>) -> bool {
self.token.as_mut().is_some_and(|expected| {
expected.read_inline(|bytes| bool::from(bytes.as_ref().ct_eq(token.as_slice())))
})
pub async fn consume_token(&mut self, token: Vec<u8>) -> bool {
if self.is_correct_token(&token) {
self.token = None;
if let Some(path) = self.token_path.take()
&& let Err(e) = tokio::fs::remove_file(&path).await
{
warn!(error = ?e, path = ?path, "Failed to delete bootstrap token file after consumption");
}
true
} else {
false
}
}
}
#[messages]
impl Bootstrapper {
#[message]
pub fn get_token(&mut self) -> Option<String> {
self.token

View File

@@ -3,7 +3,7 @@ use crate::{
crypto::integrity::{self, Integrable},
db::{
DatabaseError, DatabasePool,
models::{self, EvmWalletId},
models::{self},
schema,
},
evm::{
@@ -162,7 +162,7 @@ impl EvmActor {
}
#[message]
pub async fn list_wallets(&self) -> Result<Vec<(EvmWalletId, Address)>, Error> {
pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
.select(models::EvmWallet::as_select())
@@ -295,18 +295,18 @@ impl EvmActor {
address: wallet_address,
aead_encrypted_id: wallet.aead_encrypted_id,
},
wallet.id.to_raw(),
wallet.id,
)
.await
.map_err(|e| {
error!(?e, ?wallet.id, "EVM wallet integrity check failed");
error!(?e, wallet_id = wallet.id, "EVM wallet integrity check failed");
SignTransactionError::Internal
})?;
drop(conn);
if attestation != integrity::AttestationStatus::Attested {
error!(
?wallet.id,
wallet_id = wallet.id,
"EVM wallet integrity unavailable; refusing to sign"
);
return Err(SignTransactionError::Internal);

View File

@@ -1,7 +1,7 @@
use crate::{
actors::{
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
operator_registry::OperatorRegistry, vault::Vault, vault_coordinator::VaultCoordinator,
operator_registry::OperatorRegistry, vault::Vault,
},
db,
};
@@ -15,22 +15,20 @@ pub mod evm;
pub mod flow_coordinator;
pub mod operator_registry;
pub mod vault;
pub mod vault_coordinator;
#[derive(Error, Debug)]
pub enum SpawnError {
#[error("Failed to spawn Bootstrapper actor")]
Bootstrapper(#[from] bootstrap::Error),
#[error("Failed to spawn Vault actor")]
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)]
pub struct GlobalActors {
pub vault: ActorRef<Vault>,
pub vault_coordinator: ActorRef<VaultCoordinator>,
pub bootstrapper: ActorRef<Bootstrapper>,
pub flow_coordinator: ActorRef<FlowCoordinator>,
pub operator_registry: ActorRef<OperatorRegistry>,
@@ -44,22 +42,18 @@ impl GlobalActors {
}
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
let events = Self::spawn_message_bus();
let vault = Vault::spawn(Vault::new(db.clone(), events.clone()).await?);
let bootstrapper = Bootstrapper::spawn(Bootstrapper::new(&db, events.clone()).await?);
let vault_coordinator =
VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault.clone()));
let message_bus = Self::spawn_message_bus();
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
Ok(Self {
bootstrapper,
evm: EvmActor::spawn(EvmActor::new(vault.clone(), db.clone())),
vault,
vault_coordinator,
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)),
vault: key_holder,
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
operator_registry.clone(),
)),
operator_registry,
events,
events: message_bus,
})
}
}

View File

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

View File

@@ -1,17 +1,15 @@
use crate::{
crypto::{
KeyCell,
KeyCell, derive_key,
encryption::v1::{self, Nonce},
integrity::v1::HmacSha256,
},
db::{
self,
custody::{self, CustodyRecord},
models::{self, RootKeyHistory, RootKeyHistoryId},
models::{self, RootKeyHistory},
schema::{self},
},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use chrono::Utc;
@@ -27,6 +25,7 @@ use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info, warn};
pub mod events {
#[derive(Clone, Copy)]
pub struct Bootstrapped;
@@ -62,18 +61,15 @@ pub enum Error {
#[error("Database transaction error: {0}")]
DatabaseTransaction(#[from] diesel::result::Error),
#[error("Custody storage error: {0}")]
Custody(#[from] custody::Error),
#[error("Broken database")]
BrokenDatabase,
#[error("Integrity key version mismatch: envelope uses key {envelope:?}, current key is {current:?}")]
KeyVersionMismatch { envelope: RootKeyHistoryId, current: RootKeyHistoryId },
#[error("Integrity key version mismatch: envelope uses key {envelope}, current key is {current}")]
KeyVersionMismatch { envelope: i32, current: i32 },
}
struct Unsealed {
root_key_history_id: RootKeyHistoryId,
root_key_history_id: i32,
root_key: KeyCell,
}
@@ -82,9 +78,8 @@ struct Unsealed {
enum State {
#[default]
Unbootstrapped,
Sealed {
root_key_history_id: RootKeyHistoryId,
root_key_history_id: i32,
},
Unsealed(Unsealed),
}
@@ -123,20 +118,12 @@ impl Vault {
}
};
Ok(Self {
db,
state,
events,
unseal_failures: 0,
})
Ok(Self { db, state, events, unseal_failures: 0 })
}
// Exclusive transaction to avoid race condtions if multiple vaults write
// additional layer of protection against nonce-reuse
async fn get_new_nonce(
pool: &db::DatabasePool,
root_key_id: RootKeyHistoryId,
) -> Result<Nonce, Error> {
async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> {
let mut conn = pool.get().await?;
let nonce = conn
@@ -149,7 +136,7 @@ impl Vault {
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
error!(
"Broken database: invalid nonce for root key history id={:#?}",
"Broken database: invalid nonce for root key history id={}",
root_key_id
);
Error::BrokenDatabase
@@ -177,16 +164,13 @@ impl Vault {
}
}
/// Create the root key and take the vault into the unsealed state.
#[message]
pub async fn bootstrap(
&mut self,
mut seal_key: KeyCell,
custody: Option<CustodyRecord>,
) -> Result<(), Error> {
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if !matches!(self.state, State::Unbootstrapped) {
return Err(Error::AlreadyBootstrapped);
}
let salt = v1::generate_salt();
let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = KeyCell::new_secure_random();
// Zero nonces are fine because they are one-time
@@ -208,14 +192,14 @@ impl Vault {
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let root_key_history_id = conn
.transaction(async |conn| {
let root_key_history_id = insert_into(schema::root_key_history::table)
let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: root_key_ciphertext.clone(),
tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: root_key_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce_bytes.clone(),
schema_version: 1,
salt: v1::generate_salt().to_vec(),
salt: salt.to_vec(),
})
.returning(schema::root_key_history::id)
.get_result(&mut *conn)
@@ -226,11 +210,7 @@ impl Vault {
.execute(&mut *conn)
.await?;
if let Some(record) = custody.as_ref() {
custody::write_record(&mut *conn, record).await?;
}
Result::<_, Error>::Ok(RootKeyHistoryId::from_raw(root_key_history_id))
Result::<_, diesel::result::Error>::Ok(root_key_history_id)
})
.await?;
@@ -246,7 +226,7 @@ impl Vault {
}
#[message]
pub async fn try_unseal(&mut self, mut seal_key: KeyCell) -> Result<(), Error> {
pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if self.unseal_failures >= MAX_UNSEAL_ATTEMPTS {
return Err(Error::LockedOut);
}
@@ -268,6 +248,13 @@ impl Vault {
.await?
};
let salt = &current_key.salt;
let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| {
error!("Broken database: invalid salt for root key");
Error::BrokenDatabase
})?;
let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
let nonce =
@@ -383,10 +370,7 @@ impl Vault {
}
#[message]
pub fn sign_integrity(
&mut self,
mac_input: Vec<u8>,
) -> Result<(RootKeyHistoryId, Vec<u8>), Error> {
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
let Unsealed {
root_key,
root_key_history_id,
@@ -396,7 +380,7 @@ impl Vault {
HmacSha256::new_from_slice(k)
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
});
hmac.update(&root_key_history_id.to_raw().to_be_bytes());
hmac.update(&root_key_history_id.to_be_bytes());
hmac.update(&mac_input);
let mac = hmac.finalize().into_bytes().to_vec();
@@ -408,7 +392,7 @@ impl Vault {
&mut self,
mac_input: Vec<u8>,
expected_mac: Vec<u8>,
key_version: RootKeyHistoryId,
key_version: i32,
) -> Result<bool, Error> {
let Unsealed {
root_key,
@@ -426,7 +410,7 @@ impl Vault {
HmacSha256::new_from_slice(k)
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
});
hmac.update(&key_version.to_raw().to_be_bytes());
hmac.update(&key_version.to_be_bytes());
hmac.update(&mac_input);
Ok(hmac.verify_slice(&expected_mac).is_ok())
@@ -450,6 +434,7 @@ impl Vault {
#[cfg(test)]
mod tests {
use crate::actors::GlobalActors;
use arbiter_crypto::safecell::SafeCellHandle as _;
use super::*;
@@ -457,8 +442,8 @@ mod tests {
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
let seal_key = KeyCell::from([0u8; 32]);
actor.bootstrap(seal_key, None).await.unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.bootstrap(seal_key).await.unwrap();
actor
}

View File

@@ -1,378 +0,0 @@
//! Coordinates the multi-operator ceremonies that create and open the vault.
//!
//! 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 [`db::custody`].
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use argon2::RECOMMENDED_SALT_LEN;
use kameo::{Actor, actor::ActorRef, error::SendError, messages};
use rand::rngs::SysRng;
use rand_core::{Rng as _, UnwrapErr};
use crate::{
actors::vault::{self, Bootstrap, TryUnseal, Vault},
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir},
db::{
self,
custody::{self, CustodyRecord, EncryptedShare},
models::OperatorId,
},
};
const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("An ordinary committee is already being coordinated")]
AlreadyBootstrapping,
#[error("An unseal is already being coordinated")]
AlreadyUnsealing,
#[error("Bootstrap is not in progress")]
NotBootstrapping,
#[error("The operator already contributed")]
DuplicateContribution,
#[error("The ordinary committee cannot be empty")]
EmptyCommittee,
#[error("Two-operator committees are unsupported")]
UnsupportedCommittee,
#[error(
"The ordinary committee cannot exceed {} members",
shamir::MAX_COMMITTEE_SIZE
)]
CommitteeTooLarge,
#[error("Invalid passphrase")]
InvalidPassphrase,
#[error("Broken database")]
BrokenDatabase,
#[error("Shamir error: {0}")]
Shamir(String),
#[error("Database connection error: {0}")]
DatabaseConnection(#[from] db::PoolError),
#[error("Custody storage error: {0}")]
Custody(#[from] custody::Error),
#[error("Encryption error")]
Encryption,
#[error("The vault is already bootstrapped")]
AlreadyBootstrapped,
#[error("Vault error")]
Vault,
}
/// Passphrases gathered so far, in contribution order.
///
/// 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()
}
}
enum CoordinatorState {
Idle,
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,
contributions: Contributions,
retryable: bool,
},
Unsealing {
threshold: usize,
contributions: Contributions,
retryable: bool,
},
}
#[derive(Actor)]
pub struct VaultCoordinator {
db: db::DatabasePool,
vault: ActorRef<Vault>,
state: CoordinatorState,
}
impl VaultCoordinator {
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
Self {
db,
vault,
state: CoordinatorState::Idle,
}
}
}
/// Explain why a committee size was rejected.
const fn committee_error(declared_count: usize) -> Error {
match declared_count {
0 => Error::EmptyCommittee,
2 => Error::UnsupportedCommittee,
_ => Error::CommitteeTooLarge,
}
}
fn encrypt_share(
passphrase: &mut SafeCell<Vec<u8>>,
share: &[u8],
) -> Result<EncryptedShare, Error> {
let mut salt = [0u8; RECOMMENDED_SALT_LEN];
UnwrapErr(SysRng).fill_bytes(&mut salt);
let nonce = Nonce::default();
let ciphertext = derive_key(passphrase, &salt)
.encrypt(&nonce, SHARE_AAD, share)
.map_err(|_| Error::Encryption)?;
Ok(EncryptedShare {
ciphertext,
nonce: nonce.to_vec(),
salt: salt.to_vec(),
})
}
fn decrypt_share(
passphrase: &mut SafeCell<Vec<u8>>,
share: EncryptedShare,
) -> Result<SafeCell<Vec<u8>>, Error> {
let nonce = Nonce::try_from(share.nonce.as_slice()).map_err(|()| Error::BrokenDatabase)?;
let mut buffer = SafeCell::new(share.ciphertext);
derive_key(passphrase, &share.salt)
.decrypt_in_place(&nonce, SHARE_AAD, &mut buffer)
.map_err(|_| Error::InvalidPassphrase)?;
Ok(buffer)
}
const fn bootstrap_error(error: &SendError<Bootstrap, vault::Error>) -> Error {
match error {
SendError::HandlerError(vault::Error::AlreadyBootstrapped) => Error::AlreadyBootstrapped,
_ => Error::Vault,
}
}
/// 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(
vault: &ActorRef<Vault>,
contributions: &mut Contributions,
) -> Result<(), Error> {
let total = contributions.len();
let threshold = shamir::shamir_threshold(total).ok_or_else(|| committee_error(total))?;
let mut seal_key = KeyCell::new_secure_random();
let mut shares = shamir::split_key(threshold, total, &mut seal_key, UnwrapErr(SysRng))
.map_err(|error| Error::Shamir(error.to_string()))?;
if shares.len() < total {
return Err(Error::Shamir("missing share for operator".to_owned()));
}
let mut encrypted = Vec::with_capacity(total);
for ((operator_id, passphrase), share) in contributions.0.iter_mut().zip(shares.iter_mut()) {
let share = share.read_inline(|share| encrypt_share(passphrase, share))?;
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?;
custody::shares(&mut conn, &contributions.operators()).await?
};
let mut plaintext = Vec::with_capacity(stored.len());
for ((_, passphrase), share) in contributions.0.iter_mut().zip(stored) {
plaintext.push(decrypt_share(passphrase, share)?);
}
let seal_key = shamir::combine_shares(threshold, &mut plaintext)
.map_err(|error| Error::Shamir(error.to_string()))?;
vault
.ask(TryUnseal { seal_key })
.await
.map_err(|_| Error::Vault)
}
#[messages]
impl VaultCoordinator {
/// Announce how many operators will contribute to the bootstrap.
///
/// 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]
pub fn start_bootstrap(
&mut self,
operator_id: OperatorId,
declared_count: usize,
) -> Result<(), Error> {
if shamir::shamir_threshold(declared_count).is_none() {
return Err(committee_error(declared_count));
}
match &self.state {
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 {
declarer: operator_id,
declared_count,
contributions: Contributions::default(),
retryable: false,
};
Ok(())
}
#[message]
pub async fn contribute_bootstrap(
&mut self,
operator_id: OperatorId,
passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
let CoordinatorState::Bootstrapping {
declared_count,
contributions,
retryable,
..
} = &mut self.state
else {
return Err(Error::NotBootstrapping);
};
if contributions.contains(operator_id) && !*retryable {
return Err(Error::DuplicateContribution);
}
contributions.put(operator_id, passphrase);
*retryable = false;
if contributions.len() < *declared_count {
return Ok(false);
}
let state = std::mem::replace(&mut self.state, CoordinatorState::Idle);
let CoordinatorState::Bootstrapping {
declarer,
declared_count,
mut contributions,
..
} = state
else {
unreachable!("state was matched as Bootstrapping above")
};
match finalize_bootstrap(&self.vault, &mut contributions).await {
Ok(()) => Ok(true),
Err(error) => {
self.state = CoordinatorState::Bootstrapping {
declarer,
declared_count,
contributions,
retryable: true,
};
Err(error)
}
}
}
#[message]
pub async fn contribute_unseal(
&mut self,
operator_id: OperatorId,
passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
if matches!(self.state, CoordinatorState::Idle) {
let threshold = {
let mut conn = self.db.get().await?;
custody::threshold(&mut conn).await?
};
self.state = CoordinatorState::Unsealing {
threshold,
contributions: Contributions::default(),
retryable: false,
};
}
let CoordinatorState::Unsealing {
threshold,
contributions,
retryable,
} = &mut self.state
else {
return Err(Error::AlreadyBootstrapping);
};
if contributions.contains(operator_id) && !*retryable {
return Err(Error::DuplicateContribution);
}
contributions.put(operator_id, passphrase);
*retryable = false;
if contributions.len() < *threshold {
return Ok(false);
}
let state = std::mem::replace(&mut self.state, CoordinatorState::Idle);
let CoordinatorState::Unsealing {
threshold,
mut contributions,
..
} = state
else {
unreachable!("state was matched as Unsealing above")
};
match finalize_unseal(&self.db, &self.vault, threshold, &mut contributions).await {
Ok(()) => Ok(true),
Err(error) => {
self.state = CoordinatorState::Unsealing {
threshold,
contributions,
retryable: true,
};
Err(error)
}
}
}
}

View File

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

View File

@@ -215,9 +215,9 @@ mod tests {
GlobalActors,
vault::{Bootstrap, Vault},
},
crypto::KeyCell,
db::{self, schema},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use super::{Error, Integrable, sign_entity, verify_entity};
#[derive(Clone, arbiter_macros::Hashable)]
@@ -237,8 +237,7 @@ mod tests {
);
actor
.ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]),
custody: None,
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
})
.await
.unwrap();

View File

@@ -1,5 +1,5 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use encryption::v1::Nonce;
use encryption::v1::{Nonce, Salt};
use argon2::{Algorithm, Argon2};
use chacha20poly1305::{
@@ -13,7 +13,6 @@ use rand::{
pub mod encryption;
pub mod integrity;
pub mod shamir;
pub struct KeyCell(pub SafeCell<Key>);
impl From<SafeCell<Key>> for KeyCell {
@@ -21,16 +20,6 @@ impl From<SafeCell<Key>> for KeyCell {
Self(value)
}
}
impl From<[u8; 32]> for KeyCell {
fn from(bytes: [u8; 32]) -> Self {
let cell = SafeCell::new_inline(|key: &mut Key| {
key.copy_from_slice(&bytes);
});
Self(cell)
}
}
impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
type Error = ();
@@ -69,7 +58,6 @@ impl KeyCell {
let buffer = buffer.as_mut();
cipher.encrypt_in_place(nonce, associated_data, buffer)
}
pub fn decrypt_in_place(
&mut self,
nonce: &Nonce,
@@ -105,11 +93,8 @@ impl KeyCell {
}
}
/// Derive a fixed-length key from a passphrase using Argon2id.
///
/// 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 {
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
let params = {
#[cfg(debug_assertions)]
{
@@ -147,11 +132,11 @@ mod tests {
#[test]
fn encrypt_decrypt() {
static PASSWORD: &[u8] = b"password";
let mut password = SafeCell::new(PASSWORD.to_vec());
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_key(&mut password, &salt);
let nonce = Nonce(*b"unique nonce 123 1231233");
let mut key = derive_key(password, &salt);
let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
let associated_data = b"associated data";
let mut buffer = b"secret data".to_vec();

View File

@@ -1,221 +0,0 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use rand_core::CryptoRng;
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)]
pub enum ShamirError {
#[error("failed to split key: {0}")]
Split(String),
#[error("failed to combine shares: {0}")]
Combine(String),
}
/// Return the required threshold for a Shamir share pool of `committee_size`.
///
/// 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(
threshold: usize,
total: usize,
key: &mut KeyCell,
rng: impl CryptoRng,
) -> Result<Vec<SafeCell<Vec<u8>>>, ShamirError> {
if total == 0 || threshold == 0 || threshold > total || total == 2 || total > MAX_COMMITTEE_SIZE
{
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.
///
/// `threshold` comes from storage rather than from the shapes of the shares:
/// a one-of-one committee stores the key verbatim, and telling that apart by
/// share length alone would misread any Shamir share that happened to be key
/// sized.
pub fn combine_shares(
threshold: usize,
shares: &mut [SafeCell<Vec<u8>>],
) -> 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"
);
}
}

View File

@@ -1,137 +0,0 @@
//! Storage for Shamir custody material: the reconstruction threshold and the
//! per-operator encrypted shares of the vault seal key.
//!
//! The queries live here so that the actors above hold no Diesel code of their
//! own. Every one of them borrows the caller's connection instead of taking one
//! from the pool, which is what lets the vault write custody material inside
//! the same transaction that stores the root key.
use std::collections::HashMap;
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use crate::db::{
self,
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 db::DatabaseConnection,
record: &CustodyRecord,
) -> Result<(), Error> {
let threshold = i32::try_from(record.threshold).map_err(|_| Error::BrokenThreshold)?;
// SQLite has no batch form for REPLACE INTO in diesel, so the rows go
// in one at a time. The caller's transaction still makes them atomic.
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 db::DatabaseConnection) -> 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)
}
/// Load the shares of `operators` in one query, in the order requested.
pub async fn shares(
conn: &mut db::DatabaseConnection,
operators: &[OperatorId],
) -> Result<Vec<EncryptedShare>, Error> {
/// (id, then the three columns that make up [`EncryptedShare`])
type ShareRow = (Option<OperatorId>, Vec<u8>, Vec<u8>, Vec<u8>);
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 tracing::info;
pub mod custody;
pub mod models;
pub mod schema;
@@ -155,39 +154,3 @@ pub async fn create_test_pool() -> DatabasePool {
.await
.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

@@ -79,53 +79,10 @@ pub mod types {
}
}
macro_rules! declare_id {
($name:ident) => {
#[derive(Debug, FromSqlRow, AsExpression, Clone, Hash, Copy, PartialEq, Eq)]
#[diesel(sql_type = Integer)]
#[repr(transparent)] // hint compiler to optimize the wrapper struct away
pub struct $name(i32);
impl $name {
pub const fn to_raw(self) -> i32 {
self.0
}
pub const fn from_raw(raw: i32) -> Self {
Self(raw)
}
}
impl FromSql<Integer, Sqlite> for $name {
fn from_sql(
bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
FromSql::<Integer, Sqlite>::from_sql(bytes).map(Self)
}
}
impl ToSql<Integer, Sqlite> for $name {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
}
}
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)
}
}
};
}
declare_id!(ChainId);
#[derive(Debug, FromSqlRow, AsExpression, Clone)]
#[diesel(sql_type = Integer)]
#[repr(transparent)] // hint compiler to optimize the wrapper struct away
pub struct ChainId(pub i32);
#[expect(
clippy::cast_sign_loss,
@@ -146,13 +103,21 @@ pub mod types {
}
};
declare_id!(OperatorId);
declare_id!(OperatorIdentityId);
declare_id!(AeadEncryptedId);
declare_id!(RootKeyHistoryId);
declare_id!(TlsHistoryId);
declare_id!(EvmWalletId);
declare_id!(ClientId);
impl FromSql<Integer, Sqlite> for ChainId {
fn from_sql(
bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
FromSql::<Integer, Sqlite>::from_sql(bytes).map(Self)
}
}
impl ToSql<Integer, Sqlite> for ChainId {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
}
}
}
pub use types::*;
@@ -165,12 +130,12 @@ pub use types::*;
)]
#[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))]
pub struct AeadEncrypted {
pub id: AeadEncryptedId,
pub id: i32,
pub ciphertext: Vec<u8>,
pub tag: Vec<u8>,
pub current_nonce: Vec<u8>,
pub schema_version: i32,
pub associated_root_key_id: RootKeyHistoryId,
pub associated_root_key_id: i32, // references root_key_history.id
pub created_at: SqliteTimestamp,
}
@@ -183,7 +148,7 @@ pub struct AeadEncrypted {
attributes_with = "deriveless"
)]
pub struct RootKeyHistory {
pub id: RootKeyHistoryId,
pub id: i32,
pub ciphertext: Vec<u8>,
pub tag: Vec<u8>,
pub root_key_encryption_nonce: Vec<u8>,
@@ -201,7 +166,7 @@ pub struct RootKeyHistory {
attributes_with = "deriveless"
)]
pub struct TlsHistory {
pub id: TlsHistoryId,
pub id: i32,
pub cert: String,
pub cert_key: String, // PEM Encoded private key
pub ca_cert: String, // PEM Encoded certificate for cert signing
@@ -215,7 +180,6 @@ pub struct ArbiterSettings {
pub id: i32,
pub root_key_id: Option<i32>, // references root_key_history.id
pub tls_id: Option<i32>, // references tls_history.id
pub shamir_threshold: Option<i32>,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
@@ -227,7 +191,7 @@ pub struct ArbiterSettings {
attributes_with = "deriveless"
)]
pub struct EvmWallet {
pub id: EvmWalletId,
pub id: i32,
pub address: Vec<u8>,
pub aead_encrypted_id: i32,
pub created_at: SqliteTimestamp,
@@ -249,7 +213,7 @@ pub struct EvmWallet {
)]
pub struct EvmWalletAccess {
pub id: i32,
pub wallet_id: EvmWalletId,
pub wallet_id: i32,
pub client_id: i32,
pub created_at: SqliteTimestamp,
}
@@ -276,7 +240,7 @@ pub struct ProgramClientMetadataHistory {
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = schema::program_client, check_for_backend(Sqlite))]
pub struct ProgramClient {
pub id: ClientId,
pub id: i32,
pub public_key: Vec<u8>,
pub metadata_id: i32,
pub created_at: SqliteTimestamp,
@@ -286,23 +250,12 @@ pub struct ProgramClient {
#[derive(Queryable, Debug)]
#[diesel(table_name = schema::operator_client, check_for_backend(Sqlite))]
pub struct OperatorClient {
pub id: OperatorIdentityId,
pub id: i32,
pub public_key: Vec<u8>,
pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp,
}
#[derive(Queryable, Debug)]
#[diesel(table_name = schema::operator, check_for_backend(Sqlite))]
pub struct Operator {
pub id: OperatorId,
pub share: Vec<u8>,
pub share_nonce: Vec<u8>,
pub share_salt: Vec<u8>,
pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_ether_transfer_limit, check_for_backend(Sqlite))]
#[view(
@@ -446,7 +399,7 @@ pub struct IntegrityEnvelope {
pub entity_kind: String,
pub entity_id: Vec<u8>,
pub payload_version: i32,
pub key_version: RootKeyHistoryId,
pub key_version: i32,
pub mac: Vec<u8>,
pub signed_at: SqliteTimestamp,
pub created_at: SqliteTimestamp,

View File

@@ -17,7 +17,6 @@ diesel::table! {
id -> Integer,
root_key_id -> Nullable<Integer>,
tls_id -> Nullable<Integer>,
shamir_threshold -> Nullable<Integer>,
}
}
@@ -153,26 +152,6 @@ diesel::table! {
}
}
diesel::table! {
operator (id) {
id -> Nullable<Integer>,
share -> Binary,
share_nonce -> Binary,
share_salt -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
operator_identity (id) {
id -> Integer,
public_key -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
program_client (id) {
id -> Integer,
@@ -206,6 +185,15 @@ diesel::table! {
}
}
diesel::table! {
operator_client (id) {
id -> Integer,
public_key -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::joinable!(aead_encrypted -> root_key_history (associated_root_key_id));
diesel::joinable!(arbiter_settings -> root_key_history (root_key_id));
diesel::joinable!(arbiter_settings -> tls_history (tls_id));
@@ -224,7 +212,6 @@ diesel::joinable!(evm_transaction_log -> evm_wallet_access (wallet_access_id));
diesel::joinable!(evm_wallet -> aead_encrypted (aead_encrypted_id));
diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id));
diesel::joinable!(evm_wallet_access -> program_client (client_id));
diesel::joinable!(operator -> operator_identity (id));
diesel::joinable!(program_client -> client_metadata (metadata_id));
diesel::allow_tables_to_appear_in_same_query!(
@@ -243,9 +230,8 @@ diesel::allow_tables_to_appear_in_same_query!(
evm_wallet,
evm_wallet_access,
integrity_envelope,
operator,
operator_identity,
program_client,
root_key_history,
tls_history,
operator_client,
);

View File

@@ -502,6 +502,7 @@ impl Engine {
#[cfg(test)]
mod tests {
use alloy::primitives::{Address, Bytes, U256, address};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
@@ -509,13 +510,11 @@ mod tests {
use rstest::rstest;
use crate::actors::{GlobalActors, vault::{Bootstrap, Vault}};
use crate::crypto::KeyCell;
use crate::crypto::integrity;
use crate::db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog,
SqliteTimestamp,
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
};
@@ -535,7 +534,7 @@ mod tests {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: EvmWalletId::from_raw(5),
wallet_id: 10,
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},
@@ -772,8 +771,7 @@ mod tests {
);
actor
.ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]),
custody: None,
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
})
.await
.unwrap();
@@ -826,7 +824,7 @@ mod tests {
let wallet_access = EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: EvmWalletId::from_raw(10),
wallet_id: 10,
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
};

View File

@@ -3,8 +3,7 @@ use crate::{
db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog,
SqliteTimestamp,
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
},
@@ -32,7 +31,7 @@ fn ctx(to: Address, value: U256) -> EvalContext {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: EvmWalletId::from_raw(10),
wallet_id: 10,
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},

View File

@@ -2,7 +2,7 @@ use super::{Settings, TokenTransfer};
use crate::{
db::{
self, DatabaseConnection,
models::{EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, SqliteTimestamp},
models::{EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, SqliteTimestamp},
schema::evm_basic_grant,
},
evm::{
@@ -45,7 +45,7 @@ fn ctx(to: Address, calldata: Bytes) -> EvalContext {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: EvmWalletId::from_raw(10),
wallet_id: 10,
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},

View File

@@ -1,10 +1,11 @@
use crate::{
grpc::request_tracker::RequestTracker,
peers::operator::{OperatorConnection, OperatorSession, OutOfBand},
peers::operator::{OutOfBand, OperatorConnection, OperatorSession},
};
use arbiter_proto::{
proto::operator::{
OperatorRequest, OperatorResponse, operator_request::Payload as OperatorRequestPayload,
OperatorRequest, OperatorResponse,
operator_request::Payload as OperatorRequestPayload,
operator_response::Payload as OperatorResponsePayload,
},
transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi},
@@ -110,9 +111,6 @@ async fn dispatch_inner(
OperatorRequestPayload::Vault(req) => vault::dispatch(actor, req).await,
OperatorRequestPayload::Evm(req) => evm::dispatch(actor, req).await,
OperatorRequestPayload::SdkClient(req) => sdk_client::dispatch(actor, req).await,
OperatorRequestPayload::Governance(_) => {
Err(Status::permission_denied(stringify!(Governance)))
}
OperatorRequestPayload::Auth(..) => {
warn!("Unsupported post-auth operator auth request");
Err(Status::invalid_argument("Unsupported operator request"))

View File

@@ -90,7 +90,7 @@ async fn handle_wallet_list(
.into_iter()
.map(|(id, address)| WalletEntry {
address: address.to_vec(),
id: id.to_raw(),
id,
})
.collect(),
}),

View File

@@ -1,10 +1,11 @@
use crate::{
db::models::{CoreEvmWalletAccess, EvmWalletId, NewEvmWalletAccess},
db::models::{CoreEvmWalletAccess, NewEvmWalletAccess},
evm::policies::{
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer,
token_transfers,
},
grpc::{Convert, TryConvert},
grpc::Convert,
grpc::TryConvert,
};
use arbiter_proto::{
proto::evm::{
@@ -150,7 +151,7 @@ impl Convert for WalletAccess {
fn convert(self) -> Self::Output {
NewEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(self.wallet_id),
wallet_id: self.wallet_id,
client_id: self.sdk_client_id,
}
}
@@ -165,7 +166,7 @@ impl TryConvert for SdkClientWalletAccess {
return Err(Status::invalid_argument("Missing wallet access entry"));
};
Ok(CoreEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(access.wallet_id),
wallet_id: access.wallet_id,
client_id: access.sdk_client_id,
id: self.id,
})

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ use crate::{
peers::operator::{OperatorSession, session::handlers::HandleQueryVaultState},
};
use arbiter_proto::{
proto::shared::VaultState as ProtoVaultState,
proto::operator::{
operator_response::Payload as OperatorResponsePayload,
vault::{
@@ -10,7 +11,6 @@ use arbiter_proto::{
response::Payload as VaultResponsePayload,
},
},
proto::shared::VaultState as ProtoVaultState,
};
use kameo::actor::ActorRef;
@@ -33,11 +33,11 @@ pub(super) async fn dispatch(
match payload {
VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await,
VaultRequestPayload::Unseal(_)
| VaultRequestPayload::Bootstrap(_)
| VaultRequestPayload::Rekey(_) => Err(Status::permission_denied(
"Vault is already unsealed; unseal/bootstrap not permitted in session",
)),
VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => {
Err(Status::permission_denied(
"Vault is already unsealed; unseal/bootstrap not permitted in session",
))
}
}
}

View File

@@ -1,17 +1,14 @@
use crate::{
crypto::shamir,
grpc::{Convert, TryConvert},
peers::operator::vault_gate::{
self as vault_gate, HandleBootstrapEncryptedKey, HandleContributeBootstrapPassphrase,
HandleContributeUnsealPassphrase, HandleDeclareCommittee, HandleHandshake,
HandleUnsealEncryptedKey,
self as vault_gate, HandleBootstrapEncryptedKey, HandleHandshake, HandleUnsealEncryptedKey,
},
};
use arbiter_proto::proto::operator::{
operator_request::Payload as OperatorRequestPayload,
vault::{
self as proto_vault,
bootstrap::{self as proto_bootstrap, request::Payload as BootstrapRequestPayload},
bootstrap::{self as proto_bootstrap},
request::Payload as VaultRequestPayload,
unseal::{self as proto_unseal, request::Payload as UnsealRequestPayload},
},
@@ -53,7 +50,6 @@ impl TryConvert for VaultRequestPayload {
Self::QueryState(()) => Ok(vault_gate::Inbound::HandleVaultState),
Self::Unseal(req) => req.try_convert(),
Self::Bootstrap(req) => req.try_convert(),
Self::Rekey(_) => Err(Status::unimplemented("Vault re-key is not available")),
}
}
}
@@ -77,16 +73,6 @@ impl TryConvert for UnsealRequestPayload {
match self {
Self::Start(start) => start.try_convert(),
Self::EncryptedKey(key) => Ok(key.convert()),
Self::ContributePassphrase(passphrase) => {
Ok(vault_gate::Inbound::HandleContributeUnsealPassphrase(
HandleContributeUnsealPassphrase {
passphrase: passphrase.passphrase,
},
))
}
Self::ContributeRecoveryPassphrase(_) => Err(Status::unimplemented(
"Recovery operator contributions are not available",
)),
}
}
}
@@ -121,52 +107,12 @@ impl TryConvert for proto_bootstrap::Request {
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
self.payload
.ok_or_else(|| Status::invalid_argument("Missing bootstrap payload"))?
self.encrypted_key
.ok_or_else(|| Status::invalid_argument("Missing bootstrap encrypted key"))?
.try_convert()
}
}
impl TryConvert for BootstrapRequestPayload {
type Output = vault_gate::Inbound;
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
match self {
Self::EncryptedKey(key) => key.try_convert(),
Self::DeclareCommittee(dc) => {
if dc.recovery_count != 0 {
return Err(Status::unimplemented(
"Recovery operator contributions are not available",
));
}
let count = usize::try_from(dc.count)
.ok()
.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 {
passphrase: cp.passphrase,
},
))
}
Self::ContributeRecoveryPassphrase(_) => Err(Status::unimplemented(
"Recovery operator contributions are not available",
)),
}
}
}
impl TryConvert for proto_bootstrap::BootstrapEncryptedKey {
type Output = vault_gate::Inbound;
type Error = Status;

View File

@@ -1,9 +1,10 @@
use crate::{
actors::{vault::VaultState, vault_coordinator},
actors::vault::VaultState,
grpc::{Convert, TryConvert},
peers::operator::vault_gate::{self as vault_gate},
};
use arbiter_proto::proto::{
shared::VaultState as ProtoVaultState,
operator::{
operator_response::Payload as OperatorResponsePayload,
vault::{
@@ -16,7 +17,6 @@ use arbiter_proto::proto::{
},
},
},
shared::VaultState as ProtoVaultState,
};
use tonic::Status;
@@ -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 {
wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response {
result: result.into(),
@@ -107,6 +87,7 @@ impl TryConvert for vault_gate::Outbound {
let proto_result = match result {
Ok(()) => ProtoUnsealResult::Success,
Err(vault_gate::Error::InvalidKey) => ProtoUnsealResult::InvalidKey,
Err(vault_gate::Error::LockedOut) => ProtoUnsealResult::LockedOut,
Err(err) => {
warn!(?err, "unseal failed");
return Err(Status::internal("Failed to unseal vault"));
@@ -130,56 +111,6 @@ impl TryConvert for vault_gate::Outbound {
};
Ok(wrap_bootstrap_response(proto_result))
}
Self::HandleDeclareCommittee(result) => {
let proto_result = match result {
Ok(()) => ProtoBootstrapResult::AwaitingContributions,
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) => {
warn!(?err, "declare committee failed");
return Err(Status::internal("Failed to declare committee"));
}
};
Ok(wrap_bootstrap_response(proto_result))
}
Self::HandleContributeBootstrapPassphrase(result) => {
let proto_result = match result {
Ok(true) => ProtoBootstrapResult::Success,
Ok(false) => ProtoBootstrapResult::AwaitingContributions,
Err(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) => {
warn!(?err, "contribute bootstrap passphrase failed");
return Err(Status::internal(
"Failed to contribute bootstrap passphrase",
));
}
};
Ok(wrap_bootstrap_response(proto_result))
}
Self::HandleContributeUnsealPassphrase(result) => {
let proto_result = match result {
Ok(true) => ProtoUnsealResult::Success,
Ok(false) => ProtoUnsealResult::AwaitingContributions,
Err(err) => {
warn!(?err, "contribute unseal passphrase failed");
return Err(Status::internal("Failed to contribute unseal passphrase"));
}
};
Ok(wrap_unseal_response(UnsealResponsePayload::Result(
proto_result.into(),
)))
}
}
}
}

View File

@@ -3,8 +3,8 @@ use super::{
Error,
};
use crate::{
actors::bootstrap::VerifyToken,
db::{DatabasePool, models::OperatorId, schema::operator_identity},
actors::bootstrap::ConsumeToken,
db::{DatabasePool, schema::operator_client},
peers::operator::auth::Outbound,
};
use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT};
@@ -37,19 +37,16 @@ smlang::statemachine!(
}
);
async fn get_client_id(
db: &DatabasePool,
pubkey: &authn::PublicKey,
) -> Result<Option<OperatorId>, Error> {
async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<Option<i32>, Error> {
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
operator_identity::table
.filter(operator_identity::public_key.eq(pubkey.to_bytes()))
.select(operator_identity::id)
.first::<OperatorId>(&mut conn)
operator_client::table
.filter(operator_client::public_key.eq(pubkey.to_bytes()))
.select(operator_client::id)
.first::<i32>(&mut conn)
.await
.optional()
.map_err(|e| {
@@ -58,16 +55,16 @@ 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 mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
let id: OperatorId = diesel::insert_into(operator_identity::table)
.values((operator_identity::public_key.eq(pubkey_bytes),))
.returning(operator_identity::id)
let id: i32 = diesel::insert_into(operator_client::table)
.values((operator_client::public_key.eq(pubkey_bytes),))
.returning(operator_client::id)
.get_result(&mut conn)
.await
.map_err(|e| {
@@ -159,7 +156,7 @@ where
.conn
.actors
.bootstrapper
.ask(VerifyToken { token })
.ask(ConsumeToken { token })
.await
.map_err(|e| {
error!(?e, "Failed to consume bootstrap token");

View File

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

View File

@@ -1,17 +1,13 @@
use super::{Error, OperatorSession};
use crate::{
actors::{
evm::{
ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorListGrants,
SignTransactionError as EvmSignError,
},
flow_coordinator::{IsClientConnected, client_connect_approval::ClientApprovalAnswer},
vault::VaultState,
actors::evm::{
ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorListGrants,
SignTransactionError as EvmSignError,
},
actors::flow_coordinator::{IsClientConnected, client_connect_approval::ClientApprovalAnswer},
actors::vault::VaultState,
db::{
models::{
EvmWalletAccess, EvmWalletId, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
},
models::{EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata},
schema::program_client,
},
evm::policies::{Grant, SpecificGrant},
@@ -80,9 +76,7 @@ impl OperatorSession {
}
#[message]
pub(crate) async fn handle_evm_wallet_list(
&mut self,
) -> Result<Vec<(EvmWalletId, Address)>, Error> {
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<(i32, Address)>, Error> {
match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => Ok(wallets),
Err(err) => {
@@ -334,7 +328,7 @@ impl OperatorSession {
#[cfg(test)]
mod tests {
use crate::db::{self, models::{EvmWalletId, NewEvmWalletAccess}, schema::evm_wallet_access};
use crate::db::{self, models::NewEvmWalletAccess, schema::evm_wallet_access};
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
@@ -354,7 +348,7 @@ mod tests {
// 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),
wallet_id: 1,
client_id: 10,
})
.returning(EvmWalletAccess::as_select())
@@ -364,7 +358,7 @@ mod tests {
let entry_b = diesel::insert_into(evm_wallet_access::table)
.values(NewEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(1),
wallet_id: 1,
client_id: 20,
})
.returning(EvmWalletAccess::as_select())
@@ -417,7 +411,7 @@ mod tests {
for i in 1_i32..=5 {
diesel::insert_into(evm_wallet_access::table)
.values(NewEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(99),
wallet_id: 99,
client_id: i,
})
.execute(&mut *conn)
@@ -428,7 +422,7 @@ mod tests {
// 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),
wallet_id: 1,
client_id: 1,
})
.returning(EvmWalletAccess::as_select())
@@ -438,7 +432,7 @@ mod tests {
// Sanity: target.id != target.wallet_id
assert_ne!(
target.id, target.wallet_id.to_raw(),
target.id, target.wallet_id,
"test prerequisite: id and wallet_id must differ"
);

View File

@@ -3,9 +3,8 @@ use crate::{
actors::{
GlobalActors,
vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events},
vault_coordinator::{self, ContributeBootstrap, ContributeUnseal, StartBootstrap},
},
crypto::{KeyCell, integrity},
crypto::integrity::{self},
db::DatabasePool,
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
@@ -28,27 +27,17 @@ pub enum Error {
InvalidKey,
#[error("Vault locked: too many failed unseal attempts")]
LockedOut,
#[error("State transition failed")]
State,
#[error("Vault ceremony failed: {0}")]
Ceremony(#[from] vault_coordinator::Error),
#[error("Internal error: {0}")]
Internal(String),
}
impl Error {
fn internal(message: impl Into<String>) -> Self {
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 {
@@ -82,6 +71,7 @@ impl VaultGate {
impl Actor for VaultGate {
type Args = Self;
type Error = ();
async fn on_start(
@@ -112,8 +102,11 @@ impl VaultGate {
associated_data: &[u8],
) -> Result<SafeCell<Vec<u8>>, ()> {
let nonce = XNonce::from_slice(nonce);
let cipher = XChaCha20Poly1305::new(secret.as_bytes().into());
let mut key_buffer = SafeCell::new(ciphertext.to_vec());
let decryption_result = key_buffer.write_inline(|write_handle| {
cipher.decrypt_in_place(nonce, associated_data, write_handle)
});
@@ -126,13 +119,9 @@ impl VaultGate {
}
}
}
fn key_cell(buffer: SafeCell<Vec<u8>>) -> Result<KeyCell, Error> {
KeyCell::try_from(buffer).map_err(|()| Error::InvalidKey)
}
}
#[messages]
#[messages(messages = Inbound, replies = Outbound)]
impl VaultGate {
#[message]
pub fn handle_handshake(
@@ -141,11 +130,14 @@ impl VaultGate {
) -> Result<HandshakeResponse, Error> {
let ephemeral_secret = EphemeralSecret::random();
let public_key = PublicKey::from(&ephemeral_secret);
let secret = ephemeral_secret.diffie_hellman(&client_pubkey);
self.state = State::ReadyForExchange {
server_key: public_key,
secret,
};
Ok(HandshakeResponse {
server_pubkey: public_key,
})
@@ -161,11 +153,20 @@ impl VaultGate {
let State::ReadyForExchange { secret, .. } = &self.state else {
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_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
else {
return Err(Error::InvalidKey);
};
match self
.actors
.vault
.ask(TryUnseal {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(()) => {
info!("Successfully unsealed key with client-provided key");
Ok(())
@@ -193,16 +194,17 @@ impl VaultGate {
let State::ReadyForExchange { secret, .. } = &self.state else {
return Err(Error::State);
};
let seal_key = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
.map_err(|()| Error::InvalidKey)
.and_then(Self::key_cell)?;
let Ok(seal_key_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
else {
return Err(Error::InvalidKey);
};
match self
.actors
.vault
.ask(Bootstrap {
seal_key,
custody: None,
seal_key_raw: seal_key_buffer,
})
.await
{
@@ -226,53 +228,14 @@ impl VaultGate {
#[message]
pub async fn handle_vault_state(&mut self) -> Result<VaultState, Error> {
self.actors
let answer = self
.actors
.vault
.ask(GetState {})
.await
.map_err(|_| Error::internal("failed to query vault"))
}
.map_err(|_| Error::internal("failed to query vault"))?;
#[message]
pub async fn handle_declare_committee(&mut self, count: usize) -> Result<(), Error> {
self.actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: self.auth_creds.id,
declared_count: count,
})
.await
.map_err(Error::ceremony)
}
#[message]
pub async fn handle_contribute_bootstrap_passphrase(
&mut self,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
self.actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: self.auth_creds.id,
passphrase: SafeCell::new(passphrase),
})
.await
.map_err(Error::ceremony)
}
#[message]
pub async fn handle_contribute_unseal_passphrase(
&mut self,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
self.actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: self.auth_creds.id,
passphrase: SafeCell::new(passphrase),
})
.await
.map_err(Error::ceremony)
Ok(answer)
}
}
@@ -326,75 +289,3 @@ impl Message<events::Unsealed> for VaultGate {
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

@@ -1,12 +1,15 @@
use super::common::ChannelTransport;
use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT};
use arbiter_crypto::{
authn::{self, AuthChallenge, CLIENT_CONTEXT},
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_proto::{
ClientMetadata,
transport::{Receiver, Sender},
};
use arbiter_server::{
actors::{GlobalActors, vault::Bootstrap},
crypto::{KeyCell, integrity},
crypto::integrity,
db::{self, schema},
peers::client::{ClientConnection, ClientCredentials, auth, connect_client},
};
@@ -83,8 +86,8 @@ async fn insert_bootstrap_sentinel_operator(db: &db::DatabasePool) {
.0
.to_vec();
insert_into(schema::operator_identity::table)
.values((schema::operator_identity::public_key.eq(sentinel_key),))
insert_into(schema::operator_client::table)
.values((schema::operator_client::public_key.eq(sentinel_key),))
.execute(&mut conn)
.await
.unwrap();
@@ -97,8 +100,7 @@ async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors {
actors
.vault
.ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]),
custody: None,
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
@@ -338,10 +340,7 @@ pub async fn metadata_frozen_after_approval_ignores_reconnect_changes() {
.first::<(String, Option<String>, Option<String>)>(&mut conn)
.await
.unwrap();
assert_eq!(
metadata_count, 1,
"frozen: no new metadata row on reconnect"
);
assert_eq!(metadata_count, 1, "frozen: no new metadata row on reconnect");
assert_eq!(history_count, 0, "frozen: no history entry on reconnect");
assert_eq!(
current,

View File

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

View File

@@ -1,10 +1,13 @@
use super::common::ChannelTransport;
use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT};
use arbiter_crypto::{
authn::{self, AuthChallenge, OPERATOR_CONTEXT},
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_proto::transport::{Error as TransportError, Receiver, Sender};
use arbiter_server::{
actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap},
crypto::{KeyCell, integrity},
db::{self, models::OperatorId, schema},
crypto::integrity,
db::{self, schema},
peers::operator::{self, Credentials, OperatorConnection, auth, vault_gate},
};
@@ -151,6 +154,13 @@ impl Sender<auth::Inbound> for StartTestTransport {
pub async fn bootstrap_token_auth() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.vault
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
let (mut server_transport, mut test_transport) = ChannelTransport::new();
@@ -196,8 +206,8 @@ pub async fn bootstrap_token_auth() {
task.await.unwrap().unwrap();
let mut conn = db.get().await.unwrap();
let stored_pubkey: Vec<u8> = schema::operator_identity::table
.select(schema::operator_identity::public_key)
let stored_pubkey: Vec<u8> = schema::operator_client::table
.select(schema::operator_client::public_key)
.first::<Vec<u8>>(&mut conn)
.await
.unwrap();
@@ -249,7 +259,7 @@ pub async fn bootstrap_invalid_token_auth() {
));
let mut conn = db.get().await.unwrap();
let count: i64 = schema::operator_identity::table
let count: i64 = schema::operator_client::table
.count()
.get_result::<i64>(&mut conn)
.await
@@ -265,8 +275,7 @@ pub async fn challenge_auth() {
actors
.vault
.ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]),
custody: None,
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
@@ -276,10 +285,10 @@ pub async fn challenge_auth() {
{
let mut conn = db.get().await.unwrap();
let id: OperatorId = insert_into(schema::operator_identity::table)
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::operator_identity::id)
.get_result::<OperatorId>(&mut conn)
let id: i32 = insert_into(schema::operator_client::table)
.values((schema::operator_client::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::operator_client::id)
.get_result(&mut conn)
.await
.unwrap();
integrity::sign_entity(
@@ -352,8 +361,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
actors
.vault
.ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]),
custody: None,
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
@@ -363,8 +371,8 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
{
let mut conn = db.get().await.unwrap();
insert_into(schema::operator_identity::table)
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
insert_into(schema::operator_client::table)
.values((schema::operator_client::public_key.eq(pubkey_bytes.clone()),))
.execute(&mut conn)
.await
.unwrap();
@@ -426,8 +434,7 @@ pub async fn challenge_auth_rejects_invalid_signature() {
actors
.vault
.ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]),
custody: None,
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
@@ -437,10 +444,10 @@ pub async fn challenge_auth_rejects_invalid_signature() {
{
let mut conn = db.get().await.unwrap();
let id: OperatorId = insert_into(schema::operator_identity::table)
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::operator_identity::id)
.get_result::<OperatorId>(&mut conn)
let id: i32 = insert_into(schema::operator_client::table)
.values((schema::operator_client::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::operator_client::id)
.get_result(&mut conn)
.await
.unwrap();
integrity::sign_entity(
@@ -499,92 +506,3 @@ pub async fn challenge_auth_rejects_invalid_signature() {
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

@@ -1,11 +1,13 @@
use arbiter_crypto::authn;
use arbiter_crypto::{
authn,
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_server::{
actors::{
GlobalActors,
vault::{Bootstrap, Seal},
},
crypto::KeyCell,
db::{self, models::OperatorId},
db,
peers::operator::{
Credentials,
vault_gate::{
@@ -20,7 +22,7 @@ use tokio::sync::oneshot;
use x25519_dalek::{EphemeralSecret, PublicKey};
async fn setup_sealed_gate(
seal_key: [u8; 32],
seal_key: &[u8],
) -> (
db::DatabasePool,
kameo::actor::ActorRef<VaultGate>,
@@ -32,8 +34,7 @@ async fn setup_sealed_gate(
actors
.vault
.ask(Bootstrap {
seal_key: KeyCell::from(seal_key),
custody: None,
seal_key_raw: SafeCell::new(seal_key.to_vec()),
})
.await
.unwrap();
@@ -41,10 +42,7 @@ async fn setup_sealed_gate(
let (promotion_tx, promotion_rx) = oneshot::channel();
let pubkey = authn::SigningKey::generate().public_key();
let auth_creds = Credentials {
id: OperatorId::from_raw(1),
pubkey,
};
let auth_creds = Credentials { id: 1, pubkey };
let gate = VaultGate::spawn(VaultGate::new(auth_creds, actors, db.clone(), promotion_tx));
(db, gate, promotion_rx)
@@ -85,10 +83,10 @@ async fn client_dh_encrypt(
#[tokio::test]
#[test_log::test]
pub async fn unseal_success() {
let seal_key = [7u8; 32];
let seal_key = b"test-seal-key";
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;
assert!(matches!(response, Ok(())));
@@ -97,10 +95,10 @@ pub async fn unseal_success() {
#[tokio::test]
#[test_log::test]
pub async fn unseal_wrong_seal_key() {
let seal_key = [7u8; 32];
let seal_key = b"test-seal-key";
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").await;
let response = gate.ask(encrypted_key).await;
assert!(matches!(
@@ -114,7 +112,7 @@ pub async fn unseal_wrong_seal_key() {
#[tokio::test]
#[test_log::test]
pub async fn unseal_corrupted_ciphertext() {
let seal_key = [7u8; 32];
let seal_key = b"test-seal-key";
let (_db, gate, _promotion_rx) = setup_sealed_gate(seal_key).await;
let client_secret = EphemeralSecret::random();
@@ -145,11 +143,11 @@ pub async fn unseal_corrupted_ciphertext() {
#[tokio::test]
#[test_log::test]
pub async fn unseal_retry_after_invalid_key() {
let seal_key = [9u8; 32];
let seal_key = b"real-seal-key";
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").await;
let response = gate.ask(encrypted_key).await;
assert!(matches!(
@@ -161,7 +159,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;
assert!(matches!(response, Ok(())));

View File

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

View File

@@ -5,7 +5,6 @@ use arbiter_server::{
GlobalActors,
vault::{CreateNew, Error, Vault},
},
crypto::KeyCell,
db::{self, models, schema},
};
@@ -170,7 +169,7 @@ async fn decrypt_roundtrip_after_high_concurrency() {
.await
.unwrap();
decryptor
.try_unseal(KeyCell::from([0u8; 32]))
.try_unseal(SafeCell::new(b"test-seal-key".to_vec()))
.await
.unwrap();

View File

@@ -1,317 +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, GetState, Seal, VaultState},
vault_coordinator::{ContributeBootstrap, ContributeUnseal, StartBootstrap},
},
crypto::{KeyCell, shamir},
db::{self, 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"
);
}
#[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

@@ -5,10 +5,7 @@ use arbiter_server::{
GlobalActors,
vault::{Error, Vault},
},
crypto::{
KeyCell,
encryption::v1::{Nonce, ROOT_KEY_TAG},
},
crypto::encryption::v1::{Nonce, ROOT_KEY_TAG},
db::{self, models, schema},
};
@@ -25,8 +22,8 @@ async fn bootstrap() {
.await
.unwrap();
let seal_key = KeyCell::from([0u8; 32]);
actor.bootstrap(seal_key, None).await.unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.bootstrap(seal_key).await.unwrap();
let mut conn = db.get().await.unwrap();
let row: models::RootKeyHistory = schema::root_key_history::table
@@ -48,8 +45,8 @@ async fn bootstrap_rejects_double() {
let db = db::create_test_pool().await;
let mut actor = common::bootstrapped_vault(&db).await;
let seal_key2 = KeyCell::from([0u8; 32]);
let err = actor.bootstrap(seal_key2, None).await.unwrap_err();
let seal_key2 = SafeCell::new(b"test-seal-key".to_vec());
let err = actor.bootstrap(seal_key2).await.unwrap_err();
assert!(matches!(err, Error::AlreadyBootstrapped));
}
@@ -110,7 +107,7 @@ async fn unseal_correct_password() {
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
let seal_key = KeyCell::from([0u8; 32]);
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.try_unseal(seal_key).await.unwrap();
let mut decrypted = actor.decrypt(aead_id, TEST_AAD.to_vec()).await.unwrap();
@@ -134,11 +131,11 @@ async fn unseal_wrong_then_correct_password() {
.await
.unwrap();
let bad_key = KeyCell::from([1u8; 32]);
let bad_key = SafeCell::new(b"wrong-password".to_vec());
let err = actor.try_unseal(bad_key).await.unwrap_err();
assert!(matches!(err, Error::InvalidKey));
let good_key = KeyCell::from([0u8; 32]);
let good_key = SafeCell::new(b"test-seal-key".to_vec());
actor.try_unseal(good_key).await.unwrap();
let mut decrypted = actor.decrypt(aead_id, TEST_AAD.to_vec()).await.unwrap();