Compare commits

..

142 Commits

Author SHA1 Message Date
CleverWild
a80cd39695 fix(mise): migrate from unix only asdf backend to a universal one 2026-09-08 16:11:42 +02:00
CleverWild
23183efd0c fix(evm): revoke dependent grants when wallet access is revoked 2026-09-08 16:11:42 +02:00
CleverWild
8402691514 fix(vault): derive the recovery operator id from the authenticated peer 2026-09-08 16:11:42 +02:00
CleverWild
8d25d6640b feat(operator): authenticate recovery operators as a distinct peer type 2026-09-08 16:11:42 +02:00
CleverWild
2b02d4a9b1 fix(bootstrap): keep the token valid until the vault is bootstrapped 2026-09-08 16:11:42 +02:00
CleverWild
5ade05d475 fix(vault): require an elapsed wake-up before a recovery share can unseal 2026-09-08 16:11:42 +02:00
CleverWild
e80dc53b0e fix(vault): read the unseal threshold from the recorded split parameters 2026-09-08 16:11:42 +02:00
CleverWild
e10cc762d6 fix(proposal): count recovery operators only in electorates they can vote in 2026-09-08 16:11:42 +02:00
CleverWild
6e21505a7f fix(operator): soft-revoke wallet access instead of deleting the row 2026-09-08 16:11:42 +02:00
CleverWild
a37af6bc1c fix(db): enable foreign key enforcement on pooled connections 2026-09-08 16:11:42 +02:00
CleverWild
5d811f2ee9 fix(evm): reject unrepresentable grant timestamps instead of dropping the bound 2026-09-08 16:11:42 +02:00
CleverWild
5937ae8112 fix(operator): revoke wallet access by access id instead of wallet id 2026-09-08 16:11:42 +02:00
CleverWild
a9bc533129 test(vault): pin attestation validity across a re-key 2026-09-08 16:11:42 +02:00
CleverWild
3907241644 fix(vault): keep the root key row identity across a seal-key re-key 2026-09-08 16:11:42 +02:00
CleverWild
d916997ef3 fix(crypto): return None from shamir_threshold for an empty committee 2026-09-08 16:11:42 +02:00
CleverWild
f32da65467 refactor(proposal): put DB access behind a mockable ProposalStore trait 2026-09-08 16:11:42 +02:00
CleverWild
fa2df36fbe refactor(proposal): publish approved proposals on the bus instead of executing them 2026-08-27 17:45:13 +02:00
CleverWild
2b2c225b35 chore: normalize line endings to LF 2026-08-27 17:30:20 +02:00
CleverWild
9e9672a1b1 refactor(crypto): extract governance vote message and verification helpers 2026-08-27 16:44:22 +02:00
CleverWild
0d29d0d532 refactor(db): declare unixepoch instead of formatting a SQL fragment 2026-08-27 14:08:48 +02:00
CleverWild
e9496da78c perf(proposal): replace the per-proposal tally loop with one grouped query 2026-08-27 14:00:14 +02:00
CleverWild
c0546fa17f refactor(db): use diesel exists() instead of counting rows 2026-08-27 13:50:02 +02:00
CleverWild
71081b6ee7 refactor(proposal): use id newtypes across the governance path 2026-08-27 13:42:14 +02:00
CleverWild
8421a09d9f refactor(db!: store the one-off transaction signature by component 2026-08-27 12:46:40 +02:00
CleverWild
15b826310b refactor(db): replace the proposal payload blob with typed child tables 2026-08-27 00:45:35 +02:00
CleverWild
5698d1cfb3 feat(proposal): reject proposals with an excessive TTL 2026-08-26 16:47:12 +02:00
CleverWild
6884a59325 refactor(proposal): type ProposalSummary::kind as ProposalKindTag 2026-08-26 16:32:43 +02:00
CleverWild
b364d95489 refactor(proposal): drop the Quorum prefix from VoteOutcome variants 2026-08-26 15:30:48 +02:00
CleverWild
d64478b301 refactor(proposal): replace UpdateShamirParameters with parameterless TriggerRekey 2026-08-26 15:26:17 +02:00
CleverWild
aa884339f7 refactor(proposal): remove the ApproveServerUpdate proposal kind 2026-08-26 14:53:38 +02:00
CleverWild
180f93c1a7 refactor(proposal): derive ProposalKindTag from ProposalKind with strum 2026-08-26 14:36:41 +02:00
CleverWild
a501283b0c refactor(crypto): replace byte-slice signing contexts with SigningContext enum 2026-08-26 14:08:32 +02:00
CleverWild
f881102f0a refactor(proposal): drop the expired status, enforce expiry on every vote 2026-08-26 13:01:44 +02:00
CleverWild
f13c1cf9d1 fix(db): require share_salt to be supplied by the daemon 2026-08-26 13:01:44 +02:00
CleverWild
f03997ea56 chore(lints): fix regression caused by rust 1.98.0 2026-08-26 13:01:44 +02:00
CleverWild
957c5096df chore(deps): bump kameo version 2026-08-26 13:01:44 +02:00
CleverWild
36249129d1 feat(vault)!: implement full Shamir re-key flow and governance execution (§3.3–§3.5)
- Add `rekey.proto` with `ContributePassphrase` / `ContributeRecoveryPassphrase` / `RekeyResult`
- Wire `rekey` as a 4th vault stream payload in `vault.proto` and gRPC dispatch
- Add `RekeyRootKey` message to `Vault` actor: generates new random seal key, re-encrypts root key, writes new `root_key_history` row
- Add `StartRekey`, `ContributeRekey`, `ContributeRecoveryRekey` messages to `VaultCoordinator`; `finalize_rekey` uses threshold-1 fast path identical to bootstrap
- `execute_replace_operator` now UPDATEs `operator_identity.public_key` in-place (avoids FK constraint violation), deletes stale `operator` share row, then triggers `StartRekey`
- `execute_update_shamir_parameters` triggers `StartRekey` instead of warning stub
- `ProposalKind::ReplaceOperator` carries `old_operator_id`; encode/decode updated accordingly
- `GlobalActors::spawn` extracts `vault_coordinator` before `Ok(Self { … })` so it can be cloned into `ProposalManager::new`
- Add `handle_rekey` in session handlers forwarding passphrase contributions to `VaultCoordinator`
- Fix test: rename `replace_operator_inserts_identity_row` → `replace_operator_updates_pubkey_and_starts_rekey`, assert count stays 1 and pubkey is updated
2026-08-26 13:01:44 +02:00
CleverWild
3817a080c9 refactor(proposal): replace string kind dispatch with ProposalKindTag enum (strum) 2026-08-26 13:01:44 +02:00
CleverWild
e1d060dd06 feat(vault): add recovery passphrase handling for bootstrap and unseal processes 2026-08-26 13:01:44 +02:00
CleverWild
e121708d28 fix(crypto): handle 1-of-N Shamir split when ordinary_count=1 2026-08-26 13:01:44 +02:00
CleverWild
6e3fa736e0 feat(server): recovery operators with sleeping/wakeup mechanism (§3.5/§3.6) 2026-08-26 13:01:44 +02:00
CleverWild
19a62e7195 feat(server): key-rotation proposals require full quorum (§3.3) 2026-08-26 13:01:44 +02:00
CleverWild
57200cbc50 feat(server): two-operator vault requires at least one recovery share 2026-08-26 13:01:44 +02:00
CleverWild
291ef2e831 refactor(server): typed pubkey len via u32::try_from in ReplaceOperator 2026-08-26 13:01:44 +02:00
CleverWild
d12109c2c9 feat(server): ProposalKind::ApproveOneOffTransaction 2026-08-26 13:01:44 +02:00
CleverWild
277fb3c92d feat(server): ProposalKind::ApprovePersistentGrant 2026-08-26 13:01:44 +02:00
CleverWild
9e42097683 feat(server): ProposalKind::UpdateShamirParameters 2026-08-26 13:01:44 +02:00
CleverWild
25b86b14e9 feat(server): ProposalKind::ReplaceOperator 2026-08-26 13:01:44 +02:00
CleverWild
ba8748a17b feat(server): ProposalKind ::GrantWalletAccess and ::ApproveServerUpdate 2026-08-26 13:01:44 +02:00
CleverWild
3e61d807b4 test(server): governance integration tests 2026-08-26 13:01:44 +02:00
CleverWild
e6459aade8 feat(server::grpc): wire governance RPCs through operator session 2026-08-26 13:01:44 +02:00
CleverWild
f16d0a26e2 feat(server): introduce ProposalManager actor with quorum voting logic 2026-08-26 13:01:44 +02:00
CleverWild
074e6501ec feat(crypto): expose governance signing context and make shamir_threshold pub const 2026-08-26 13:01:44 +02:00
CleverWild
b2632661f8 feat(db): add proposal and proposal_vote tables 2026-08-26 13:01:44 +02:00
CleverWild
966b4c8828 feat(proto): add governance proposal/vote RPC definitions 2026-08-26 13:01:44 +02:00
CleverWild
b6c91c56eb housekepping: add fixme for start_bootstrap's operator_id 2026-08-26 13:01:44 +02:00
CleverWild
240fd3eb63 refactor(server::crypto): use fixed-size [u8; 32] and KeyCell throughout seal key API 2026-08-26 13:01:44 +02:00
CleverWild
80ba30d430 fix(server::tests): tighten unseal test seal_key params to &[u8; 32] 2026-08-26 13:01:44 +02:00
CleverWild
59cb65f3e1 feat(server::grpc): wire Shamir committee bootstrap and unseal proto messages
Adds DeclareCommittee and ContributePassphrase variants to bootstrap.proto,
ContributePassphrase to unseal.proto, and AwaitingContributions result codes
to both. Implements corresponding inbound converters and outbound reply
mappings. VaultGate handlers delegate to VaultCoordinator.
2026-08-26 13:01:44 +02:00
CleverWild
83075e9df7 feat(server): introduce VaultCoordinator for multi-operator Shamir bootstrap/unseal
VaultCoordinator collects operator passphrases, splits the seal key into
Shamir shares on bootstrap (encrypting each share with the operator's
passphrase via Argon2 + XChaCha20-Poly1305), and reconstructs the seal
key from threshold shares on unseal. Adds vsss-rs 5.4.0 and rand_core 0.6
dependencies.
2026-08-26 13:01:44 +02:00
CleverWild
fc7f2b1a03 refactor(server::actors::vault): clean up Bootstrap/TryUnseal, remove Bootstrapping state
Bootstrap and TryUnseal now accept a SafeCell<Vec<u8>> seal key directly.
The Bootstrapping intermediate state is removed — multi-operator coordination
is the responsibility of VaultCoordinator, which calls Bootstrap atomically
once all shares are collected.
2026-08-26 13:01:44 +02:00
CleverWild
0695ec96a8 feat(server::crypto): add Shamir secret sharing utilities
Wraps vsss_rs Gf256::split_array / combine_array into thin split_key /
combine_shares helpers. Also widens derive_key salt parameter from &[u8;16]
to &[u8] to accommodate the 32-byte share salts.
2026-08-26 13:01:44 +02:00
CleverWild
8159902027 feat(server::db): add share_salt column to operator table
Each operator row now stores a 32-byte random salt used to derive the
per-operator share encryption key from their passphrase (Argon2 KDF).
2026-08-26 13:01:44 +02:00
CleverWild
928799fa07 feat(server::actors::evm): implement operator_delete_grant
Sets revoked_at on the evm_basic_grant row; returns NotFound if the grant
does not exist. Wires the handler in OperatorSession replacing the todo!().
2026-08-26 13:01:44 +02:00
CleverWild
3d3a4be806 fix(server::peers::operator::auth): make ChallengeContext pub for smlang state machine
smlang generates a public state enum whose variants contain ChallengeContext,
requiring the type itself to be fully public. Also tightens the wildcard arm
in client auth to an exhaustive match.
2026-08-26 13:01:44 +02:00
Skipper
28b7276e11 WIP: some things 2026-08-26 13:01:44 +02:00
Skipper
f97b8f9424 feat(grpc): governance contract 2026-08-26 13:01:44 +02:00
Skipper
9ca2c4ed64 refactor(server::db): introduced newtype wrappers for entity id's in database 2026-08-26 13:01:44 +02:00
Skipper
ab79959dce housekeeping(server): deps upgrade + diesel migration to AsyncFnOnce 2026-08-26 13:01:44 +02:00
Skipper
853a038363 fix(server): MacOS build version 2026-08-26 13:01:44 +02:00
Skipper
a31ef99338 housekeeping(server): removed unused deps 2026-08-26 13:01:44 +02:00
Skipper
ec5a8143fb refactor: rename to to better reflect meaning 2026-08-26 13:01:44 +02:00
Skipper
e4db36f145 docs: move to folder and update to new challenge payload 2026-08-26 13:00:40 +02:00
Skipper
602be3aa11 merge: feat-lints into main 2026-08-26 13:00:40 +02:00
CleverWild
1d15e04a6b fix(lints): remove unstable ones 2026-08-26 13:00:40 +02:00
d8a03fcdbe Merge pull request 'refactor-integrity-check' (#90) from refactor-integrity-check into main
Reviewed-on: #90
2026-08-26 13:00:40 +02:00
CleverWild
d4084ded35 feat: rustc and clippy linting 2026-08-26 13:00:40 +02:00
6221debd84 Merge pull request 'housekeeping(server): dependencies upgrade' (#89) from push-zmvtzuwrnyyv into main
Reviewed-on: #89
2026-08-26 13:00:40 +02:00
Skipper
8392f27ce4 housekeeping(server): clippy warns fix 2026-08-26 13:00:40 +02:00
96b4eb1d98 Merge pull request 'refactor(hashing): introduce Hashable derive macro and migrate server types' (#82) from hashing-proc-macro into main
Reviewed-on: #82
Reviewed-by: Stas <business@jexter.tech>
2026-08-26 13:00:40 +02:00
Skipper
d7ca0b4fc8 housekeeping(server): dependencies upgrade 2026-08-26 13:00:40 +02:00
Skipper
e6724cb8d3 merge: refactor-integrity-check into main 2026-08-26 13:00:40 +02:00
Skipper
6b572d0c86 housekeeping(server): clean too-broad visibility markers and organize imports 2026-08-26 13:00:40 +02:00
Skipper
bf63279f73 merge: refactor-integrity-check into main 2026-08-26 13:00:40 +02:00
Skipper
539265e3b1 docs: updated to new auth challenge format and removed stale TOCTOU race condition note 2026-08-26 13:00:40 +02:00
Skipper
cddf9e02ba fix(useragent): now using new challenge format 2026-08-26 13:00:40 +02:00
Skipper
270e843598 fix(server::tests): api surface of auth challenge changed 2026-08-26 13:00:40 +02:00
Skipper
0a48839321 refactor(server::{useragent::auth, client::auth}): use random based + timestamp nonce instead of monotonic counter in database 2026-08-26 13:00:40 +02:00
Skipper
f6151293d4 refactor(server): now keeps track of useragents, instead of 2026-08-26 13:00:40 +02:00
Skipper
3bbf713e67 refactor(server::grpc::vault_gate): standard approach using / traits 2026-08-26 13:00:40 +02:00
Skipper
810656702b fix(server): sending fixed vault state when on stage 2026-08-26 13:00:40 +02:00
hdbg
4823a291f8 WIP: kameo::messages wiring for transport generalization 2026-08-26 13:00:40 +02:00
hdbg
1d9b572565 feat(user-agent): add VaultGate for sealed vault authentication 2026-08-26 13:00:40 +02:00
hdbg
af7e90dcad fix(server::integrity): vault now differentias between expected/unexpected states for commands more granularly 2026-08-26 13:00:40 +02:00
hdbg
97e6b1950d fix(useragent): unsafe, but working implementation of ml-dsa 2026-08-26 13:00:40 +02:00
hdbg
d2f41e693c fix(server::user_agent): useragents now self-sign themselves on bootstrap 2026-08-26 13:00:40 +02:00
hdbg
9650407093 refactor(server): reorganized client/user_agent actors into separate module peers and added event MessageBus 2026-08-26 13:00:40 +02:00
100c257e95 Merge pull request 'Post-quantum crypto and better useragent security' (#80) from push-xrxykvkuxpsv into main
Reviewed-on: #80
2026-08-26 13:00:39 +02:00
CleverWild
247cb90fbf refactor(hashing): introduce Hashable derive macro and migrate server types 2026-08-26 13:00:39 +02:00
hdbg
13fb31f841 fix(server::bootsrapper): token compare is now constant-time 2026-08-26 13:00:39 +02:00
hdbg
a49baf7386 housekeeping(server): fixed clippy warns 2026-08-26 13:00:39 +02:00
hdbg
2df25b8641 tests(server::client::auth): integrity envelope insertion for valid paths 2026-08-26 13:00:39 +02:00
hdbg
6b814f4869 refactor(server): moved shared module crypto into arbiter-crypto 2026-08-26 13:00:39 +02:00
hdbg
11ad5df426 feat(server): add integrity verification for client keys 2026-08-26 13:00:39 +02:00
hdbg
97b7b483ff docs: ml-dsa scheme everywhere 2026-08-26 13:00:39 +02:00
307822187c Merge pull request 'fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing' (#77) from push-opwuyuwxknyo into main
Reviewed-on: #77
2026-08-26 13:00:39 +02:00
hdbg
4c2e00b56d refactor(server): migrated auth to ml-dsa 2026-08-26 13:00:39 +02:00
hdbg
dcf42b0e4e fix(server): added chain_id check and covered check_shared_constraints with unit tests 2026-08-26 13:00:39 +02:00
hdbg
bd0a8f3907 tests(server): property-based testing for ordering independency for hash 2026-08-26 13:00:39 +02:00
hdbg
38284d84b6 tests(server): initial cargo-mutants 2026-08-26 13:00:39 +02:00
hdbg
d482ed37aa fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing 2026-08-26 13:00:39 +02:00
hdbg
498e2e3145 fix(server): simplify hash function for debug profile 2026-08-26 13:00:39 +02:00
e1b32165e9 Merge pull request 'feat(server): integrity envelope engine for EVM grants with HMAC verification' (#51) from integrity-envelope into main
Reviewed-on: #51
2026-08-26 13:00:39 +02:00
hdbg
6f33d44697 docs: add recovery operators and multi-operator details 2026-08-26 13:00:39 +02:00
hdbg
9173be2920 tests(server): fixed for new integrity checks 2026-08-26 13:00:39 +02:00
hdbg
cdf2394963 docs: add multi-operator governance section 2026-08-26 13:00:39 +02:00
hdbg
d55e9880ec refactor(server): rework envelopes and integrity check 2026-08-26 13:00:39 +02:00
hdbg
37fa954933 merge: @main into client-integrity-verification 2026-08-26 13:00:39 +02:00
hdbg
ba7bfe9b6f fix(server): remove stale mentions of miette 2026-08-26 13:00:39 +02:00
hdbg
5c3421de1e housekeeping(server): fixed clippy warns 2026-08-26 13:00:39 +02:00
CleverWild
6a0c512773 chore: inline integrity proto types 2026-08-26 13:00:39 +02:00
hdbg
be23f87e67 refactor(user-agent): remove backfill pubkey integrity tags 2026-08-26 13:00:39 +02:00
CleverWild
4bd8a2df33 feat(server): integrity envelope engine for EVM grants with HMAC verification 2026-08-26 13:00:39 +02:00
hdbg
29f2174dcf fix(server): previously, user agent auth accepted invalid signatures 2026-08-26 13:00:39 +02:00
hdbg
149035ecf5 refactor(server): separate crypto by purpose and moved outside of actor into separate module 2026-08-26 13:00:39 +02:00
CleverWild
17ea8d8dc9 feat(auth): implement attestation status verification for public keys 2026-08-26 13:00:39 +02:00
CleverWild
e9e2888e95 fix(keyholder): comment drift 2026-08-26 13:00:39 +02:00
CleverWild
0b30416a7b refactor(keyholder): generalize derive_useragent_integrity_key and compute_useragent_pubkey_integrity_tag corespondenly to derive_integrity_key and compute_integrity_tag 2026-08-26 13:00:39 +02:00
CleverWild
e2e2c4fc07 feat(auth): add seal-key-derived pubkey integrity tags with auth enforcement and unseal backfill 2026-08-26 13:00:39 +02:00
hdbg
2b7d3257da style(dashboard): format code and add title margin 2026-08-26 13:00:39 +02:00
hdbg
ed6e102c96 feat(grants-create): add configurable grant authorization fields 2026-08-26 13:00:39 +02:00
hdbg
a7e031faca refactor(useragent::evm::grants): split into more files & flutter_form_builder usage 2026-08-26 13:00:39 +02:00
hdbg
8736a01e1f fix(useragent::dashboard): screen pushed twice due to improper listen hook 2026-08-26 13:00:39 +02:00
hdbg
11c45ca4e1 refactor(grants): wrap grant list in SingleChildScrollView 2026-08-26 13:00:39 +02:00
hdbg
5323eea129 style(dashboard): remove const from _CalloutBell and add title to nav rail 2026-08-26 13:00:39 +02:00
hdbg
238a9206e1 refactor(useragent): moved shared CreamPanel and StatePanel into generic widgets 2026-08-26 13:00:39 +02:00
hdbg
0048d627eb feat(evm): add EVM grants screen with create UI and list 2026-08-26 13:00:39 +02:00
hdbg
8aaeec078b refactor(proto): restructure wallet access messages for improved data organization 2026-08-26 13:00:39 +02:00
hdbg
3ca2554468 refactor(server::evm): removed repetetive errors and error variants 2026-08-26 13:00:39 +02:00
hdbg
d982954a11 refactor(useragent::evm::table): broke down into more widgets 2026-08-26 13:00:39 +02:00
hdbg
3b75d146a9 refactor(useragent::evm): moved out header into general widget 2026-08-26 13:00:39 +02:00
hdbg
27230f975f feat(useragent): vibe-coded access list 2026-08-26 13:00:39 +02:00
154 changed files with 16561 additions and 6804 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

2
.gitignore vendored
View File

@@ -3,4 +3,4 @@ scripts/__pycache__/
.DS_Store
.cargo/config.toml
.vscode/
docs/
docs/superpowers

View File

@@ -6,7 +6,7 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t
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 desktop app (macOS/Windows) with a Rust backend via Rinf
- **`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.
@@ -28,7 +28,7 @@ Key versions: Rust 1.93.0 (with clippy), Flutter 3.38.9-stable, protoc 29.6, die
|---|---|
| `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-useragent` | Rust client library for the user agent side of the gRPC protocol |
| `arbiter-operator` | Rust client library for the operator side of the gRPC protocol |
| `arbiter-client` | Rust client library for SDK clients |
### Common Commands
@@ -67,10 +67,10 @@ The server is actor-based using the **kameo** crate. All long-lived state lives
- **`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 user agents and SDK clients.
- **`FlowCoordinator`** — Coordinates cross-connection flow between operators and SDK clients.
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
Per-connection actors live under `actors/user_agent/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
Per-connection actors live under `actors/operator/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
**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()`.
@@ -100,20 +100,41 @@ diesel migration generate <name> --migration-dir crates/arbiter-server/migration
diesel migration run --migration-dir crates/arbiter-server/migrations
```
## User Agent (Flutter + Rinf at `useragent/`)
### Code Conventions
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `useragent/native/hub/` as a separate crate that uses `arbiter-useragent` for the gRPC client.
**`#[must_use]` Attribute:**
Apply the `#[must_use]` attribute to return types of functions where the return value is critical and should not be accidentally ignored. This is commonly used for:
Communication between Dart and Rust uses typed **signals** defined in `useragent/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
- Methods that return `bool` indicating success/failure or validation state
- Any function where ignoring the return value indicates a logic error
Do not apply `#[must_use]` redundantly to items (types or functions) that are already annotated with `#[must_use]`.
Example:
```rust
#[must_use]
pub fn verify(&self, nonce: i32, context: &[u8], signature: &Signature) -> bool {
// verification logic
}
```
This forces callers to either use the return value or explicitly ignore it with `let _ = ...;`, preventing silent failures.
## Operator (Flutter + Rinf at `operator/`)
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 useragent && rinf gen
cd operator && rinf gen
```
### Common Commands
```sh
cd useragent
cd operator
# Run the app (macOS or Windows)
flutter run
@@ -125,4 +146,4 @@ rinf gen
flutter analyze
```
The Rinf Rust entry point is `useragent/native/hub/src/lib.rs`. It spawns actors defined in `useragent/native/hub/src/actors/` which handle Dart↔server communication via signals.
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.

129
CLAUDE.md
View File

@@ -1,128 +1 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
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 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.
## Toolchain Setup
Tools are managed via [mise](https://mise.jdx.dev/). Install all required tools:
```sh
mise install
```
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/`)
### Crates
| Crate | Purpose |
|---|---|
| `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-useragent` | Rust client library for the user agent side of the gRPC protocol |
| `arbiter-client` | Rust client library for SDK clients |
### Common Commands
```sh
cd server
# Build
cargo build
# Run the server daemon
cargo run -p arbiter-server
# Run all tests (preferred over cargo test)
cargo nextest run
# Run a single test
cargo nextest run <test_name>
# Lint
cargo clippy
# Security audit
cargo audit
# Check unused dependencies
cargo shear
# Run snapshot tests and update snapshots
cargo insta review
```
### Architecture
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
- **`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 user agents and SDK clients.
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
Per-connection actors live under `actors/user_agent/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
**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: 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
- TLS: self-signed certificate (aws-lc-rs backend), fingerprint distributed via `ArbiterUrl`
**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
When `.proto` files in `protobufs/` change, rebuild to regenerate:
```sh
cd server && cargo build -p arbiter-proto
```
### Database Migrations
```sh
# Create a new migration
diesel migration generate <name> --migration-dir crates/arbiter-server/migrations
# Run migrations manually (server also runs them on startup)
diesel migration run --migration-dir crates/arbiter-server/migrations
```
## User Agent (Flutter + Rinf at `useragent/`)
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `useragent/native/hub/` as a separate crate that uses `arbiter-useragent` for the gRPC client.
Communication between Dart and Rust uses typed **signals** defined in `useragent/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
```sh
cd useragent && rinf gen
```
### Common Commands
```sh
cd useragent
# Run the app (macOS or Windows)
flutter run
# Regenerate Rust↔Dart signal bindings
rinf gen
# Analyze Dart code
flutter analyze
```
The Rinf Rust entry point is `useragent/native/hub/src/lib.rs`. It spawns actors defined in `useragent/native/hub/src/actors/` which handle Dart↔server communication via signals.
Refer to @AGENTS.md for instructions.

View File

@@ -4,7 +4,7 @@
## Security warning
Arbiter can't meaningfully protect against host compromise. Potential attack flow:
- Attacker steals TLS keys from database
- Pretends to be server; just accepts user agent challenge solutions
- Pretends to be server; just accepts operator challenge solutions
- Pretend to be in sealed state and performing DH with client
- Steals user password and derives seal key

View File

@@ -1,31 +0,0 @@
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

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

View File

@@ -1,178 +0,0 @@
{
"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

@@ -1,230 +0,0 @@
{
"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
}

View File

@@ -1 +0,0 @@
3.38.9

View File

@@ -9,7 +9,7 @@ Arbiter is a permissioned signing service for cryptocurrency wallets. It runs as
Arbiter distinguishes two kinds of peers:
- **User Agent** — A client application used by the owner to manage the vault (create wallets, approve SDK clients, configure policies).
- **Operator** — A client application used by the owner to manage the vault (create wallets, approve SDK clients, configure policies).
- **SDK Client** — A consumer of signing capabilities, typically an automation tool. In the future, this could include a browser-based wallet.
- **Recovery Operator** — A dormant recovery participant with narrowly scoped authority used only for custody recovery and operator replacement.
@@ -22,30 +22,32 @@ Arbiter distinguishes two kinds of peers:
All peers authenticate via public-key cryptography using a challenge-response protocol:
1. The peer sends its public key and requests a challenge.
2. The server looks up the key in its database. If found, it increments the nonce and returns a challenge (replay-attack protection).
3. The peer signs the challenge with its private key and sends the signature back.
2. The server looks up the key in its database. If found, it generates a fresh challenge from random bytes plus the current timestamp.
3. The peer signs the canonical challenge payload with its private key and sends the signature back.
4. The server verifies the signature:
- **Pass:** The connection is considered authenticated.
- **Fail:** The server closes the connection.
### 2.2 User Agent Bootstrap
Authentication challenges are per-connection, ephemeral values. They are not persisted in the peer tables, and peer records store no challenge state.
On first run — when no User Agents are registered — the server generates a one-time bootstrap token. It is made available in two ways:
### 2.2 Operator Bootstrap
- **Local setup:** Written to `~/.arbiter/bootstrap_token` for automatic discovery by a co-located User Agent.
On first run — when no Operators are registered — the server generates a one-time bootstrap token. It is made available in two ways:
- **Local setup:** Written to `~/.arbiter/bootstrap_token` for automatic discovery by a co-located Operator.
- **Remote setup:** Printed to the server's console output.
The first User Agent must present this token alongside the standard challenge-response to complete registration.
The first Operator must present this token alongside the standard challenge-response to complete registration.
### 2.3 SDK Client Registration
There is no bootstrap mechanism for SDK clients. They must be explicitly approved by an already-registered User Agent.
There is no bootstrap mechanism for SDK clients. They must be explicitly approved by an already-registered Operator.
---
## 3. Multi-Operator Governance
When more than one User Agent is registered, the vault is treated as having multiple operators. In that mode, sensitive actions are governed by voting rather than by a single operator decision.
When more than one Operator is registered, the vault is treated as having multiple operators. In that mode, sensitive actions are governed by voting rather than by a single operator decision.
### 3.1 Voting Rules
@@ -163,13 +165,13 @@ In both cases, committee formation is a coordinated process. Arbiter does not al
When an unbootstrapped vault is initialized as a multi-operator vault, the setup proceeds as follows:
1. An operator connects to the unbootstrapped vault using a User Agent and the bootstrap token.
1. An operator connects to the unbootstrapped vault using an Operator and the bootstrap token.
2. During bootstrap setup, that operator declares:
- the total number of ordinary operators
- the total number of Recovery Operators
3. The vault enters **multi-bootstrap mode**.
4. While in multi-bootstrap mode:
- every ordinary operator must connect with a User Agent using the bootstrap token
- every ordinary operator must connect with an Operator using the bootstrap token
- every Recovery Operator must also connect using the bootstrap token
- each participant is registered individually
- each participant's share is created and protected with that participant's credentials
@@ -191,8 +193,8 @@ The server proves its identity using TLS with a self-signed certificate. The TLS
Peers verify the server by its **public key fingerprint**:
- **User Agent (local):** Receives the fingerprint automatically through the bootstrap token.
- **User Agent (remote) / SDK Client:** Must receive the fingerprint out-of-band.
- **Operator (local):** Receives the fingerprint automatically through the bootstrap token.
- **Operator (remote) / SDK Client:** Must receive the fingerprint out-of-band.
> A streamlined setup mechanism using a single connection string is planned but not yet implemented.
@@ -229,11 +231,11 @@ On boot, the root key is encrypted and the server cannot perform any signing ope
### 6.2 Unseal Flow
To transition to the **Unsealed** state, a User Agent must provide the password:
To transition to the **Unsealed** state, an Operator must provide the password:
1. The User Agent initiates an unseal request.
1. The Operator initiates an unseal request.
2. The server generates a one-time key pair and returns the public key.
3. The User Agent encrypts the user's password with this one-time public key and sends the ciphertext to the server.
3. The Operator encrypts the user's password with this one-time public key and sends the ciphertext to the server.
4. The server decrypts and verifies the password:
- **Success:** The root key is decrypted and placed into a hardened memory cell. The server transitions to `Unsealed`. Any entries pending encryption scheme migration are re-encrypted.
- **Failure:** The server returns an error indicating the password is incorrect.
@@ -255,7 +257,7 @@ See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory pr
### 7.1 Fundamental Rules
- SDK clients have **no access by default**.
- Access is granted **explicitly** by a User Agent.
- Access is granted **explicitly** by an Operator.
- Grants are scoped to **specific wallets** and governed by **policies**.
Each blockchain requires its own policy system due to differences in static transaction analysis. Currently, only EVM is supported; Solana support is planned.
@@ -275,19 +277,19 @@ sequenceDiagram
autonumber
actor SDK as SDK Client
participant Server
participant UA as User Agent
participant operator as Operator
SDK->>Server: SignTransactionRequest
Server->>Server: Resolve wallet and wallet visibility
alt Visibility approval required
Server->>UA: Ask for wallet visibility approval
UA-->>Server: Vote result
Server->>operator: Ask for wallet visibility approval
operator-->>Server: Vote result
end
Server->>Server: Evaluate transaction
Server->>Server: Load grant and limits context
alt Grant approval required
Server->>UA: Ask for execution / grant approval
UA-->>Server: Vote result
Server->>operator: Ask for execution / grant approval
operator-->>Server: Vote result
opt Create persistent grant
Server->>Server: Create and store grant
end

View File

@@ -8,10 +8,10 @@ This document covers concrete technology choices and dependencies. For the archi
### Authentication Result Semantics
Authentication no longer uses an implicit success-only response shape. Both `client` and `user-agent` return explicit auth status enums over the wire.
Authentication no longer uses an implicit success-only response shape. Both `client` and `operator` return explicit auth status enums over the wire.
- **Client:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `APPROVAL_DENIED`, `NO_USER_AGENTS_ONLINE`, or `INTERNAL`
- **User-agent:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `BOOTSTRAP_REQUIRED`, `TOKEN_INVALID`, or `INTERNAL`
- **Client:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `APPROVAL_DENIED`, `NO_OPERATORS_ONLINE`, or `INTERNAL`
- **Operator:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `BOOTSTRAP_REQUIRED`, `TOKEN_INVALID`, or `INTERNAL`
This makes transport-level failures and actor/domain-level auth failures distinct:
@@ -22,7 +22,7 @@ Clients are expected to handle these status codes directly and present the concr
### New Client Approval
When a client whose public key is not yet in the database connects, all connected user agents are asked to approve the connection. The first agent to respond determines the outcome; remaining requests are cancelled via a watch channel.
When a client whose public key is not yet in the database connects, all connected operators are asked to approve the connection. The first operator to respond determines the outcome; remaining requests are cancelled via a watch channel.
```mermaid
flowchart TD
@@ -31,10 +31,10 @@ flowchart TD
C -- yes --> G[Generate AuthChallenge]
C -- no --> E[Ask all UserAgents:\nClientConnectionRequest]
C -- no --> E[Ask all Operators:\nClientConnectionRequest]
E --> F{First response}
F -- denied --> Z([Reject connection])
F -- approved --> F2[Cancel remaining\nUserAgent requests]
F -- approved --> F2[Cancel remaining\nOperator requests]
F2 --> F3[INSERT client]
F3 --> G
@@ -45,7 +45,13 @@ flowchart TD
K -- yes --> J([Session started])
```
Auth challenges are generated from fresh random bytes plus a timestamp. They are signed as the canonical challenge payload and are not persisted in `program_client`.
Auth challenges are generated from fresh random bytes plus a nanosecond timestamp. The server keeps the issued challenge only in the in-flight authentication state for that connection, then verifies the signature against the same canonical challenge payload.
The authentication schema stores peer identity, not replay counters:
- `program_client` stores the SDK client's public key, metadata binding, and timestamps.
- `operator_client` stores the Operator public key and timestamps.
- Neither table stores an authentication nonce, and challenge generation does not update either table.
---
@@ -56,7 +62,7 @@ Auth challenges are generated from fresh random bytes plus a timestamp. They are
### User-Agent Authentication
User-agent authentication supports multiple signature schemes because platform-provided "hardware-bound" keys do not expose a uniform algorithm across operating systems and hardware.
Operator authentication supports multiple signature schemes because platform-provided "hardware-bound" keys do not expose a uniform algorithm across operating systems and hardware.
- **Supported schemes:** ML-DSA
- **Why:** Secure Enclave (MacOS) support them natively, on other platforms we could emulate while they roll-out
@@ -80,7 +86,7 @@ User-agent authentication supports multiple signature schemes because platform-p
### Request Multiplexing
Both `client` and `user-agent` connections support multiple in-flight requests over one gRPC bidi stream.
Both `client` and `operator` connections support multiple in-flight requests over one gRPC bidi stream.
- Every request carries a monotonically increasing request ID
- Every normal response echoes the request ID it corresponds to
@@ -135,7 +141,7 @@ flowchart TD
L -- Yes --> M[Check grant limits]
L -- No --> N[Start execution or grant voting flow]
N --> O{User-agent decision}
N --> O{Operator decision}
O -- Reject --> Z4[Return no matching grant error]
O -- Allow once --> M
O -- Create grant --> P[Create grant with user-selected limits]

File diff suppressed because it is too large Load Diff

View File

@@ -1,821 +0,0 @@
# 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 |
|---|---|---|
| `useragent/lib/theme/palette.dart` | Modify | Add `Palette.token` (indigo accent for token-transfer cards) |
| `useragent/lib/features/connection/evm/wallet_access.dart` | Modify | Add `listAllWalletAccesses()` function |
| `useragent/lib/providers/sdk_clients/wallet_access_list.dart` | Create | `WalletAccessListProvider` — fetches full wallet access list with IDs |
| `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart` | Create | `GrantCard` widget — watches enrichment providers + revoke mutation; one card per grant |
| `useragent/lib/screens/dashboard/evm/grants/grants.dart` | Create | `EvmGrantsScreen` — watches `evmGrantsProvider`; handles loading/error/empty/data states; renders `GrantCard` list |
| `useragent/lib/router.dart` | Modify | Register `EvmGrantsRoute` in dashboard children |
| `useragent/lib/screens/dashboard.dart` | Modify | Add Grants entry to `routes` list and `NavigationDestination` list |
---
## Task 1: Add `Palette.token`
**Files:**
- Modify: `useragent/lib/theme/palette.dart`
- [ ] **Step 1: Add the color**
Replace the contents of `useragent/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 useragent && 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: `useragent/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 `useragent/lib/features/connection/evm/wallet_access.dart`:
```dart
Future<List<SdkClientWalletAccess>> listAllWalletAccesses(
Connection connection,
) async {
final response = await connection.ask(
UserAgentRequest(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 useragent && 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: `useragent/lib/providers/sdk_clients/wallet_access_list.dart`
- Generated: `useragent/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 `useragent/lib/providers/sdk_clients/wallet_access_list.dart`:
```dart
import 'package:arbiter/features/connection/evm/wallet_access.dart';
import 'package:arbiter/proto/user_agent.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 useragent && dart run build_runner build --delete-conflicting-outputs
```
Expected: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart` created. No errors.
- [ ] **Step 3: Verify**
```sh
cd useragent && 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: `useragent/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/user_agent.pb.dart`): `.id`, `.access.walletId`, `.access.sdkClientId`
- `WalletEntry` (from `proto/evm.pb.dart`): `.id`, `.address` (List<int>)
- `SdkClientEntry` (from `proto/user_agent.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 `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`:
```dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/proto/user_agent.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 useragent && 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: `useragent/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 `useragent/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 useragent && 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: `useragent/lib/router.dart`
- Modify: `useragent/lib/screens/dashboard.dart`
- Regenerated: `useragent/lib/router.gr.dart`
- [ ] **Step 1: Add route to `router.dart`**
Replace the contents of `useragent/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 `useragent/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 useragent && 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 useragent && flutter analyze
```
Expected: no issues.
- [ ] **Step 5: Commit**
```sh
jj describe -m "feat(nav): add Grants dashboard tab"
jj new
```

View File

@@ -1,170 +0,0 @@
# Grant Grid View — Design Spec
**Date:** 2026-03-28
## Overview
Add a "Grants" dashboard tab to the Flutter user-agent 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:** `useragent/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:** `useragent/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 |

113
mise.lock
View File

@@ -1,51 +1,56 @@
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
[[tools.ast-grep]]
version = "0.42.0"
version = "0.42.1"
backend = "aqua:ast-grep/ast-grep"
[tools.ast-grep."platforms.linux-arm64"]
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip"
checksum = "sha256:3ba383839044cf9817929435f5ce0027f91d06931e8efb32d942e58d73d92be5"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-unknown-linux-gnu.zip"
url_api = "https://api.github.com/repos/ast-grep/ast-grep/releases/assets/388772218"
[tools.ast-grep."platforms.linux-arm64-musl"]
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip"
checksum = "sha256:3ba383839044cf9817929435f5ce0027f91d06931e8efb32d942e58d73d92be5"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.linux-x64"]
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip"
checksum = "sha256:5de8b87cba67fc8dc3e239d54b6484802ad745a7ae3de76be4fe89661dc52657"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-unknown-linux-gnu.zip"
url_api = "https://api.github.com/repos/ast-grep/ast-grep/releases/assets/388771275"
[tools.ast-grep."platforms.linux-x64-musl"]
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip"
checksum = "sha256:5de8b87cba67fc8dc3e239d54b6484802ad745a7ae3de76be4fe89661dc52657"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.macos-arm64"]
checksum = "sha256:fc300d5293b1c770a5aece03a8a193b92e71e87cec726c28096990691a582620"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-apple-darwin.zip"
checksum = "sha256:c3961d8e8a4ee0ce2d0d98c7beeb168bb331cdc766b53630118a7b6c4fd39015"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-apple-darwin.zip"
url_api = "https://api.github.com/repos/ast-grep/ast-grep/releases/assets/388770234"
[tools.ast-grep."platforms.macos-x64"]
checksum = "sha256:979ffe611327056f4730a1ae71b0209b3b830f58b22c6ed194cda34f55400db2"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-apple-darwin.zip"
checksum = "sha256:a038965bfd7fe44257c771cdf8918dc3467dd8ec0eef673b8b14f639b144cdbd"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-apple-darwin.zip"
url_api = "https://api.github.com/repos/ast-grep/ast-grep/releases/assets/388770498"
[tools.ast-grep."platforms.windows-x64"]
checksum = "sha256:55836fa1b2c65dc7d61615a4d9368622a0d2371a76d28b9a165e5a3ab6ae32a4"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-pc-windows-msvc.zip"
checksum = "sha256:fe34f631bb24c08ad146f92ca2a92971a53d179461b509fd8d32dc863bff9f83"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-pc-windows-msvc.zip"
url_api = "https://api.github.com/repos/ast-grep/ast-grep/releases/assets/388771363"
[[tools."cargo:cargo-audit"]]
version = "0.22.1"
backend = "cargo:cargo-audit"
[[tools."cargo:cargo-edit"]]
version = "0.13.9"
version = "0.13.10"
backend = "cargo:cargo-edit"
[[tools."cargo:cargo-features-manager"]]
version = "0.11.1"
version = "0.12.0"
backend = "cargo:cargo-features-manager"
[[tools."cargo:cargo-insta"]]
version = "1.46.3"
version = "1.47.2"
backend = "cargo:cargo-insta"
[[tools."cargo:cargo-mutants"]]
@@ -53,11 +58,11 @@ version = "27.0.0"
backend = "cargo:cargo-mutants"
[[tools."cargo:cargo-nextest"]]
version = "0.9.126"
version = "0.9.133"
backend = "cargo:cargo-nextest"
[[tools."cargo:cargo-shear"]]
version = "1.11.2"
version = "1.13.4"
backend = "cargo:cargo-shear"
[[tools."cargo:cargo-vet"]]
@@ -65,7 +70,7 @@ version = "0.10.2"
backend = "cargo:cargo-vet"
[[tools."cargo:diesel_cli"]]
version = "2.3.6"
version = "2.3.7"
backend = "cargo:diesel_cli"
[tools."cargo:diesel_cli".options]
@@ -77,8 +82,28 @@ version = "2.12.0"
backend = "cargo:flutter_rust_bridge_codegen"
[[tools.flutter]]
version = "3.38.9-stable"
backend = "asdf:flutter"
version = "3.41.7-stable"
backend = "http:flutter"
[tools.flutter."platforms.linux-x64"]
checksum = "sha256:f344d5057db52abc2a63cd3a7c7370957b7685d1fca5e5fbe2ce4dfe74657a79"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.41.7-stable.tar.xz"
[tools.flutter."platforms.linux-x64-musl"]
checksum = "sha256:f344d5057db52abc2a63cd3a7c7370957b7685d1fca5e5fbe2ce4dfe74657a79"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.41.7-stable.tar.xz"
[tools.flutter."platforms.macos-arm64"]
checksum = "sha256:2e3e6af44d1adccf695deff52e5e4c8beb10e5625066b27ad082b38b83ef805e"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.41.7-stable.zip"
[tools.flutter."platforms.macos-x64"]
checksum = "sha256:a0b9af49e6e1a6800f31a408b98c1d7bd51e98650a8b9ebcd77168b48c916ff0"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.41.7-stable.zip"
[tools.flutter."platforms.windows-x64"]
checksum = "sha256:de17b513b740a931c5dbc3f96b5a659c1612dfe6b5e1f910c5ad954a8bac17ee"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.41.7-stable.zip"
[[tools.protoc]]
version = "29.6"
@@ -87,70 +112,80 @@ backend = "aqua:protocolbuffers/protobuf/protoc"
[tools.protoc."platforms.linux-arm64"]
checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795076"
[tools.protoc."platforms.linux-arm64-musl"]
checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795076"
[tools.protoc."platforms.linux-x64"]
checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795083"
[tools.protoc."platforms.linux-x64-musl"]
checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795083"
[tools.protoc."platforms.macos-arm64"]
checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795082"
[tools.protoc."platforms.macos-x64"]
checksum = "sha256:312f04713946921cc0187ef34df80241ddca1bab6f564c636885fd2cc90d3f88"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-x86_64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795085"
[tools.protoc."platforms.windows-x64"]
checksum = "sha256:1ebd7c87baffb9f1c47169b640872bf5fb1e4408079c691af527be9561d8f6f7"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-win64.zip"
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795088"
[[tools.python]]
version = "3.14.3"
version = "3.14.4"
backend = "core:python"
[tools.python."platforms.linux-arm64"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
checksum = "sha256:b8b597fdb2f8dccdc502c11947b60a4b65eb6bce79cfa60c7ccf9b6e8352c60a"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
checksum = "sha256:a10687b226e0941632569836bc1d8fa6353a8e3e8424316467ca9cdf220b983d"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-unknown-linux-musl-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
checksum = "sha256:fe9a9c32d13870af632cbac3dfc7528ae53597e94472aa4c7d6a42e8166136cd"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64-musl"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
checksum = "sha256:d6005226cd24e780630626232c7a63243d4885fdf975dcf930a0758a0759ce14"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-unknown-linux-musl-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-arm64"]
checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-apple-darwin-install_only_stripped.tar.gz"
checksum = "sha256:6f304f4ec30854611f23316578302235fb517cd970519ecdd11a8c4db87fd843"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-aarch64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-x64"]
checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz"
checksum = "sha256:d51250a32fa5d9f0799c7bcb71720c27b10a3afd4a7de288120f96085d508a5a"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.windows-x64"]
checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
checksum = "sha256:a976991dcd085c1bb5d9a8084823a6bc8b7f9b079d8c432574a6ddd68c3a6fe1"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260414/cpython-3.14.4+20260414-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
provenance = "github-attestations"
[[tools.rust]]
version = "1.93.0"
version = "1.95.0"
backend = "core:rust"
[tools.rust.options]
components = "clippy,rust-analyzer"

View File

@@ -1,17 +1,17 @@
[tools]
"cargo:diesel_cli" = { version = "2.3.6", features = "sqlite,sqlite-bundled", default-features = false }
"cargo:diesel_cli" = { version = "2.3.7", features = "sqlite,sqlite-bundled", default-features = "false" }
"cargo:cargo-audit" = "0.22.1"
"cargo:cargo-vet" = "0.10.2"
flutter = "3.38.9-stable"
flutter = "3.41.7-stable"
protoc = "29.6"
"rust" = {version = "1.93.0", components = "clippy"}
"cargo:cargo-features-manager" = "0.11.1"
"cargo:cargo-nextest" = "0.9.126"
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"
"cargo:cargo-insta" = "1.46.3"
python = "3.14.3"
ast-grep = "0.42.0"
"cargo:cargo-edit" = "0.13.9"
"cargo:cargo-insta" = "1.47.2"
python = "3.14.4"
ast-grep = "0.42.1"
"cargo:cargo-edit" = "0.13.10"
"cargo:cargo-mutants" = "27.0.0"
"cargo:flutter_rust_bridge_codegen" = "2.12.0"
@@ -22,3 +22,5 @@ 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

@@ -3,7 +3,7 @@ syntax = "proto3";
package arbiter;
import "client.proto";
import "user_agent.proto";
import "operator.proto";
message ServerInfo {
string version = 1;
@@ -12,5 +12,5 @@ message ServerInfo {
service ArbiterService {
rpc Client(stream arbiter.client.ClientRequest) returns (stream arbiter.client.ClientResponse);
rpc UserAgent(stream arbiter.user_agent.UserAgentRequest) returns (stream arbiter.user_agent.UserAgentResponse);
rpc Operator(stream arbiter.operator.OperatorRequest) returns (stream arbiter.operator.OperatorResponse);
}

View File

@@ -24,7 +24,7 @@ enum AuthResult {
AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_APPROVAL_DENIED = 4;
AUTH_RESULT_NO_USER_AGENTS_ONLINE = 5;
AUTH_RESULT_NO_OPERATORS_ONLINE = 5;
AUTH_RESULT_INTERNAL = 6;
}

View File

@@ -75,7 +75,7 @@ message SpecificGrant {
}
}
// --- UserAgent grant management ---
// --- Operator grant management ---
message EvmGrantCreateRequest {
SharedSettings shared = 1;
SpecificGrant specific = 2;

31
protobufs/operator.proto Normal file
View File

@@ -0,0 +1,31 @@
syntax = "proto3";
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;
}
}
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;
}
}

View File

@@ -1,6 +1,6 @@
syntax = "proto3";
package arbiter.user_agent.auth;
package arbiter.operator.auth;
message AuthChallengeRequest {
bytes pubkey = 1;

View File

@@ -1,6 +1,6 @@
syntax = "proto3";
package arbiter.user_agent.evm;
package arbiter.operator.evm;
import "evm.proto";
import "google/protobuf/empty.proto";

View File

@@ -0,0 +1,131 @@
syntax = "proto3";
package arbiter.operator.governance;
import "google/protobuf/empty.proto";
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 = 2;
ReplaceOperatorPayload replace_operator = 3;
google.protobuf.Empty trigger_rekey = 4;
ApprovePersistentGrantPayload approve_persistent_grant = 5;
ApproveOneOffTransactionPayload approve_one_off_transaction = 6;
}
optional uint32 ttl_secs = 7;
}
message ReplaceOperatorPayload {
int32 old_operator_id = 1;
bytes new_pubkey = 2;
}
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

@@ -1,6 +1,6 @@
syntax = "proto3";
package arbiter.user_agent.sdk_client;
package arbiter.operator.sdk_client;
import "shared/client.proto";
import "google/protobuf/empty.proto";

View File

@@ -0,0 +1,43 @@
syntax = "proto3";
package arbiter.operator.vault.bootstrap;
message BootstrapEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
message DeclareCommittee {
uint32 count = 1;
uint32 recovery_count = 2;
}
message ContributePassphrase {
bytes passphrase = 1;
}
message ContributeRecoveryPassphrase {
bytes passphrase = 1;
}
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;
}
}
message Response {
BootstrapResult result = 1;
}

View File

@@ -0,0 +1,29 @@
syntax = "proto3";
package arbiter.operator.vault.rekey;
message ContributePassphrase {
bytes passphrase = 1;
}
message ContributeRecoveryPassphrase {
bytes passphrase = 1;
}
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

@@ -1,6 +1,6 @@
syntax = "proto3";
package arbiter.user_agent.vault.unseal;
package arbiter.operator.vault.unseal;
message UnsealStart {
bytes client_pubkey = 1;
@@ -15,17 +15,28 @@ message UnsealEncryptedKey {
bytes associated_data = 3;
}
message ContributePassphrase {
bytes passphrase = 1;
}
message ContributeRecoveryPassphrase {
bytes passphrase = 1;
}
enum UnsealResult {
UNSEAL_RESULT_UNSPECIFIED = 0;
UNSEAL_RESULT_SUCCESS = 1;
UNSEAL_RESULT_INVALID_KEY = 2;
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
UNSEAL_RESULT_AWAITING_CONTRIBUTIONS = 4;
}
message Request {
oneof payload {
UnsealStart start = 1;
UnsealEncryptedKey encrypted_key = 2;
UnsealStart start = 1;
UnsealEncryptedKey encrypted_key = 2;
ContributePassphrase contribute_passphrase = 3;
ContributeRecoveryPassphrase contribute_recovery_passphrase = 4;
}
}

View File

@@ -0,0 +1,27 @@
syntax = "proto3";
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;
}
}
message Response {
oneof payload {
arbiter.shared.VaultState state = 1;
unseal.Response unseal = 2;
bootstrap.Response bootstrap = 3;
rekey.Response rekey = 4;
}
}

View File

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

View File

@@ -1,28 +0,0 @@
syntax = "proto3";
package arbiter.user_agent;
import "user_agent/auth.proto";
import "user_agent/evm.proto";
import "user_agent/sdk_client.proto";
import "user_agent/vault/vault.proto";
message UserAgentRequest {
int32 id = 16;
oneof payload {
auth.Request auth = 1;
vault.Request vault = 2;
evm.Request evm = 3;
sdk_client.Request sdk_client = 4;
}
}
message UserAgentResponse {
optional int32 id = 16;
oneof payload {
auth.Response auth = 1;
vault.Response vault = 2;
evm.Response evm = 3;
sdk_client.Response sdk_client = 4;
}
}

View File

@@ -1,24 +0,0 @@
syntax = "proto3";
package arbiter.user_agent.vault.bootstrap;
message BootstrapEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
enum BootstrapResult {
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
BOOTSTRAP_RESULT_SUCCESS = 1;
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
BOOTSTRAP_RESULT_INVALID_KEY = 3;
}
message Request {
BootstrapEncryptedKey encrypted_key = 2;
}
message Response {
BootstrapResult result = 1;
}

View File

@@ -1,24 +0,0 @@
syntax = "proto3";
package arbiter.user_agent.vault;
import "google/protobuf/empty.proto";
import "shared/vault.proto";
import "user_agent/vault/bootstrap.proto";
import "user_agent/vault/unseal.proto";
message Request {
oneof payload {
google.protobuf.Empty query_state = 1;
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;
}
}

View File

@@ -0,0 +1,2 @@
[env]
MACOSX_DEPLOYMENT_TARGET = "26.3"

1052
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,48 +4,168 @@ members = [
]
resolver = "3"
[workspace.lints.clippy]
disallowed-methods = "deny"
[workspace.dependencies]
tonic = { version = "0.14.5", features = [
"deflate",
"gzip",
"tls-connect-info",
"zstd",
] }
tracing = "0.1.44"
tokio = { version = "1.50.0", features = ["full"] }
ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] }
chrono = { version = "0.4.44", features = ["serde"] }
rand = "0.10.0"
rustls = { version = "0.23.37", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
smlang = "0.8.0"
thiserror = "2.0.18"
alloy = "2.0.4"
async-trait = "0.1.89"
futures = "0.3.32"
tokio-stream = { version = "0.1.18", features = ["full"] }
prost-types = { version = "0.14.3", features = ["chrono"] }
x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
rstest = "0.26.1"
rustls-pki-types = "1.14.0"
alloy = "1.7.3"
rcgen = { version = "0.14.7", features = [
"aws_lc_rs",
"pem",
"x509-parser",
"zeroize",
], default-features = false }
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
rsa = { version = "0.9", features = ["sha2"] }
sha2 = "0.10"
spki = "0.7"
prost = "0.14.3"
miette = { version = "7.6.0", features = ["fancy", "serde"] }
mutants = "0.0.4"
ml-dsa = { version = "0.1.0-rc.8", features = ["zeroize"] }
base64 = "0.22.1"
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
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 = "17af90e3"}
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "17af90e3"}
hmac = "0.13.0"
miette = { version = "7.6.0", features = ["fancy", "serde"] }
ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] }
mutants = "0.0.4"
prost = "0.14.3"
prost-types = { version = "0.14.3", features = ["chrono"] }
rand = "0.10.1"
rcgen = { version = "0.14.7", features = [ "aws_lc_rs", "pem", "x509-parser", "zeroize" ], default-features = false }
rstest = "0.26.1"
rustls = { version = "0.23.40", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
rustls-pki-types = "1.14.1"
sha2 = "0.11"
smlang = "0.8.0"
strum = { version = "0.28.0", features = ["derive"] }
thiserror = "2.0.18"
tokio = { version = "1.52.1", features = ["full"] }
tokio-stream = { version = "0.1.18", features = ["full"] }
tonic = { version = "0.14.5", features = [ "deflate", "gzip", "tls-connect-info", "zstd" ] }
tracing = "0.1.44"
x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
[workspace.lints.rust]
missing_unsafe_on_extern = "deny"
unsafe_attr_outside_unsafe = "deny"
unsafe_op_in_unsafe_fn = "deny"
unstable_features = "deny"
deprecated_safe_2024 = "warn"
ffi_unwind_calls = "warn"
linker_messages = "warn"
elided_lifetimes_in_paths = "warn"
explicit_outlives_requirements = "warn"
impl-trait-overcaptures = "warn"
impl-trait-redundant-captures = "warn"
redundant_lifetimes = "warn"
single_use_lifetimes = "warn"
unused_lifetimes = "warn"
macro_use_extern_crate = "warn"
redundant_imports = "warn"
unused_import_braces = "warn"
unused_macro_rules = "warn"
unused_qualifications = "warn"
unit_bindings = "warn"
# missing_docs = "warn" # ENABLE BY THE FIRST MAJOR VERSION!!
unnameable_types = "warn"
[workspace.lints.clippy]
derive_partial_eq_without_eq = "allow"
future_not_send = "allow"
inconsistent_struct_constructor = "allow"
inline_always = "allow"
missing_errors_doc = "allow"
missing_fields_in_debug = "allow"
missing_panics_doc = "allow"
must_use_candidate = "allow"
needless_pass_by_ref_mut = "allow"
pub_underscore_fields = "allow"
redundant_pub_crate = "allow"
uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {})
# restriction lints
alloc_instead_of_core = "warn"
allow_attributes_without_reason = "warn"
as_conversions = "warn"
assertions_on_result_states = "warn"
cfg_not_test = "warn"
clone_on_ref_ptr = "warn"
cognitive_complexity = "warn"
create_dir = "warn"
dbg_macro = "warn"
decimal_literal_representation = "warn"
default_union_representation = "warn"
deref_by_slicing = "warn"
disallowed_script_idents = "warn"
doc_include_without_cfg = "warn"
empty_drop = "warn"
empty_enum_variants_with_brackets = "warn"
empty_structs_with_brackets = "warn"
exit = "warn"
filetype_is_file = "warn"
float_arithmetic = "warn"
float_cmp_const = "warn"
fn_to_numeric_cast_any = "warn"
get_unwrap = "warn"
if_then_some_else_none = "warn"
indexing_slicing = "warn"
infinite_loop = "warn"
inline_asm_x86_att_syntax = "warn"
inline_asm_x86_intel_syntax = "warn"
large_include_file = "warn"
lossy_float_literal = "warn"
map_with_unused_argument_over_ranges = "warn"
mem_forget = "warn"
missing_assert_message = "warn"
mixed_read_write_in_expression = "warn"
modulo_arithmetic = "warn"
multiple_unsafe_ops_per_block = "warn"
mutex_atomic = "warn"
mutex_integer = "warn"
needless_raw_strings = "warn"
non_ascii_literal = "warn"
non_zero_suggestions = "warn"
pathbuf_init_then_push = "warn"
pointer_format = "warn"
precedence_bits = "warn"
pub_without_shorthand = "warn"
rc_buffer = "warn"
rc_mutex = "warn"
redundant_test_prefix = "warn"
redundant_type_annotations = "warn"
ref_patterns = "warn"
renamed_function_params = "warn"
rest_pat_in_fully_bound_structs = "warn"
return_and_then = "warn"
semicolon_inside_block = "warn"
str_to_string = "warn"
string_add = "warn"
string_lit_chars_any = "warn"
string_slice = "warn"
suspicious_xor_used_as_pow = "warn"
try_err = "warn"
undocumented_unsafe_blocks = "warn"
uninlined_format_args = "warn"
unnecessary_safety_comment = "warn"
unnecessary_safety_doc = "warn"
unnecessary_self_imports = "warn"
unneeded_field_pattern = "warn"
unused_result_ok = "warn"
verbose_file_reads = "warn"
# cargo lints
negative_feature_names = "warn"
redundant_feature_names = "warn"
wildcard_dependencies = "warn"
# ENABLE BY THE FIRST MAJOR VERSION!!
# todo = "warn"
# unimplemented = "warn"
# panic = "warn"
# panic_in_result_fn = "warn"
#
# cargo_common_metadata = "warn"
# multiple_crate_versions = "warn" # a controversial option since it's really difficult to maintain
disallowed_methods = "deny"
nursery = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
type_repetition_in_bounds = "allow" # sometimes, it's better for readability this way
unused_async_trait_impl = "allow"

View File

@@ -7,3 +7,24 @@ disallowed-methods = [
{ path = "rsa::traits::Decryptor::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
{ path = "rsa::traits::RandomizedDecryptor::decrypt_with_rng", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt_with_rng() on rsa::{pkcs1v15,oaep}::DecryptingKey." },
]
allow-indexing-slicing-in-tests = true
allow-panic-in-tests = true
check-inconsistent-struct-field-initializers = true
suppress-restriction-lint-in-const = true
allow-renamed-params-for = [
"core::convert::From",
"core::convert::TryFrom",
"core::str::FromStr",
"kameo::actor::Actor",
]
module-items-ordered-within-groupings = ["UPPER_SNAKE_CASE"]
source-item-ordering = ["enum"]
trait-assoc-item-kinds-order = [
"const",
"type",
"fn",
] # community tested standard
too-many-lines-threshold = 150

View File

@@ -21,7 +21,9 @@ tokio.workspace = true
tokio-stream.workspace = true
thiserror.workspace = true
http = "1.4.0"
rustls-webpki = { version = "0.103.10", features = ["aws-lc-rs"] }
rustls-webpki = { version = "0.103.13", features = ["aws-lc-rs"] }
async-trait.workspace = true
rand.workspace = true
chrono.workspace = true
[lib]
doctest = false

View File

@@ -1,4 +1,8 @@
use arbiter_crypto::authn::{self, CLIENT_CONTEXT, SigningKey};
use crate::{
storage::StorageError,
transport::{ClientTransport, next_request_id},
};
use arbiter_crypto::authn::{self, SigningContext, SigningKey};
use arbiter_proto::{
ClientMetadata,
proto::{
@@ -15,37 +19,32 @@ use arbiter_proto::{
shared::ClientInfo as ProtoClientInfo,
},
};
use chrono::DateTime;
use crate::{
storage::StorageError,
transport::{ClientTransport, next_request_id},
};
use chrono::DateTime;
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Server sent invalid auth challenge")]
InvalidChallenge,
#[error("Client approval denied by Operator")]
ApprovalDenied,
#[error("Auth challenge was not returned by server")]
MissingAuthChallenge,
#[error("Client approval denied by User Agent")]
ApprovalDenied,
#[error("No User Agents online to approve client")]
NoUserAgentsOnline,
#[error("Unexpected auth response payload")]
UnexpectedAuthResponse,
#[error("No Operators online to approve client")]
NoOperatorsOnline,
#[error("Signing key storage error")]
Storage(#[from] StorageError),
#[error("Unexpected auth response payload")]
UnexpectedAuthResponse,
}
fn map_auth_result(code: i32) -> AuthError {
match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) {
AuthResult::ApprovalDenied => AuthError::ApprovalDenied,
AuthResult::NoUserAgentsOnline => AuthError::NoUserAgentsOnline,
AuthResult::NoOperatorsOnline => AuthError::NoOperatorsOnline,
AuthResult::Unspecified
| AuthResult::Success
| AuthResult::InvalidKey
@@ -58,7 +57,7 @@ async fn send_auth_challenge_request(
transport: &mut ClientTransport,
metadata: ClientMetadata,
key: &SigningKey,
) -> std::result::Result<(), AuthError> {
) -> Result<(), AuthError> {
transport
.send(ClientRequest {
request_id: next_request_id(),
@@ -79,7 +78,7 @@ async fn send_auth_challenge_request(
async fn receive_auth_challenge(
transport: &mut ClientTransport,
) -> std::result::Result<AuthChallenge, AuthError> {
) -> Result<AuthChallenge, AuthError> {
let response = transport
.recv()
.await
@@ -100,7 +99,7 @@ async fn send_auth_challenge_solution(
transport: &mut ClientTransport,
key: &SigningKey,
challenge: AuthChallenge,
) -> std::result::Result<(), AuthError> {
) -> Result<(), AuthError> {
let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos as i64);
let challenge = authn::AuthChallenge {
nonce: *challenge
@@ -111,7 +110,7 @@ async fn send_auth_challenge_solution(
};
let challenge_payload: Vec<u8> = challenge.format();
let signature = key
.sign_message(&challenge_payload, CLIENT_CONTEXT)
.sign_message(&challenge_payload, SigningContext::Client)
.map_err(|_| AuthError::UnexpectedAuthResponse)?
.to_bytes();
@@ -128,9 +127,7 @@ async fn send_auth_challenge_solution(
.map_err(|_| AuthError::UnexpectedAuthResponse)
}
async fn receive_auth_confirmation(
transport: &mut ClientTransport,
) -> std::result::Result<(), AuthError> {
async fn receive_auth_confirmation(transport: &mut ClientTransport) -> Result<(), AuthError> {
let response = transport
.recv()
.await
@@ -151,11 +148,11 @@ async fn receive_auth_confirmation(
}
}
pub(crate) async fn authenticate(
pub async fn authenticate(
transport: &mut ClientTransport,
metadata: ClientMetadata,
key: &SigningKey,
) -> std::result::Result<(), AuthError> {
) -> Result<(), AuthError> {
send_auth_challenge_request(transport, metadata, key).await?;
let challenge = receive_auth_challenge(transport).await?;
send_auth_challenge_solution(transport, key, challenge).await?;

View File

@@ -1,8 +1,8 @@
use std::io::{self, Write};
use arbiter_client::ArbiterClient;
use arbiter_proto::{ClientMetadata, url::ArbiterUrl};
use std::io::{self, Write};
#[tokio::main]
async fn main() {
println!("Testing connection to Arbiter server...");
@@ -29,16 +29,16 @@ async fn main() {
}
};
println!("{:#?}", url);
println!("{url:#?}");
let metadata = ClientMetadata {
name: "arbiter-client test_connect".to_string(),
description: Some("Manual connection smoke test".to_string()),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
name: "arbiter-client test_connect".to_owned(),
description: Some("Manual connection smoke test".to_owned()),
version: Some(env!("CARGO_PKG_VERSION").to_owned()),
};
match ArbiterClient::connect(url, metadata).await {
Ok(_) => println!("Connected and authenticated successfully."),
Err(err) => eprintln!("Failed to connect: {:#?}", err),
Err(err) => eprintln!("Failed to connect: {err:#?}"),
}
}

View File

@@ -1,50 +1,55 @@
use arbiter_crypto::authn::SigningKey;
use arbiter_proto::{
ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl,
};
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::ClientTlsConfig;
#[cfg(feature = "evm")]
use crate::wallets::evm::ArbiterEvmWallet;
use crate::{
StorageError,
auth::{AuthError, authenticate},
storage::{FileSigningKeyStorage, SigningKeyStorage},
transport::{BUFFER_LENGTH, ClientTransport},
};
use arbiter_crypto::authn::SigningKey;
use arbiter_proto::{
ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl,
};
#[cfg(feature = "evm")]
use crate::wallets::evm::ArbiterEvmWallet;
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::ClientTlsConfig;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("gRPC error")]
Grpc(#[from] tonic::Status),
pub enum ArbiterClientError {
#[error("Authentication error")]
Authentication(#[from] AuthError),
#[error("Could not establish connection")]
Connection(#[from] tonic::transport::Error),
#[error("Invalid server URI")]
InvalidUri(#[from] http::uri::InvalidUri),
#[error("gRPC error")]
Grpc(#[from] tonic::Status),
#[error("Invalid CA certificate")]
InvalidCaCert(#[from] webpki::Error),
#[error("Authentication error")]
Authentication(#[from] AuthError),
#[error("Invalid server URI")]
InvalidUri(#[from] http::uri::InvalidUri),
#[error("Storage error")]
Storage(#[from] StorageError),
}
pub struct ArbiterClient {
#[allow(dead_code)]
#[expect(
dead_code,
reason = "transport will be used in future methods for sending requests and receiving responses"
)]
transport: Arc<Mutex<ClientTransport>>,
}
impl ArbiterClient {
pub async fn connect(url: ArbiterUrl, metadata: ClientMetadata) -> Result<Self, Error> {
pub async fn connect(
url: ArbiterUrl,
metadata: ClientMetadata,
) -> Result<Self, ArbiterClientError> {
let storage = FileSigningKeyStorage::from_default_location()?;
Self::connect_with_storage(url, metadata, &storage).await
}
@@ -53,7 +58,7 @@ impl ArbiterClient {
url: ArbiterUrl,
metadata: ClientMetadata,
storage: &S,
) -> Result<Self, Error> {
) -> Result<Self, ArbiterClientError> {
let key = storage.load_or_create()?;
Self::connect_with_key(url, metadata, key).await
}
@@ -62,7 +67,7 @@ impl ArbiterClient {
url: ArbiterUrl,
metadata: ClientMetadata,
key: SigningKey,
) -> Result<Self, Error> {
) -> Result<Self, ArbiterClientError> {
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
let tls = ClientTlsConfig::new().trust_anchor(anchor);
@@ -89,7 +94,8 @@ impl ArbiterClient {
}
#[cfg(feature = "evm")]
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, Error> {
#[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

@@ -5,7 +5,7 @@ mod transport;
pub mod wallets;
pub use auth::AuthError;
pub use client::{ArbiterClient, Error};
pub use client::{ArbiterClient, ArbiterClientError};
pub use storage::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
#[cfg(feature = "evm")]

View File

@@ -1,18 +1,19 @@
use arbiter_crypto::authn::SigningKey;
use arbiter_proto::home_path;
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
#[error("I/O error")]
Io(#[from] std::io::Error),
#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")]
InvalidKeyLength { expected: usize, actual: usize },
#[error("I/O error")]
Io(#[from] std::io::Error),
}
pub trait SigningKeyStorage {
fn load_or_create(&self) -> std::result::Result<SigningKey, StorageError>;
fn load_or_create(&self) -> Result<SigningKey, StorageError>;
}
#[derive(Debug, Clone)]
@@ -27,11 +28,11 @@ impl FileSigningKeyStorage {
Self { path: path.into() }
}
pub fn from_default_location() -> std::result::Result<Self, StorageError> {
pub fn from_default_location() -> Result<Self, StorageError> {
Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME)))
}
fn read_key(path: &Path) -> std::result::Result<SigningKey, StorageError> {
fn read_key(path: &Path) -> Result<SigningKey, StorageError> {
let bytes = std::fs::read(path)?;
let raw: [u8; 32] =
bytes
@@ -45,7 +46,7 @@ impl FileSigningKeyStorage {
}
impl SigningKeyStorage for FileSigningKeyStorage {
fn load_or_create(&self) -> std::result::Result<SigningKey, StorageError> {
fn load_or_create(&self) -> Result<SigningKey, StorageError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
@@ -125,7 +126,7 @@ mod tests {
assert_eq!(expected, 32);
assert_eq!(actual, 31);
}
other => panic!("unexpected error: {other:?}"),
other @ StorageError::Io(_) => panic!("unexpected error: {other:?}"),
}
std::fs::remove_file(path).expect("temp key file should be removable");

View File

@@ -1,16 +1,17 @@
use arbiter_proto::proto::client::{ClientRequest, ClientResponse};
use std::sync::atomic::{AtomicI32, Ordering};
use tokio::sync::mpsc;
pub(crate) const BUFFER_LENGTH: usize = 16;
pub const BUFFER_LENGTH: usize = 16;
static NEXT_REQUEST_ID: AtomicI32 = AtomicI32::new(1);
pub(crate) fn next_request_id() -> i32 {
pub fn next_request_id() -> i32 {
NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ClientSignError {
pub enum ClientSignError {
#[error("Transport channel closed")]
ChannelClosed,
@@ -18,27 +19,23 @@ pub(crate) enum ClientSignError {
ConnectionClosed,
}
pub(crate) struct ClientTransport {
pub struct ClientTransport {
pub(crate) sender: mpsc::Sender<ClientRequest>,
pub(crate) receiver: tonic::Streaming<ClientResponse>,
}
impl ClientTransport {
pub(crate) async fn send(
&mut self,
request: ClientRequest,
) -> std::result::Result<(), ClientSignError> {
pub(crate) async fn send(&mut self, request: ClientRequest) -> Result<(), ClientSignError> {
self.sender
.send(request)
.await
.map_err(|_| ClientSignError::ChannelClosed)
}
pub(crate) async fn recv(&mut self) -> std::result::Result<ClientResponse, ClientSignError> {
pub(crate) async fn recv(&mut self) -> Result<ClientResponse, ClientSignError> {
match self.receiver.message().await {
Ok(Some(resp)) => Ok(resp),
Ok(None) => Err(ClientSignError::ConnectionClosed),
Err(_) => Err(ClientSignError::ConnectionClosed),
Ok(None) | Err(_) => Err(ClientSignError::ConnectionClosed),
}
}
}

View File

@@ -1,13 +1,4 @@
use alloy::{
consensus::SignableTransaction,
network::TxSigner,
primitives::{Address, B256, ChainId, Signature},
signers::{Error, Result, Signer},
};
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::transport::{ClientTransport, next_request_id};
use arbiter_proto::proto::{
client::{
ClientRequest,
@@ -25,7 +16,15 @@ use arbiter_proto::proto::{
shared::evm::TransactionEvalError,
};
use crate::transport::{ClientTransport, next_request_id};
use alloy::{
consensus::SignableTransaction,
network::TxSigner,
primitives::{Address, B256, ChainId, Signature},
signers::{Error, Result, Signer},
};
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::Mutex;
/// A typed error payload returned by [`ArbiterEvmWallet`] transaction signing.
///
@@ -59,7 +58,11 @@ pub struct ArbiterEvmWallet {
}
impl ArbiterEvmWallet {
pub(crate) fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
#[expect(
dead_code,
reason = "new will be used in future methods for creating wallets with different parameters"
)]
pub(crate) const fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
Self {
transport,
address,
@@ -67,11 +70,12 @@ impl ArbiterEvmWallet {
}
}
pub fn address(&self) -> Address {
pub const fn address(&self) -> Address {
self.address
}
pub fn with_chain_id(mut self, chain_id: ChainId) -> Self {
#[must_use]
pub const fn with_chain_id(mut self, chain_id: ChainId) -> Self {
self.chain_id = Some(chain_id);
self
}
@@ -146,6 +150,7 @@ impl TxSigner<Signature> for ArbiterEvmWallet {
.recv()
.await
.map_err(|_| Error::other("failed to receive evm sign transaction response"))?;
drop(transport);
if response.request_id != Some(request_id) {
return Err(Error::other(

View File

@@ -7,13 +7,20 @@ edition = "2024"
ml-dsa = {workspace = true, optional = true }
rand = {workspace = true, optional = true}
memsafe = {version = "0.4.0", optional = true}
strum = { workspace = true, optional = true }
hmac.workspace = true
alloy.workspace = true
x-wing = { version = "0.1.0-rc.0", features = ["zeroize"] }
chrono.workspace = true
thiserror.workspace = true
[lints]
workspace = true
[features]
default = ["authn", "safecell"]
authn = ["dep:ml-dsa", "dep:rand"]
authn = ["dep:ml-dsa", "dep:rand", "dep:strum"]
safecell = ["dep:memsafe"]
[lib]
doctest = false

View File

@@ -1,17 +1,39 @@
use std::hash::Hash;
use chrono::{DateTime, Utc};
use hmac::digest::Digest;
use ml_dsa::{
EncodedVerifyingKey, Error, KeyGen, MlDsa87, Seed, Signature as MlDsaSignature,
SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, signature::Keypair as _,
};
use rand::RngExt;
use strum::IntoStaticStr;
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
pub static USERAGENT_CONTEXT: &[u8] = b"arbiter_user_agent";
/// Domain separation tag mixed into every ML-DSA signature.
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
pub enum SigningContext {
#[strum(serialize = "arbiter_client")]
Client,
#[strum(serialize = "arbiter_operator")]
Operator,
#[strum(serialize = "arbiter_governance_vote")]
GovernanceVote,
}
impl SigningContext {
#[must_use]
pub fn as_bytes(self) -> &'static [u8] {
<&'static str>::from(self).as_bytes()
}
}
const NONCE_SIZE: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("invalid length: expected {expected} bytes, got {actual} bytes")]
pub struct InvalidLength {
pub expected: usize,
pub actual: usize,
}
#[derive(Debug, Clone)]
pub struct AuthChallenge {
pub nonce: [u8; NONCE_SIZE],
@@ -44,9 +66,12 @@ impl AuthChallenge {
}
}
pub fn from_parts(nonce: &[u8], timestamp: i64) -> Result<Self, ()> {
let random_nonce = nonce.as_array().ok_or(())?;
Ok(AuthChallenge {
pub fn from_parts(nonce: &[u8], timestamp: i64) -> Result<Self, InvalidLength> {
let random_nonce = nonce.as_array().ok_or(InvalidLength {
expected: NONCE_SIZE,
actual: nonce.len(),
})?;
Ok(Self {
nonce: *random_nonce,
timestamp: DateTime::from_timestamp_nanos(timestamp),
})
@@ -58,9 +83,9 @@ pub type KeyParams = MlDsa87;
#[derive(Clone, Debug, PartialEq)]
pub struct PublicKey(Box<MlDsaVerifyingKey<KeyParams>>);
impl Hash for PublicKey {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.to_bytes().hash(state);
impl crate::hashing::Hashable for PublicKey {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.to_bytes());
}
}
@@ -72,19 +97,36 @@ pub struct SigningKey(Box<MlDsaSigningKey<KeyParams>>);
impl PublicKey {
pub fn to_bytes(&self) -> Vec<u8> {
self.0.encode().to_vec()
self.0.encode().0.to_vec()
}
pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool {
#[must_use]
pub fn verify(
&self,
challenge: &AuthChallenge,
context: SigningContext,
signature: &Signature,
) -> bool {
let challenge = challenge.format();
self.0
.verify_with_context(&challenge, context, &signature.0)
.verify_with_context(&challenge, context.as_bytes(), &signature.0)
}
#[must_use]
pub fn verify_message(
&self,
message: &[u8],
context: SigningContext,
signature: &Signature,
) -> bool {
self.0
.verify_with_context(message, context.as_bytes(), &signature.0)
}
}
impl Signature {
pub fn to_bytes(&self) -> Vec<u8> {
self.0.encode().to_vec()
self.0.encode().0.to_vec()
}
}
@@ -105,17 +147,21 @@ impl SigningKey {
self.0.verifying_key().into()
}
pub fn sign_message(&self, message: &[u8], context: &[u8]) -> Result<Signature, Error> {
pub fn sign_message(
&self,
message: &[u8],
context: SigningContext,
) -> Result<Signature, Error> {
self.0
.signing_key()
.sign_deterministic(message, context)
.sign_deterministic(message, context.as_bytes())
.map(Into::into)
}
pub fn sign_challenge(
&self,
challenge: &AuthChallenge,
context: &[u8],
context: SigningContext,
) -> Result<Signature, Error> {
let challenge = challenge.format();
@@ -182,7 +228,7 @@ mod tests {
use crate::authn::AuthChallenge;
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, USERAGENT_CONTEXT};
use super::{PublicKey, Signature, SigningContext, SigningKey};
#[test]
fn public_key_round_trip_decodes() {
@@ -198,7 +244,7 @@ mod tests {
fn signature_round_trip_decodes() {
let key = SigningKey::generate();
let signature = key
.sign_message(b"challenge", CLIENT_CONTEXT)
.sign_message(b"challenge", SigningContext::Client)
.expect("signature should be created");
let decoded =
@@ -213,11 +259,11 @@ mod tests {
let public_key = key.public_key();
let challenge = AuthChallenge::generate(&mut rand::rng());
let signature = key
.sign_challenge(&challenge, CLIENT_CONTEXT)
.sign_challenge(&challenge, SigningContext::Client)
.expect("signature should be created");
assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature));
assert!(!public_key.verify(&challenge, USERAGENT_CONTEXT, &signature));
assert!(public_key.verify(&challenge, SigningContext::Client, &signature));
assert!(!public_key.verify(&challenge, SigningContext::Operator, &signature));
}
#[test]
@@ -230,13 +276,13 @@ mod tests {
let challenge = AuthChallenge::generate(&mut rand::rng());
let signature = restored
.sign_challenge(&challenge, CLIENT_CONTEXT)
.sign_challenge(&challenge, SigningContext::Client)
.expect("signature should be created");
assert!(
restored
.public_key()
.verify(&challenge, CLIENT_CONTEXT, &signature)
.verify(&challenge, SigningContext::Client, &signature)
);
}
}

View File

@@ -1,21 +1,26 @@
use hmac::digest::Digest;
use std::collections::HashSet;
pub use hmac::digest::Digest;
/// Deterministically hash a value by feeding its fields into the hasher in a consistent order.
#[diagnostic::on_unimplemented(
note = "for local types consider adding `#[derive(arbiter_macros::Hashable)]` to your `{Self}` type",
note = "for types from other crates check whether the crate offers a `Hashable` implementation"
)]
pub trait Hashable {
fn hash<H: Digest>(&self, hasher: &mut H);
}
macro_rules! impl_numeric {
($($t:ty),*) => {
$(
impl Hashable for $t {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(&self.to_be_bytes());
($($t:ty),*) => {
$(
impl Hashable for $t {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(&self.to_be_bytes());
}
}
}
)*
};
)*
};
}
impl_numeric!(u8, u16, u32, u64, i8, i16, i32, i64);
@@ -45,7 +50,7 @@ impl<T: Hashable + PartialOrd> Hashable for Vec<T> {
}
}
impl<T: Hashable + PartialOrd> Hashable for HashSet<T> {
impl<T: Hashable + PartialOrd, S: std::hash::BuildHasher> Hashable for HashSet<T, S> {
fn hash<H: Digest>(&self, hasher: &mut H) {
let ref_sorted = {
let mut sorted = self.iter().collect::<Vec<_>>();

View File

@@ -1,6 +1,6 @@
#[cfg(feature = "authn")]
pub mod authn;
pub mod hashing;
#[cfg(feature = "safecell")]
pub mod safecell;

View File

@@ -1,7 +1,9 @@
use std::ops::{Deref, DerefMut};
use std::{any::type_name, fmt};
use memsafe::MemSafe;
use std::{
any::type_name,
fmt,
ops::{Deref, DerefMut},
};
pub trait SafeCellHandle<T> {
type CellRead<'a>: Deref<Target = T>
@@ -20,7 +22,7 @@ pub trait SafeCellHandle<T> {
fn read(&mut self) -> Self::CellRead<'_>;
fn write(&mut self) -> Self::CellWrite<'_>;
fn new_inline<F>(f: F) -> Self
fn new_inline_default<F>(f: F) -> Self
where
Self: Sized,
T: Default,
@@ -29,11 +31,19 @@ pub trait SafeCellHandle<T> {
let mut cell = Self::new(T::default());
{
let mut handle = cell.write();
f(handle.deref_mut());
f(&mut *handle);
}
cell
}
fn new_inline<F>(f: Box<F>) -> Self
where
Self: Sized,
F: for<'a> FnOnce() -> T,
{
Self::new(f())
}
#[inline(always)]
fn read_inline<F, R>(&mut self, f: F) -> R
where

View File

@@ -0,0 +1,19 @@
[package]
name = "arbiter-macros"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
doctest = false
[dependencies]
proc-macro2 = "1.0"
quote = "1.0"
syn = { version = "2.0", features = ["derive", "fold", "full", "visit-mut"] }
[dev-dependencies]
arbiter-crypto = { path = "../arbiter-crypto" }
[lints]
workspace = true

View File

@@ -0,0 +1,131 @@
use crate::utils::{HASHABLE_TRAIT_PATH, HMAC_DIGEST_PATH};
use proc_macro2::{Span, TokenStream, TokenTree};
use quote::quote;
use syn::{DataStruct, DeriveInput, Fields, Generics, Index, parse_quote, spanned::Spanned};
pub(crate) fn derive(input: &DeriveInput) -> TokenStream {
match &input.data {
syn::Data::Struct(struct_data) => hashable_struct(input, struct_data),
syn::Data::Enum(_) => {
syn::Error::new_spanned(input, "Hashable can currently be derived only for structs")
.to_compile_error()
}
syn::Data::Union(_) => {
syn::Error::new_spanned(input, "Hashable cannot be derived for unions")
.to_compile_error()
}
}
}
fn hashable_struct(input: &DeriveInput, struct_data: &DataStruct) -> TokenStream {
let ident = &input.ident;
let hashable_trait = HASHABLE_TRAIT_PATH.to_path();
let hmac_digest = HMAC_DIGEST_PATH.to_path();
let generics = add_hashable_bounds(input.generics.clone(), &hashable_trait);
let field_accesses = collect_field_accesses(struct_data);
let hash_calls = build_hash_calls(&field_accesses, &hashable_trait);
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
quote! {
#[automatically_derived]
impl #impl_generics #hashable_trait for #ident #ty_generics #where_clause {
fn hash<H: #hmac_digest>(&self, hasher: &mut H) {
#(#hash_calls)*
}
}
}
}
fn add_hashable_bounds(mut generics: Generics, hashable_trait: &syn::Path) -> Generics {
for type_param in generics.type_params_mut() {
type_param.bounds.push(parse_quote!(#hashable_trait));
}
generics
}
struct FieldAccess {
access: TokenStream,
span: Span,
}
fn collect_field_accesses(struct_data: &DataStruct) -> Vec<FieldAccess> {
match &struct_data.fields {
Fields::Named(fields) => {
// Keep deterministic alphabetical order for named fields.
// Do not remove this sort, because it keeps hash output stable regardless of source order.
let mut named_fields = fields
.named
.iter()
.map(|field| {
let name = field
.ident
.as_ref()
.expect("Fields::Named(fields) must have names")
.clone();
(name.to_string(), name)
})
.collect::<Vec<_>>();
named_fields.sort_by(|a, b| a.0.cmp(&b.0));
named_fields
.into_iter()
.map(|(_, name)| FieldAccess {
access: quote! { #name },
span: name.span(),
})
.collect()
}
Fields::Unnamed(fields) => fields
.unnamed
.iter()
.enumerate()
.map(|(i, field)| FieldAccess {
access: {
let index = Index::from(i);
quote! { #index }
},
span: field.ty.span(),
})
.collect(),
Fields::Unit => Vec::new(),
}
}
fn build_hash_calls(
field_accesses: &[FieldAccess],
hashable_trait: &syn::Path,
) -> Vec<TokenStream> {
field_accesses
.iter()
.map(|field| {
let access = &field.access;
let call = quote! {
#hashable_trait::hash(&self.#access, hasher);
};
respan(call, field.span)
})
.collect()
}
/// Recursively set span on all tokens, including interpolated ones.
fn respan(tokens: TokenStream, span: Span) -> TokenStream {
tokens
.into_iter()
.map(|tt| match tt {
TokenTree::Group(g) => {
let mut new = proc_macro2::Group::new(g.delimiter(), respan(g.stream(), span));
new.set_span(span);
TokenTree::Group(new)
}
mut other => {
other.set_span(span);
other
}
})
.collect()
}

View File

@@ -0,0 +1,10 @@
use syn::{DeriveInput, parse_macro_input};
mod hashable;
mod utils;
#[proc_macro_derive(Hashable)]
pub fn derive_hashable(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let input = parse_macro_input!(input as DeriveInput);
hashable::derive(&input).into()
}

View File

@@ -0,0 +1,24 @@
pub(crate) struct ToPath(pub &'static str);
impl ToPath {
pub(crate) fn to_path(&self) -> syn::Path {
syn::parse_str(self.0).expect("Invalid path")
}
}
macro_rules! ensure_path {
($path:path as $name:ident) => {
const _: () = {
#[cfg(test)]
#[expect(
unused_imports,
reason = "Ensures the path is valid and will cause a compile error if not"
)]
use $path as _;
};
pub(crate) const $name: ToPath = ToPath(stringify!($path));
};
}
ensure_path!(::arbiter_crypto::hashing::Hashable as HASHABLE_TRAIT_PATH);
ensure_path!(::arbiter_crypto::hashing::Digest as HMAC_DIGEST_PATH);

View File

@@ -9,7 +9,6 @@ license = "Apache-2.0"
tonic.workspace = true
tokio.workspace = true
futures.workspace = true
hex = "0.4.3"
tonic-prost = "0.14.5"
prost.workspace = true
kameo.workspace = true
@@ -19,18 +18,18 @@ thiserror.workspace = true
rustls-pki-types.workspace = true
base64.workspace = true
prost-types.workspace = true
tracing.workspace = true
async-trait.workspace = true
tokio-stream.workspace = true
[build-dependencies]
tonic-prost-build = "0.14.5"
protoc-bin-vendored = "3"
[dev-dependencies]
rstest.workspace = true
rand.workspace = true
rcgen.workspace = true
[lib]
doctest = false
[package.metadata.cargo-shear]
ignored = ["tonic-prost", "prost", "kameo"]
ignored = ["tonic-prost", "prost"]

View File

@@ -10,7 +10,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.compile_protos(
&[
format!("{}/arbiter.proto", PROTOBUF_DIR),
format!("{}/user_agent.proto", PROTOBUF_DIR),
format!("{}/operator.proto", PROTOBUF_DIR),
format!("{}/client.proto", PROTOBUF_DIR),
format!("{}/evm.proto", PROTOBUF_DIR),
],

View File

@@ -12,30 +12,38 @@ pub mod proto {
}
}
pub mod user_agent {
tonic::include_proto!("arbiter.user_agent");
pub mod operator {
tonic::include_proto!("arbiter.operator");
pub mod auth {
tonic::include_proto!("arbiter.user_agent.auth");
tonic::include_proto!("arbiter.operator.auth");
}
pub mod evm {
tonic::include_proto!("arbiter.user_agent.evm");
tonic::include_proto!("arbiter.operator.evm");
}
pub mod governance {
tonic::include_proto!("arbiter.operator.governance");
}
pub mod sdk_client {
tonic::include_proto!("arbiter.user_agent.sdk_client");
tonic::include_proto!("arbiter.operator.sdk_client");
}
pub mod vault {
tonic::include_proto!("arbiter.user_agent.vault");
tonic::include_proto!("arbiter.operator.vault");
pub mod bootstrap {
tonic::include_proto!("arbiter.user_agent.vault.bootstrap");
tonic::include_proto!("arbiter.operator.vault.bootstrap");
}
pub mod rekey {
tonic::include_proto!("arbiter.operator.vault.rekey");
}
pub mod unseal {
tonic::include_proto!("arbiter.user_agent.vault.unseal");
tonic::include_proto!("arbiter.operator.vault.unseal");
}
}
}

View File

@@ -54,11 +54,9 @@
//! as a closed outbound channel.
//! - [`Bi::recv`] returns `None` when the underlying transport closes.
//! - Message translation is intentionally out of scope for this module.
use std::marker::PhantomData;
use async_trait::async_trait;
use kameo::{error::Infallible, prelude::*};
use std::marker::PhantomData;
/// Errors returned by transport adapters implementing [`Bi`].
#[derive(thiserror::Error, Debug)]

View File

@@ -1,10 +1,10 @@
use super::{Bi, Receiver, Sender};
use async_trait::async_trait;
use futures::StreamExt;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use super::{Bi, Receiver, Sender};
pub struct GrpcSender<Outbound> {
tx: mpsc::Sender<Result<Outbound, tonic::Status>>,
}

View File

@@ -1,7 +1,6 @@
use std::fmt::Display;
use base64::{Engine as _, prelude::BASE64_URL_SAFE};
use rustls_pki_types::CertificateDer;
use std::fmt::Display;
const ARBITER_URL_SCHEME: &str = "arbiter";
const CERT_QUERY_KEY: &str = "cert";
@@ -105,7 +104,7 @@ mod tests {
#[rstest]
fn test_parsing_correctness(
fn parsing_correctness(
#[values("127.0.0.1", "localhost", "192.168.1.1", "some.domain.com")] host: &str,
#[values(None, Some("token123".to_string()))] bootstrap_token: Option<String>,

View File

@@ -9,8 +9,8 @@ license = "Apache-2.0"
workspace = true
[dependencies]
diesel = { version = "2.3.7", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] }
diesel-async = { version = "0.8.0", features = [
diesel = { version = "2.3.9", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] }
diesel-async = { version = "0.9.0", features = [
"bb8",
"migrations",
"sqlite",
@@ -18,6 +18,7 @@ diesel-async = { version = "0.8.0", features = [
] }
arbiter-proto.path = "../arbiter-proto"
arbiter-crypto.path = "../arbiter-crypto"
arbiter-macros.path = "../arbiter-macros"
tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tonic.workspace = true
@@ -26,42 +27,40 @@ tokio.workspace = true
rustls.workspace = true
smlang.workspace = true
thiserror.workspace = true
fatality = "0.1.1"
diesel_migrations = { version = "2.3.1", features = ["sqlite"] }
diesel_migrations = { version = "2.3.2", features = ["sqlite"] }
async-trait.workspace = true
secrecy = "0.10.3"
futures.workspace = true
tokio-stream.workspace = true
dashmap = "6.1.0"
rand.workspace = true
rcgen.workspace = true
chrono.workspace = true
zeroize = { version = "1.8.2", features = ["std", "simd"] }
kameo.workspace = true
chacha20poly1305 = { version = "0.10.1", features = ["std"] }
argon2 = { version = "0.5.3", features = ["zeroize"] }
restructed = "0.2.2"
strum = { version = "0.28.0", features = ["derive"] }
strum.workspace = true
pem = "3.0.6"
sha2.workspace = true
hmac = "0.12"
spki.workspace = true
hmac.workspace = true
alloy.workspace = true
prost-types.workspace = true
prost.workspace = true
prost-types.workspace = true
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
anyhow = "1.0.102"
serde_with = "3.18.0"
mutants.workspace = true
subtle = "2.6.1"
ml-dsa.workspace = true
ed25519-dalek.workspace = true
x25519-dalek.workspace = true
k256.workspace = true
kameo_actors.workspace = true
vsss-rs = "5.4.0"
rand_core = "0.6"
[dev-dependencies]
insta = "1.46.3"
proptest = "1.11.0"
rstest.workspace = true
test-log = { version = "0.2", default-features = false, features = ["trace"] }
ml-dsa.workspace = true
mockall = "0.15.0"
tempfile = "3.27.0"
[lib]
doctest = false

View File

@@ -37,19 +37,35 @@ 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
tls_id integer references tls_history (id) on delete RESTRICT,
-- Shamir threshold of the split that produced the stored shares. Null before bootstrap.
-- Recorded rather than recomputed: an aborted operator replacement leaves fewer share
-- rows than the split has shares, and a recomputed threshold would then be wrong.
shamir_threshold integer
) STRICT;
insert into arbiter_settings (id) values (1) on conflict do nothing;
-- ensure singleton row exists
create table if not exists useragent_client (
create table if not exists operator_identity (
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_useragent_client_public_key on useragent_client (public_key);
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 table if not exists client_metadata (
id integer not null primary key,
@@ -97,6 +113,7 @@ create table if not exists evm_wallet_access (
id integer not null primary key,
wallet_id integer not null references evm_wallet (id) on delete cascade,
client_id integer not null references program_client (id) on delete cascade,
revoked_at integer, -- unix timestamp when revoked, null = still active
created_at integer not null default(unixepoch ('now'))
) STRICT;
@@ -204,3 +221,156 @@ create table if not exists integrity_envelope (
) STRICT;
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);
create table if not exists proposal (
id integer not null primary key,
kind text not null,
initiator_id integer not null references operator_identity(id) on delete restrict,
created_at integer not null default(unixepoch('now')),
expires_at integer not null,
status text not null default 'pending'
check (status in ('pending', 'approved', 'rejected'))
) STRICT;
-- Parameters of an approved-or-pending proposal
create table if not exists proposal_approve_sdk_client (
proposal_id integer not null primary key references proposal(id) on delete cascade,
client_id integer not null references program_client(id) on delete restrict
) STRICT;
create table if not exists proposal_grant_wallet_access (
proposal_id integer not null primary key references proposal(id) on delete cascade,
wallet_id integer not null references evm_wallet(id) on delete restrict,
client_id integer not null references program_client(id) on delete restrict
) STRICT;
create table if not exists proposal_replace_operator (
proposal_id integer not null primary key references proposal(id) on delete cascade,
old_operator_id integer not null references operator_identity(id) on delete restrict,
new_pubkey blob not null
) STRICT;
-- The transaction an operator votes to sign.
create table if not exists proposal_one_off_transaction (
proposal_id integer not null primary key references proposal(id) on delete cascade,
client_id integer not null references program_client(id) on delete restrict,
wallet_address blob not null check (length(wallet_address) = 20),
chain_id integer not null,
nonce integer not null,
gas_limit integer not null,
max_fee_per_gas blob not null check (length(max_fee_per_gas) = 16),
max_priority_fee_per_gas blob not null check (length(max_priority_fee_per_gas) = 16),
to_address blob not null check (length(to_address) = 20),
value blob not null check (length(value) = 32),
input blob not null
) STRICT;
-- The grant an operator votes to creat
create table if not exists proposal_persistent_grant (
proposal_id integer not null primary key references proposal (id) on delete cascade,
wallet_access_id integer not null references evm_wallet_access (id) on delete restrict,
chain_id integer not null, -- EIP-155 chain ID
valid_from integer, -- unix timestamp (seconds), null = no lower bound
valid_until integer, -- unix timestamp (seconds), null = no upper bound
max_gas_fee_per_gas blob check (max_gas_fee_per_gas is null or length(max_gas_fee_per_gas) = 32),
max_priority_fee_per_gas blob check (max_priority_fee_per_gas is null or length(max_priority_fee_per_gas) = 32),
rate_limit_count integer, -- max transactions in window, null = unlimited
rate_limit_window_secs integer, -- window duration in seconds, null = unlimited
check ((rate_limit_count is null) = (rate_limit_window_secs is null))
) STRICT;
-- `specific = ether_transfer`
create table if not exists proposal_persistent_grant_ether (
proposal_id integer not null primary key references proposal_persistent_grant (proposal_id) on delete cascade,
window_secs integer not null,
max_volume blob not null check (length(max_volume) = 32)
) STRICT;
create table if not exists proposal_persistent_grant_ether_target (
id integer not null primary key,
proposal_id integer not null references proposal_persistent_grant_ether (proposal_id) on delete cascade,
address blob not null check (length(address) = 20)
) STRICT;
create unique index if not exists uniq_proposal_ether_target on proposal_persistent_grant_ether_target (proposal_id, address);
-- `specific = token_transfer`
create table if not exists proposal_persistent_grant_token (
proposal_id integer not null primary key references proposal_persistent_grant (proposal_id) on delete cascade,
token_contract blob not null check (length(token_contract) = 20),
receiver blob check (receiver is null or length(receiver) = 20)
) STRICT;
create table if not exists proposal_persistent_grant_token_limit (
id integer not null primary key,
proposal_id integer not null references proposal_persistent_grant_token (proposal_id) on delete cascade,
window_secs integer not null,
max_volume blob not null check (length(max_volume) = 32)
) STRICT;
create table if not exists proposal_vote (
id integer not null primary key,
proposal_id integer not null references proposal(id) on delete cascade,
operator_id integer not null references operator_identity(id) on delete restrict,
approve integer not null check (approve in (0, 1)),
signature blob not null,
voted_at integer not null default(unixepoch('now')),
unique (proposal_id, operator_id)
) STRICT;
-- The signature the vault produced for an approved transaction, by component.
--
-- secp256k1 signatures have three common encodings (Electrum v=27/28, raw parity,
-- ERC-2098 compact); a single blob would not say which one it holds. `y_parity` is
-- the raw bit -- add 27 to rebuild the Electrum form `Signature::as_bytes` emits.
create table if not exists proposal_one_off_transaction_result (
proposal_id integer not null primary key
references proposal_one_off_transaction (proposal_id) on delete cascade,
r blob not null check (length(r) = 32),
s blob not null check (length(s) = 32),
y_parity integer not null check (y_parity in (0, 1)),
created_at integer not null default(unixepoch('now'))
) STRICT;
-- ===============================
-- Recovery Operators (§3.4/§3.5/§3.6)
-- ===============================
-- Encrypted Shamir shares for recovery operators (mirrors the `operator` table).
create table if not exists recovery_operator (
id integer not null primary key references recovery_operator_identity(id) on delete restrict,
share blob not null,
share_nonce blob not null,
share_salt blob not null,
created_at integer not null default(unixepoch('now')),
updated_at integer not null default(unixepoch('now'))
) STRICT;
create table if not exists recovery_operator_identity (
id integer not null primary key,
public_key blob not null unique,
created_at integer not null default(unixepoch('now')),
updated_at integer not null default(unixepoch('now'))
) STRICT;
-- One active wakeup request at a time. A request is pending when cancelled_at IS NULL
-- and requested_at + 14 days > now. It becomes active (recovery live) after 14 days.
create table if not exists recovery_wakeup_request (
id integer not null primary key,
requested_by integer not null references operator_identity(id) on delete restrict,
requested_at integer not null default(unixepoch('now')),
cancelled_by integer references operator_identity(id) on delete restrict,
cancelled_at integer
) STRICT;
-- Votes cast by recovery operators; only allowed on replace_operator proposals.
create table if not exists recovery_proposal_vote (
id integer not null primary key,
proposal_id integer not null references proposal(id) on delete cascade,
recovery_operator_id integer not null references recovery_operator_identity(id) on delete restrict,
approve integer not null check (approve in (0, 1)),
signature blob not null,
voted_at integer not null default(unixepoch('now')),
unique (proposal_id, recovery_operator_id)
) STRICT;

View File

@@ -1,41 +1,56 @@
use crate::db::{self, DatabasePool, schema};
use arbiter_proto::{BOOTSTRAP_PATH, home_path};
use diesel::QueryDsl;
use diesel_async::RunQueryDsl;
use kameo::{Actor, messages};
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
use rand::{
distr::{Alphanumeric, SampleString as _},
make_rng,
rngs::StdRng,
};
use std::path::Path;
use subtle::ConstantTimeEq as _;
use thiserror::Error;
use crate::db::{self, DatabasePool, schema};
const TOKEN_LENGTH: usize = 64;
pub async fn generate_token() -> Result<String, std::io::Error> {
let rng: StdRng = make_rng();
pub async fn generate_token(home: &Path) -> Result<String, std::io::Error> {
let mut rng: StdRng = make_rng();
let token: String = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold(
Default::default(),
|mut accum, char| {
accum += char.to_string().as_str();
accum
},
);
// `Alphanumeric` samples raw `u8` ASCII codes, not `char`s -- `SampleString::sample_string`
// is `rand`'s own documented way to turn that into an actual TOKEN_LENGTH-character string
// (see the "Passwords" example on `Alphanumeric`'s docs). A prior version of this function
// called `.to_string()` on the sampled `u8` directly, which stringifies the numeric byte
// value (e.g. `65` instead of `'A'`) rather than the character it represents, silently
// producing a variable-length, all-decimal-digit string instead of a real token.
let token = Alphanumeric.sample_string(&mut rng, TOKEN_LENGTH);
tokio::fs::write(home_path()?.join(BOOTSTRAP_PATH), token.as_str()).await?;
tokio::fs::write(home.join(BOOTSTRAP_PATH), token.as_str()).await?;
Ok(token)
}
/// A token file is only trustworthy if it looks like something `generate_token` could have
/// produced. Anything else -- empty (a crash between the file's truncate and write), foreign
/// content, or a trailing newline added by an editor -- must not be adopted as a live
/// credential: an empty file would make every empty-string token verify, and mismatched
/// content would silently lock out every operator holding the real, already-printed token.
#[must_use]
fn is_valid_token(candidate: &str) -> bool {
candidate.len() == TOKEN_LENGTH && candidate.chars().all(|c| c.is_ascii_alphanumeric())
}
#[derive(Error, Debug)]
pub enum Error {
#[error("Database error: {0}")]
Database(#[from] db::PoolError),
#[error("Database query error: {0}")]
Query(#[from] diesel::result::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Database query error: {0}")]
Query(#[from] diesel::result::Error),
}
#[derive(Actor)]
@@ -44,51 +59,107 @@ pub struct Bootstrapper {
}
impl Bootstrapper {
/// Production constructor: resolves the real `~/.arbiter` directory and delegates.
pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
let row_count: i64 = {
let mut conn = db.get().await?;
let home = home_path()?;
Self::new_in(db, &home).await
}
schema::useragent_client::table
.count()
.get_result(&mut conn)
.await?
/// Carries all of `new`'s logic, parameterized on the directory the token file lives in.
/// `new` resolves the real home directory and calls this; tests call it directly with a
/// throwaway temp directory so they never touch the real `~/.arbiter/bootstrap_token`.
pub async fn new_in(db: &DatabasePool, home: &Path) -> Result<Self, Error> {
let mut conn = db.get().await?;
let bootstrapped: bool = schema::arbiter_settings::table
.select(schema::arbiter_settings::root_key_id)
.first::<Option<i32>>(&mut conn)
.await?
.is_some();
if bootstrapped {
return Ok(Self { token: None });
}
let any_operator_registered: bool = schema::operator_identity::table
.count()
.get_result::<i64>(&mut conn)
.await?
> 0;
if !any_operator_registered {
// Nobody has used the current token yet, so there is nothing to preserve across a
// restart: generate a fresh one, exactly as on a first run. Reusing an old file
// here would let a token survive a database reset, silently reviving trust in
// whoever still held it.
return Ok(Self {
token: Some(generate_token(home).await?),
});
}
// At least one operator has already registered with the current token: every other
// declared operator still needs that same token, including across a restart, so an
// existing file is reused rather than replaced -- but only if it still looks like a
// real token. A truncated, foreign, or corrupted file must not become a live
// credential (see `is_valid_token`).
let path = home.join(BOOTSTRAP_PATH);
let token = match tokio::fs::read_to_string(&path).await {
Ok(existing) if is_valid_token(&existing) => existing,
Ok(_) => {
// Replacing the file invalidates whatever token the already-registered
// operators were handed, so it must not happen quietly. The content itself is
// a credential and stays out of the log; the path is enough to act on.
tracing::warn!(
?path,
"Bootstrap token file is not a well-formed token; replacing it"
);
generate_token(home).await?
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => generate_token(home).await?,
Err(err) => return Err(Error::Io(err)),
};
let token = if row_count == 0 {
let token = generate_token().await?;
Some(token)
} else {
None
};
Ok(Self { token: Some(token) })
}
Ok(Self { token })
/// Drops the token from memory. Called once the vault is bootstrapped: from then on,
/// operators are added through governance rather than through the token.
pub(crate) fn forget_token(&mut self) {
self.token = None;
}
#[must_use]
fn is_correct_token(&self, token: &str) -> bool {
self.token.as_ref().is_some_and(|expected| {
let expected_bytes = expected.as_bytes();
let token_bytes = token.as_bytes();
let choice = expected_bytes.ct_eq(token_bytes);
bool::from(choice)
})
}
}
#[messages]
impl Bootstrapper {
/// Checks the token without retiring it: every operator in a declared committee
/// authenticates with the same token during bootstrap.
#[message]
pub fn is_correct_token(&self, token: String) -> bool {
match &self.token {
Some(expected) => {
let expected_bytes = expected.as_bytes();
let token_bytes = token.as_bytes();
let choice = expected_bytes.ct_eq(token_bytes);
bool::from(choice)
}
None => false,
}
#[must_use]
pub fn verify_token(&self, token: String) -> bool {
self.is_correct_token(&token)
}
}
#[message]
pub fn consume_token(&mut self, token: String) -> bool {
if self.is_correct_token(token) {
self.token = None;
true
} else {
false
}
impl kameo::prelude::Message<crate::actors::vault::events::Bootstrapped> for Bootstrapper {
type Reply = ();
async fn handle(
&mut self,
_msg: crate::actors::vault::events::Bootstrapped,
_ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
) -> Self::Reply {
self.forget_token();
}
}
@@ -99,3 +170,150 @@ impl Bootstrapper {
self.token.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use diesel::{ExpressionMethods as _, insert_into, update};
/// A multi-operator committee registers every member with the same token, so verifying it
/// must not consume it. Only a completed bootstrap retires the token.
#[tokio::test]
async fn token_verifies_repeatedly_until_bootstrap_completes() {
let mut bootstrapper = Bootstrapper {
token: Some("test-token".to_owned()),
};
assert!(bootstrapper.verify_token("test-token".to_owned()));
assert!(bootstrapper.verify_token("test-token".to_owned()));
assert!(!bootstrapper.verify_token("wrong-token".to_owned()));
bootstrapper.forget_token();
assert!(!bootstrapper.verify_token("test-token".to_owned()));
assert!(bootstrapper.get_token().is_none());
}
/// Once the vault is bootstrapped, `Bootstrapper::new_in` must return with no token -- and
/// it must do so from `arbiter_settings.root_key_id` alone, taking the early return before
/// `home` is ever consulted. Uses `new_in` with a throwaway temp directory (never the
/// production `new`, which unconditionally resolves the real home directory before this
/// method even runs) so this test cannot touch the real filesystem regardless of outcome.
#[tokio::test]
async fn new_returns_no_token_once_the_vault_is_bootstrapped() {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
.values(&db::models::NewRootKeyHistory {
ciphertext: vec![0u8; 32],
tag: vec![0u8; 16],
root_key_encryption_nonce: vec![0u8; 24],
data_encryption_nonce: vec![0u8; 24],
schema_version: 1,
salt: vec![0u8; 16],
})
.returning(schema::root_key_history::id)
.get_result(&mut conn)
.await
.unwrap();
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
.execute(&mut conn)
.await
.unwrap();
drop(conn);
let home = tempfile::tempdir().unwrap();
let bootstrapper = Bootstrapper::new_in(&db, home.path()).await.unwrap();
assert!(bootstrapper.get_token().is_none());
}
/// The file-reuse path (an operator has already registered, so bootstrap is unfinished)
/// must not adopt a corrupted token file as a live credential: it must reject it and
/// generate a fresh one instead. This is the Critical from the review, now reachable
/// safely because `new_in` takes a throwaway temp directory instead of the real home.
#[tokio::test]
async fn new_in_rejects_and_replaces_a_corrupted_token_file() {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
// At least one operator must have registered, or `new_in` would regenerate
// unconditionally regardless of the file (Important 2) and never exercise validation.
insert_into(schema::operator_identity::table)
.values(schema::operator_identity::public_key.eq(vec![0u8; 32]))
.execute(&mut conn)
.await
.unwrap();
drop(conn);
let home = tempfile::tempdir().unwrap();
tokio::fs::write(home.path().join(BOOTSTRAP_PATH), "")
.await
.unwrap();
let bootstrapper = Bootstrapper::new_in(&db, home.path()).await.unwrap();
let token = bootstrapper
.get_token()
.expect("a fresh token must be generated");
assert!(is_valid_token(&token));
// The replacement must also have landed on disk, not just in memory, so a restart
// reads back the same (now valid) token rather than the corrupted one again.
let on_disk = tokio::fs::read_to_string(home.path().join(BOOTSTRAP_PATH))
.await
.unwrap();
assert_eq!(on_disk, token);
}
/// The second defect the task exists to fix: a restart between the first registration and
/// the completed bootstrap must not invalidate the token the other declared operators were
/// already given. With an operator registered and a well-formed file on disk, `new_in` has
/// to hand back exactly what it read instead of generating a replacement.
#[tokio::test]
async fn new_in_reuses_a_valid_token_file_across_a_restart() {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
// Without a registered operator, `new_in` regenerates unconditionally (Important 2 of
// the round-2 review) and never reaches the reuse path this test is about.
insert_into(schema::operator_identity::table)
.values(schema::operator_identity::public_key.eq(vec![0u8; 32]))
.execute(&mut conn)
.await
.unwrap();
drop(conn);
let home = tempfile::tempdir().unwrap();
// Stands in for the token a previous run wrote and printed to the console.
let handed_out = "Zq7Z2rXaB90kLmNpQwErTyUiOpAsDfGhJkLzXcVbNmQwErTyUiOpAsDfGhJkLzXc";
assert!(is_valid_token(handed_out));
tokio::fs::write(home.path().join(BOOTSTRAP_PATH), handed_out)
.await
.unwrap();
let bootstrapper = Bootstrapper::new_in(&db, home.path()).await.unwrap();
assert_eq!(bootstrapper.get_token().as_deref(), Some(handed_out));
}
/// An empty file must not be adopted as a live credential: `is_correct_token`'s
/// `is_some_and` would enter its closure for `Some(String::new())`, and comparing two empty
/// byte slices is true, so an unauthenticated `Some("")` from the wire would otherwise
/// verify. Also pins the other corrupted-content shapes `is_valid_token` must reject.
#[test]
fn is_valid_token_rejects_anything_that_is_not_a_real_token() {
assert!(!is_valid_token(""));
assert!(!is_valid_token("too-short"));
assert!(!is_valid_token(&"a".repeat(TOKEN_LENGTH - 1)));
assert!(!is_valid_token(&"a".repeat(TOKEN_LENGTH + 1)));
// A trailing newline (e.g. from an editor) must not be silently accepted either.
assert!(!is_valid_token(&format!("{}\n", "a".repeat(TOKEN_LENGTH))));
assert!(!is_valid_token(&"!".repeat(TOKEN_LENGTH)));
assert!(is_valid_token(&"a".repeat(TOKEN_LENGTH)));
}
}

View File

@@ -1,17 +1,13 @@
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use diesel::{
ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into,
};
use diesel_async::RunQueryDsl;
use kameo::{Actor, actor::ActorRef, messages};
use rand::{SeedableRng, rng, rngs::StdRng};
use crate::{
actors::vault::{CreateNew, Decrypt, Vault},
actors::{
proposal_manager::events::ProposalApproved,
vault::{CreateNew, Decrypt, Vault},
},
crypto::integrity,
db::{
DatabaseError, DatabasePool,
models::{self},
models::{self, EvmWalletId, ProposalId},
proposal::{ProposalKind, grant_wallet_access, one_off_transaction, persistent_grant},
schema,
},
evm::{
@@ -24,6 +20,17 @@ use crate::{
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use alloy::{
consensus::TxEip1559, network::TxSignerSync as _, primitives::Address, signers::Signature,
};
use diesel::{
ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into,
};
use diesel_async::RunQueryDsl;
use kameo::{Actor, actor::ActorRef, messages, prelude::Message};
use rand::{SeedableRng, rng, rngs::StdRng};
use tracing::error;
pub use crate::evm::safe_signer;
#[derive(Debug, thiserror::Error)]
@@ -60,6 +67,55 @@ pub enum Error {
#[error("Integrity violation: {0}")]
Integrity(#[from] integrity::Error),
#[error("Signing error: {0}")]
Sign(#[from] SignTransactionError),
#[error(
"Grant timestamp {0} is outside the i32 range a grant boundary column can store \
(Unix seconds, so no later than 2038-01-19T03:14:07Z)"
)]
InvalidTimestamp(i64),
#[error("Wallet access {0} is revoked or does not exist")]
AccessNotActive(i32),
}
/// Converts a grant boundary from Unix seconds. `None` in means "unbounded"; a value the
/// boundary column cannot store is an error, never a silently different window.
///
/// The range is `i32`, not `i64`, because that is what actually reaches the database:
/// `SqliteTimestamp::to_sql` narrows to `i32` (`fixme! #84`), so `3_000_000_000` -- a
/// `valid_from` in 2065 -- would wrap to 1902 and open the grant immediately instead of in
/// forty years. Accepting only what round-trips keeps the grant that gets written the grant
/// that was voted on.
fn grant_timestamp(secs: Option<i64>) -> Result<Option<chrono::DateTime<chrono::Utc>>, Error> {
secs.map(|s| {
let storable = i32::try_from(s).map_err(|_| Error::InvalidTimestamp(s))?;
chrono::DateTime::from_timestamp(i64::from(storable), 0).ok_or(Error::InvalidTimestamp(s))
})
.transpose()
}
/// Refuses an access id that is revoked or absent, so nothing hangs a grant off it.
async fn ensure_access_active(
conn: &mut crate::db::DatabaseConnection,
access_id: i32,
) -> Result<(), Error> {
let active: bool = diesel::select(diesel::dsl::exists(
schema::evm_wallet_access::table
.filter(schema::evm_wallet_access::id.eq(access_id))
.filter(schema::evm_wallet_access::revoked_at.is_null()),
))
.get_result(conn)
.await
.map_err(DatabaseError::from)?;
if active {
Ok(())
} else {
Err(Error::AccessNotActive(access_id))
}
}
#[derive(Actor)]
@@ -114,7 +170,7 @@ impl EvmActor {
}
#[message]
pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
pub async fn list_wallets(&self) -> Result<Vec<(EvmWalletId, 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())
@@ -132,7 +188,7 @@ impl EvmActor {
#[messages]
impl EvmActor {
#[message]
pub async fn useragent_create_grant(
pub async fn operator_create_grant(
&mut self,
basic: SharedGrantSettings,
grant: SpecificGrant,
@@ -158,32 +214,27 @@ impl EvmActor {
}
#[message]
pub async fn useragent_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> {
// let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
// let vault = self.vault.clone();
pub async fn operator_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
// diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
// Box::pin(async move {
// diesel::update(schema::evm_basic_grant::table)
// .filter(schema::evm_basic_grant::id.eq(grant_id))
// .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
// .execute(conn)
// .await?;
let affected = diesel::update(schema::evm_basic_grant::table)
.filter(schema::evm_basic_grant::id.eq(grant_id))
.set(schema::evm_basic_grant::revoked_at.eq(models::SqliteTimestamp::now()))
.execute(&mut conn)
.await
.map_err(DatabaseError::from)?;
// let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?;
if affected == 0 {
return Err(Error::Database(DatabaseError::from(
diesel::result::Error::NotFound,
)));
}
// diesel::result::QueryResult::Ok(())
// })
// })
// .await
// .map_err(DatabaseError::from)?;
// Ok(())
todo!()
Ok(())
}
#[message]
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
pub async fn operator_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.engine.list_all_grants().await {
Ok(grants) => Ok(grants),
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),
@@ -211,6 +262,7 @@ impl EvmActor {
.select(models::EvmWalletAccess::as_select())
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
.filter(schema::evm_wallet_access::client_id.eq(client_id))
.filter(schema::evm_wallet_access::revoked_at.is_null())
.first(&mut conn)
.await
.optional()
@@ -246,6 +298,7 @@ impl EvmActor {
.select(models::EvmWalletAccess::as_select())
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
.filter(schema::evm_wallet_access::client_id.eq(client_id))
.filter(schema::evm_wallet_access::revoked_at.is_null())
.first(&mut conn)
.await
.optional()
@@ -267,7 +320,375 @@ impl EvmActor {
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.await?;
use alloy::network::TxSignerSync as _;
Ok(signer.sign_transaction_sync(&mut transaction)?)
}
}
impl Message<ProposalApproved> for EvmActor {
type Reply = ();
/// Every subscriber sees every approval and acts only on the kinds it owns.
async fn handle(
&mut self,
msg: ProposalApproved,
_ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
) -> Self::Reply {
let result = match msg.kind {
ProposalKind::GrantWalletAccess(settings) => self.grant_wallet_access(&settings).await,
ProposalKind::ApprovePersistentGrant(settings) => {
self.create_persistent_grant(*settings).await
}
ProposalKind::ApproveOneOffTransaction(settings) => {
self.sign_one_off_transaction(msg.id, *settings).await
}
_ => return,
};
if let Err(error) = result {
error!(
?error,
proposal_id = msg.id.to_raw(),
"Failed to execute an approved proposal"
);
}
}
}
impl EvmActor {
async fn grant_wallet_access(
&mut self,
settings: &grant_wallet_access::Settings,
) -> Result<(), Error> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
// Revives a previously revoked row instead of conflicting on it forever:
// `uniq_wallet_access` is a unique index on `(wallet_id, client_id)`. Visibility is
// all this restores -- revocation closes the grants that hung off the access, so a
// persistent grant needs its own vote again (§3.2). See
// `peers::operator::session::handlers::revoke_wallet_access`.
insert_into(schema::evm_wallet_access::table)
.values((
schema::evm_wallet_access::wallet_id.eq(EvmWalletId::from_raw(settings.wallet_id)),
schema::evm_wallet_access::client_id.eq(settings.client_id),
))
.on_conflict((
schema::evm_wallet_access::wallet_id,
schema::evm_wallet_access::client_id,
))
.do_update()
.set(schema::evm_wallet_access::revoked_at.eq(None::<models::SqliteTimestamp>))
.execute(&mut conn)
.await
.map_err(DatabaseError::from)?;
Ok(())
}
async fn create_persistent_grant(
&mut self,
grant: persistent_grant::Settings,
) -> Result<(), Error> {
use crate::evm::policies::{
TransactionRateLimit, VolumeRateLimit, ether_transfer, token_transfers,
};
use alloy::primitives::U256;
use chrono::Duration;
// A persistent grant is only as good as the visibility it hangs off (§3.2, two
// separate votes). The proposal names the access id when it is created and can be
// approved much later, so the access may have been revoked in between; a grant
// against a revoked access would sit dormant and go live the moment anyone re-grants.
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
ensure_access_active(&mut conn, grant.wallet_access_id).await?;
drop(conn);
let volume = |limit: persistent_grant::VolumeLimit| VolumeRateLimit {
max_volume: U256::from_be_bytes(limit.max_volume),
window: Duration::seconds(limit.window_secs),
};
let basic = SharedGrantSettings {
wallet_access_id: grant.wallet_access_id,
chain: grant.chain_id,
valid_from: grant_timestamp(grant.valid_from_secs)?,
valid_until: grant_timestamp(grant.valid_until_secs)?,
max_gas_fee_per_gas: grant.max_gas_fee_per_gas.map(U256::from_be_bytes),
max_priority_fee_per_gas: grant.max_priority_fee_per_gas.map(U256::from_be_bytes),
rate_limit: grant.rate_limit.map(|r| TransactionRateLimit {
count: r.count,
window: Duration::seconds(r.window_secs),
}),
};
let specific = match grant.specific {
persistent_grant::Specific::EtherTransfer { targets, limit } => {
SpecificGrant::EtherTransfer(ether_transfer::Settings {
target: targets.into_iter().map(Address::from).collect(),
limit: volume(limit),
})
}
persistent_grant::Specific::TokenTransfer {
token_contract,
receiver,
volume_limits,
} => SpecificGrant::TokenTransfer(token_transfers::Settings {
token_contract: Address::from(token_contract),
target: receiver.map(Address::from),
volume_limits: volume_limits.into_iter().map(volume).collect(),
}),
};
self.operator_create_grant(basic, specific).await?;
Ok(())
}
async fn sign_one_off_transaction(
&mut self,
proposal_id: ProposalId,
tx: one_off_transaction::Settings,
) -> Result<(), Error> {
use alloy::{
eips::eip2930::AccessList,
primitives::{Bytes, TxKind, U256},
};
let transaction = TxEip1559 {
chain_id: tx.chain_id,
nonce: tx.nonce,
gas_limit: tx.gas_limit,
max_fee_per_gas: tx.max_fee_per_gas,
max_priority_fee_per_gas: tx.max_priority_fee_per_gas,
to: TxKind::Call(Address::from(tx.to)),
value: U256::from_be_bytes(tx.value),
input: Bytes::from(tx.input),
access_list: AccessList::default(),
};
let signature = self
.client_sign_transaction(tx.client_id, Address::from(tx.wallet_address), transaction)
.await?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
one_off_transaction::store_signature(proposal_id, &signature, &mut conn)
.await
.map_err(DatabaseError::from)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{Error, EvmActor, ensure_access_active, grant_timestamp};
use crate::db::{self, models, schema};
use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into};
use diesel_async::RunQueryDsl;
#[test]
fn absent_timestamp_stays_absent() {
assert!(grant_timestamp(None).unwrap().is_none());
}
#[test]
fn in_range_timestamp_is_converted() {
let converted = grant_timestamp(Some(1_800_000_000)).unwrap();
assert_eq!(converted.unwrap().timestamp(), 1_800_000_000);
}
/// An unrepresentable expiry must not silently become "no expiry": that would widen the
/// grant beyond what was voted on.
#[test]
fn out_of_range_timestamp_is_an_error() {
let err = grant_timestamp(Some(i64::MAX)).unwrap_err();
assert!(matches!(err, Error::InvalidTimestamp(i64::MAX)));
}
/// A `valid_from` past 2038 is representable as a `DateTime` but not as the `i32` the
/// boundary column stores: `3_000_000_000` (2065) wraps to a negative, which reads back as
/// 1902 and makes the grant active immediately. Refusing it is the only way the grant
/// that lands can match the window that was voted on.
#[test]
fn a_timestamp_past_2038_is_an_error() {
let past_2038 = 3_000_000_000_i64;
assert!(
chrono::DateTime::from_timestamp(past_2038, 0).is_some(),
"the fixture must be a date chrono accepts, or it proves nothing about storage"
);
let err = grant_timestamp(Some(past_2038)).unwrap_err();
assert!(
matches!(err, Error::InvalidTimestamp(got) if got == past_2038),
"expected an out-of-range error, got {err:?}"
);
}
/// The last second the boundary column can hold must still be accepted: the range check
/// has to stop at what storage can take, not short of it.
#[test]
fn the_last_storable_timestamp_is_accepted() {
let converted = grant_timestamp(Some(i64::from(i32::MAX))).unwrap();
assert_eq!(converted.unwrap().timestamp(), i64::from(i32::MAX));
}
/// Seeds a wallet, a client and one access row between them, and returns the access id.
async fn seed_access(conn: &mut db::DatabaseConnection) -> i32 {
let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: vec![0u8; 32],
tag: vec![0u8; 16],
root_key_encryption_nonce: vec![0u8; 24],
data_encryption_nonce: vec![0u8; 24],
schema_version: 1,
salt: vec![0u8; 16],
})
.returning(schema::root_key_history::id)
.get_result(conn)
.await
.unwrap();
let aead_id: i32 = insert_into(schema::aead_encrypted::table)
.values(&models::NewAeadEncrypted {
ciphertext: vec![0u8; 32],
tag: vec![0u8; 16],
current_nonce: vec![0u8; 24],
schema_version: 1,
associated_root_key_id: root_key_id,
created_at: chrono::Utc::now().into(),
})
.returning(schema::aead_encrypted::id)
.get_result(conn)
.await
.unwrap();
let wallet_id: models::EvmWalletId = insert_into(schema::evm_wallet::table)
.values((
schema::evm_wallet::address.eq(rand::random::<[u8; 20]>().to_vec()),
schema::evm_wallet::aead_encrypted_id.eq(aead_id),
))
.returning(schema::evm_wallet::id)
.get_result(conn)
.await
.unwrap();
let metadata_id: i32 = insert_into(schema::client_metadata::table)
.values(schema::client_metadata::name.eq("test"))
.returning(schema::client_metadata::id)
.get_result(conn)
.await
.unwrap();
let client_id: i32 = insert_into(schema::program_client::table)
.values((
schema::program_client::public_key.eq(rand::random::<[u8; 32]>().to_vec()),
schema::program_client::metadata_id.eq(metadata_id),
))
.returning(schema::program_client::id)
.get_result(conn)
.await
.unwrap();
insert_into(schema::evm_wallet_access::table)
.values((
schema::evm_wallet_access::wallet_id.eq(wallet_id),
schema::evm_wallet_access::client_id.eq(client_id),
))
.returning(schema::evm_wallet_access::id)
.get_result(conn)
.await
.unwrap()
}
/// Both directions, so a guard that refused everything could not pass: a live access is
/// let through, a revoked one is not.
#[tokio::test]
async fn only_a_live_access_passes_the_grant_guard() {
let pool = db::create_test_pool().await;
let mut conn = pool.get().await.unwrap();
let access_id = seed_access(&mut conn).await;
ensure_access_active(&mut conn, access_id)
.await
.expect("a live access must pass");
diesel::update(schema::evm_wallet_access::table)
.filter(schema::evm_wallet_access::id.eq(access_id))
.set(schema::evm_wallet_access::revoked_at.eq(models::SqliteTimestamp::now()))
.execute(&mut conn)
.await
.unwrap();
let err = ensure_access_active(&mut conn, access_id)
.await
.expect_err("a revoked access must be refused");
assert!(
matches!(err, Error::AccessNotActive(got) if got == access_id),
"expected AccessNotActive, got {err:?}"
);
}
/// The guard has to be wired into the executor, not just exist: an approved persistent
/// grant whose access was revoked between proposal and approval must not create a grant
/// that would go live again the moment anyone re-grants that access (§3.2).
#[tokio::test]
async fn an_approved_persistent_grant_refuses_a_revoked_access() {
use crate::actors::{GlobalActors, vault::Vault};
use crate::db::proposal::persistent_grant;
use kameo::actor::Spawn as _;
let pool = db::create_test_pool().await;
let mut conn = pool.get().await.unwrap();
let access_id = seed_access(&mut conn).await;
diesel::update(schema::evm_wallet_access::table)
.filter(schema::evm_wallet_access::id.eq(access_id))
.set(schema::evm_wallet_access::revoked_at.eq(models::SqliteTimestamp::now()))
.execute(&mut conn)
.await
.unwrap();
drop(conn);
let vault = Vault::spawn(
Vault::new(pool.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap(),
);
let mut evm_actor = EvmActor::new(vault, pool.clone());
let err = evm_actor
.create_persistent_grant(persistent_grant::Settings {
wallet_access_id: access_id,
chain_id: 1,
valid_from_secs: None,
valid_until_secs: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
specific: persistent_grant::Specific::EtherTransfer {
targets: vec![[0u8; 20]],
limit: persistent_grant::VolumeLimit {
max_volume: [0u8; 32],
window_secs: 3600,
},
},
})
.await
.expect_err("a grant against a revoked access must be refused");
assert!(
matches!(err, Error::AccessNotActive(got) if got == access_id),
"expected AccessNotActive, got {err:?}"
);
let grants: i64 = schema::evm_basic_grant::table
.filter(schema::evm_basic_grant::wallet_access_id.eq(access_id))
.count()
.get_result(&mut pool.get().await.unwrap())
.await
.unwrap();
assert_eq!(
grants, 0,
"no grant row may be written for a revoked access"
);
}
}

View File

@@ -1,27 +1,26 @@
use std::ops::ControlFlow;
use crate::{
actors::flow_coordinator::ApprovalError,
peers::{
client::ClientProfile,
operator::{OperatorSession, session::BeginNewClientApproval},
},
};
use kameo::{
Actor, messages,
prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef},
reply::ReplySender,
};
use crate::{
actors::flow_coordinator::ApprovalError,
peers::{
client::ClientProfile,
user_agent::{UserAgentSession, session::BeginNewClientApproval},
},
};
use std::ops::ControlFlow;
pub struct Args {
pub client: ClientProfile,
pub user_agents: Vec<ActorRef<UserAgentSession>>,
pub operators: Vec<ActorRef<OperatorSession>>,
pub reply: ReplySender<Result<bool, ApprovalError>>,
}
pub struct ClientApprovalController {
/// Number of UAs that have not yet responded (approval or denial) or died.
/// Number of operators that have not yet responded (approval or denial) or died.
pending: usize,
/// Number of approvals received so far.
approved: usize,
@@ -43,20 +42,21 @@ impl Actor for ClientApprovalController {
async fn on_start(
Args {
client,
mut user_agents,
operators,
reply,
}: Self::Args,
actor_ref: ActorRef<Self>,
) -> Result<Self, Self::Error> {
let this = Self {
pending: user_agents.len(),
pending: operators.len(),
approved: 0,
reply: Some(reply),
};
for user_agent in user_agents.drain(..) {
actor_ref.link(&user_agent).await;
let _ = user_agent
for operator in operators {
actor_ref.link(&operator).await;
let _ = operator
.tell(BeginNewClientApproval {
client: client.clone(),
controller: actor_ref.clone(),
@@ -73,10 +73,10 @@ impl Actor for ClientApprovalController {
_: ActorId,
_: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
// A linked UA died before responding — counts as a non-approval.
// A linked operator died before responding — counts as a non-approval.
self.pending = self.pending.saturating_sub(1);
if self.pending == 0 {
// At least one UA didn't approve: deny.
// At least one operator didn't approve: deny.
self.send_reply(Ok(false));
return Ok(ControlFlow::Break(ActorStopReason::Normal));
}
@@ -87,7 +87,7 @@ impl Actor for ClientApprovalController {
#[messages]
impl ClientApprovalController {
#[message(ctx)]
pub async fn client_approval_answer(&mut self, approved: bool, ctx: &mut Context<Self, ()>) {
pub fn client_approval_answer(&mut self, approved: bool, ctx: &mut Context<Self, ()>) {
if !approved {
// Denial wins immediately regardless of other pending responses.
self.send_reply(Ok(false));
@@ -99,7 +99,7 @@ impl ClientApprovalController {
self.pending = self.pending.saturating_sub(1);
if self.pending == 0 {
// Every connected UA approved.
// Every connected operator approved.
self.send_reply(Ok(true));
ctx.stop();
}

View File

@@ -1,4 +1,10 @@
use std::{collections::HashMap, ops::ControlFlow};
use crate::{
actors::{
flow_coordinator::client_connect_approval::ClientApprovalController,
operator_registry::{GetConnected, OperatorRegistry},
},
peers::client::{ClientProfile, session::ClientSession},
};
use kameo::{
Actor,
@@ -7,28 +13,21 @@ use kameo::{
prelude::{ActorStopReason, Context, WeakActorRef},
reply::DelegatedReply,
};
use std::{collections::HashMap, ops::ControlFlow};
use tracing::info;
use crate::{
actors::{
flow_coordinator::client_connect_approval::ClientApprovalController,
useragent_registry::{GetConnected, UserAgentRegistry},
},
peers::client::{ClientProfile, session::ClientSession},
};
pub mod client_connect_approval;
pub struct FlowCoordinator {
pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
useragent_registry: ActorRef<UserAgentRegistry>,
operator_registry: ActorRef<OperatorRegistry>,
}
impl FlowCoordinator {
pub fn new(useragent_registry: ActorRef<UserAgentRegistry>) -> Self {
pub fn new(operator_registry: ActorRef<OperatorRegistry>) -> Self {
Self {
clients: HashMap::default(),
useragent_registry,
operator_registry,
}
}
}
@@ -67,8 +66,8 @@ impl Actor for FlowCoordinator {
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)]
pub enum ApprovalError {
#[error("No user agents connected")]
NoUserAgentsConnected,
#[error("No operators connected")]
NoOperatorsConnected,
}
#[messages]
@@ -94,22 +93,19 @@ impl FlowCoordinator {
unreachable!("Expected `request_client_approval` to have callback channel");
};
let refs = match self.useragent_registry.ask(GetConnected).await {
Ok(refs) => refs,
Err(_) => {
reply_sender.send(Err(ApprovalError::NoUserAgentsConnected));
return reply;
}
let Ok(refs) = self.operator_registry.ask(GetConnected).await else {
reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
return reply;
};
if refs.is_empty() {
reply_sender.send(Err(ApprovalError::NoUserAgentsConnected));
reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
return reply;
}
ClientApprovalController::spawn(client_connect_approval::Args {
client,
user_agents: refs,
operators: refs,
reply: reply_sender,
});

View File

@@ -1,23 +1,31 @@
use kameo::actor::{ActorRef, Spawn};
use kameo_actors::{DeliveryStrategy, message_bus::MessageBus};
use thiserror::Error;
use crate::{
actors::{
bootstrap::Bootstrapper,
evm::EvmActor,
flow_coordinator::FlowCoordinator,
useragent_registry::UserAgentRegistry,
vault::Vault,
operator_registry::OperatorRegistry,
proposal_manager::{ProposalManager, events::ProposalApproved},
vault::{Vault, events},
vault_coordinator::VaultCoordinator,
},
db,
};
use kameo::actor::{ActorRef, Spawn};
use kameo_actors::{
DeliveryStrategy,
message_bus::{MessageBus, Register},
};
use thiserror::Error;
use tracing::error;
pub mod bootstrap;
pub mod evm;
pub mod flow_coordinator;
pub mod operator_registry;
pub mod proposal_manager;
pub mod vault;
pub mod useragent_registry;
pub mod vault_coordinator;
#[derive(Error, Debug)]
pub enum SpawnError {
@@ -33,9 +41,11 @@ pub enum SpawnError {
pub struct GlobalActors {
pub vault: ActorRef<Vault>,
pub bootstrapper: ActorRef<Bootstrapper>,
pub vault_coordinator: ActorRef<VaultCoordinator>,
pub flow_coordinator: ActorRef<FlowCoordinator>,
pub useragent_registry: ActorRef<UserAgentRegistry>,
pub operator_registry: ActorRef<OperatorRegistry>,
pub evm: ActorRef<EvmActor>,
pub proposal_manager: ActorRef<ProposalManager>,
pub events: ActorRef<MessageBus>,
}
@@ -45,18 +55,69 @@ impl GlobalActors {
}
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
let bootstrapper = Bootstrapper::new(&db).await?;
Self::spawn_with_bootstrapper(db, bootstrapper).await
}
/// Test-facing: threads an explicit directory through to `Bootstrapper` instead of letting
/// it resolve the real home directory, so a test spawning a full `GlobalActors` can never
/// reach (let alone write to) the real `~/.arbiter/bootstrap_token`. Mirrors `spawn`
/// exactly, aside from where the token file lives.
pub async fn spawn_in(
db: db::DatabasePool,
home: &std::path::Path,
) -> Result<Self, SpawnError> {
let bootstrapper = Bootstrapper::new_in(&db, home).await?;
Self::spawn_with_bootstrapper(db, bootstrapper).await
}
async fn spawn_with_bootstrapper(
db: db::DatabasePool,
bootstrapper: Bootstrapper,
) -> Result<Self, SpawnError> {
let message_bus = Self::spawn_message_bus();
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
let useragent_registry = UserAgentRegistry::spawn(UserAgentRegistry::default());
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
let evm = EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone()));
let vault_coordinator = VaultCoordinator::spawn(VaultCoordinator::new(
db.clone(),
key_holder.clone(),
));
let bootstrapper = Bootstrapper::spawn(bootstrapper);
// Approved proposals are executed by whoever owns the kind, not by ProposalManager.
for recipient in [
evm.clone().recipient::<ProposalApproved>(),
vault_coordinator.clone().recipient::<ProposalApproved>(),
key_holder.clone().recipient::<ProposalApproved>(),
] {
let _ = message_bus.tell(Register(recipient)).await;
}
// The token guards bootstrap only: once the vault reports success, it must be retired.
// A dropped registration would leave the token valid forever with nothing else to
// notice, so a failure here is logged rather than silently discarded.
if let Err(err) = message_bus
.tell(Register(
bootstrapper.clone().recipient::<events::Bootstrapped>(),
))
.await
{
error!(
?err,
"Failed to register Bootstrapper for the Bootstrapped event"
);
}
Ok(Self {
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)),
bootstrapper,
proposal_manager: ProposalManager::spawn(ProposalManager::new(db, message_bus.clone())),
vault: key_holder,
vault_coordinator,
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
useragent_registry.clone(),
operator_registry.clone(),
)),
useragent_registry,
operator_registry,
events: message_bus,
evm,
})
}
}

View File

@@ -1,4 +1,4 @@
use std::{collections::HashMap, ops::ControlFlow};
use crate::peers::operator::OperatorSession;
use kameo::{
Actor,
@@ -7,16 +7,15 @@ use kameo::{
messages,
prelude::{ActorStopReason, Context, WeakActorRef},
};
use std::{collections::HashMap, ops::ControlFlow};
use tracing::info;
use crate::peers::user_agent::UserAgentSession;
#[derive(Default)]
pub struct UserAgentRegistry {
connected: HashMap<ActorId, ActorRef<UserAgentSession>>,
pub struct OperatorRegistry {
connected: HashMap<ActorId, ActorRef<OperatorSession>>,
}
impl Actor for UserAgentRegistry {
impl Actor for OperatorRegistry {
type Args = Self;
type Error = Infallible;
@@ -32,27 +31,31 @@ impl Actor for UserAgentRegistry {
_: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
if self.connected.remove(&id).is_some() {
info!(?id, actor = "UserAgentRegistry", event = "useragent.disconnected");
info!(
?id,
actor = "OperatorRegistry",
event = "operator.disconnected"
);
}
Ok(ControlFlow::Continue(()))
}
}
#[messages]
impl UserAgentRegistry {
impl OperatorRegistry {
#[message(ctx)]
pub async fn connect_useragent(
pub async fn connect_operator(
&mut self,
actor: ActorRef<UserAgentSession>,
actor: ActorRef<OperatorSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), actor = "UserAgentRegistry", event = "useragent.connected");
info!(id = %actor.id(), actor = "OperatorRegistry", event = "operator.connected");
ctx.actor_ref().link(&actor).await;
self.connected.insert(actor.id(), actor);
}
#[message]
pub fn get_connected(&self) -> Vec<ActorRef<UserAgentSession>> {
pub fn get_connected(&self) -> Vec<ActorRef<OperatorSession>> {
self.connected.values().cloned().collect()
}
}

View File

@@ -0,0 +1,355 @@
use crate::{
actors::proposal_manager::{
events::ProposalApproved,
store::{DieselProposalStore, ProposalStore, Tally},
},
crypto::governance,
db::{
self,
models::{
NewProposalVote, NewRecoveryProposalVote, OperatorIdentityId, Proposal, ProposalId,
ProposalStatus, RecoveryOperatorIdentityId, SqliteTimestamp,
},
proposal::{ProposalKind, ProposalKindTag},
},
};
use chrono::Utc;
use kameo::{Actor, actor::ActorRef, messages};
use kameo_actors::message_bus::{MessageBus, Publish};
use std::sync::Arc;
use tracing::warn;
pub mod events;
pub mod store;
pub const DEFAULT_TTL_SECS: u32 = 7 * 24 * 60 * 60; // 7 days
pub const MAX_TTL_SECS: u32 = DEFAULT_TTL_SECS;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VoteOutcome {
Pending,
Approved,
Rejected,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Proposal not found")]
ProposalNotFound,
#[error("Proposal is not pending")]
ProposalNotPending,
#[error("Proposal has expired")]
ProposalExpired,
#[error("Requested TTL exceeds the maximum of {} seconds", MAX_TTL_SECS)]
TtlTooLong,
#[error("Operator already voted on this proposal")]
AlreadyVoted,
#[error("Invalid vote signature")]
InvalidSignature,
#[error("Operator not found")]
OperatorNotFound,
#[error("Database connection error: {0}")]
DatabaseConnection(#[from] db::PoolError),
#[error("Database query error: {0}")]
DatabaseQuery(#[from] diesel::result::Error),
#[error("Proposal manager is unavailable")]
Unavailable,
#[error("Recovery operators are sleeping")]
RecoveryNotActive,
#[error("Recovery operators may only vote on operator replacement")]
NotAllowedForRecoveryOperator,
#[error("A recovery wake-up is already pending or active")]
WakeupAlreadyPending,
#[error("No active recovery wake-up to cancel")]
NoActiveWakeup,
}
#[derive(Debug)]
pub struct ProposalSummary {
pub id: ProposalId,
pub kind: ProposalKindTag,
pub initiator_id: OperatorIdentityId,
pub expires_at: SqliteTimestamp,
pub approve_count: i64,
pub reject_count: i64,
}
#[derive(Actor)]
pub struct ProposalManager {
pub(crate) store: Arc<dyn ProposalStore>,
pub(crate) events: ActorRef<MessageBus>,
}
impl ProposalManager {
pub fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Self {
Self::with_store(Arc::new(DieselProposalStore::new(db)), events)
}
/// Builds the actor over an arbitrary store, so tests can supply a mock.
pub(crate) const fn with_store(
store: Arc<dyn ProposalStore>,
events: ActorRef<MessageBus>,
) -> Self {
Self { store, events }
}
}
#[messages]
impl ProposalManager {
#[message]
pub async fn create_proposal(
&mut self,
kind: ProposalKind,
initiator_id: OperatorIdentityId,
ttl_secs: Option<u32>,
) -> Result<ProposalId, Error> {
let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
if ttl > MAX_TTL_SECS {
return Err(Error::TtlTooLong);
}
let expires_at =
SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(i64::from(ttl)));
self.store.create(kind, initiator_id, expires_at).await
}
#[message]
pub async fn query_pending(&mut self, operator_id: OperatorIdentityId) -> Vec<ProposalSummary> {
self.store
.pending_for(operator_id)
.await
.unwrap_or_else(|e| {
warn!(?e, "query_pending failed");
vec![]
})
}
#[message]
pub async fn cast_vote(
&mut self,
proposal_id: ProposalId,
operator_id: OperatorIdentityId,
approve: bool,
signature: Vec<u8>,
) -> Result<VoteOutcome, Error> {
let proposal = self.store.load(proposal_id).await?;
// Checked before the status check so AlreadyVoted takes priority.
if self.store.has_voted(proposal_id, operator_id).await? {
return Err(Error::AlreadyVoted);
}
Self::check_votable(&proposal)?;
let public_key = self.store.operator_public_key(operator_id).await?;
governance::verify_vote(&public_key, proposal_id, approve, &signature)
.map_err(|_| Error::InvalidSignature)?;
self.store
.record_vote(NewProposalVote {
proposal_id,
operator_id,
approve,
signature,
})
.await?;
let mut tally = self.store.tally(proposal_id).await?;
self.narrow_electorate(&proposal, &mut tally).await?;
self.settle(&proposal, &tally).await
}
/// §3.6: Any ordinary operator may request recovery wake-up.
/// Fails if a wake-up is already pending or active.
#[message]
pub async fn request_recovery_wakeup(
&mut self,
operator_id: OperatorIdentityId,
) -> Result<(), Error> {
if self.store.has_uncancelled_wakeup().await? {
return Err(Error::WakeupAlreadyPending);
}
self.store.request_wakeup(operator_id).await
}
/// §3.6: Any ordinary operator may cancel a pending wake-up request.
/// Fails if there is no uncancelled request.
#[message]
pub async fn cancel_recovery_wakeup(
&mut self,
operator_id: OperatorIdentityId,
) -> Result<(), Error> {
if self.store.cancel_wakeup(operator_id).await? {
Ok(())
} else {
Err(Error::NoActiveWakeup)
}
}
/// §3.5: Recovery operators may only vote on operator replacement proposals.
/// §3.6: Voting is gated behind recovery being active (14-day window elapsed).
#[message]
pub async fn cast_recovery_vote(
&mut self,
proposal_id: ProposalId,
recovery_operator_id: RecoveryOperatorIdentityId,
approve: bool,
signature: Vec<u8>,
) -> Result<VoteOutcome, Error> {
let proposal = self.store.load(proposal_id).await?;
if proposal.kind != ProposalKindTag::ReplaceOperator {
return Err(Error::NotAllowedForRecoveryOperator);
}
if !self.store.is_recovery_active().await? {
return Err(Error::RecoveryNotActive);
}
if self
.store
.has_recovery_voted(proposal_id, recovery_operator_id)
.await?
{
return Err(Error::AlreadyVoted);
}
Self::check_votable(&proposal)?;
let public_key = self
.store
.recovery_operator_public_key(recovery_operator_id)
.await?;
governance::verify_vote(&public_key, proposal_id, approve, &signature)
.map_err(|_| Error::InvalidSignature)?;
self.store
.record_recovery_vote(NewRecoveryProposalVote {
proposal_id,
recovery_operator_id,
approve,
signature,
})
.await?;
let mut tally = self.store.tally(proposal_id).await?;
self.narrow_electorate(&proposal, &mut tally).await?;
self.settle(&proposal, &tally).await
}
}
impl ProposalManager {
/// A vote only counts while the proposal is still open.
fn check_votable(proposal: &Proposal) -> Result<(), Error> {
if proposal.status != ProposalStatus::Pending {
return Err(Error::ProposalNotPending);
}
if proposal.expires_at.0 <= Utc::now() {
return Err(Error::ProposalExpired);
}
Ok(())
}
/// §3.5/§3.6: recovery operators join the electorate only for the kinds they may vote on,
/// and only once the wake-up window has elapsed. Counting them anywhere else makes the
/// rejection threshold unreachable and, for full-quorum kinds, approval unreachable too.
///
/// The votes go out with the voters. A wake-up can be cancelled after recovery operators
/// have already voted (`cancel_wakeup` cancels any uncancelled request, elapsed or not),
/// so a `ReplaceOperator` tally can hold recovery approvals at the moment the committee
/// stops being eligible. Keeping those while zeroing only the electorate size would let
/// them cover ordinary votes that were never cast.
async fn narrow_electorate(&self, proposal: &Proposal, tally: &mut Tally) -> Result<(), Error> {
if !proposal.kind.recovery_may_vote() || !self.store.is_recovery_active().await? {
tally.drop_recovery();
}
Ok(())
}
/// Pure quorum arithmetic — no I/O, so the rules can be tested directly (§3.3).
///
/// A proposal is rejected once approval has become unreachable: even if every voter
/// who has not spoken yet approved, the threshold could not be met.
#[must_use]
pub(crate) const fn evaluate_quorum(tally: &Tally, requires_full_quorum: bool) -> VoteOutcome {
let total_eligible = tally.total_ordinary + tally.total_recovery;
// No electorate, nothing to settle. Guarded before the branch rather than inside it:
// the full-quorum arm would otherwise set `threshold` to 0 and read an empty tally as
// unanimous approval.
if total_eligible <= 0 {
return VoteOutcome::Pending;
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::as_conversions,
reason = "operator counts are always small positive integers"
)]
// §3.3: key-rotation proposals require every eligible voter to approve.
// §3.5: when recovery is active, recovery operators are eligible too.
let threshold: i64 = if requires_full_quorum {
total_eligible
} else {
match crate::crypto::shamir::shamir_threshold(tally.total_ordinary as usize) {
Some(threshold) => threshold as i64,
// No ordinary operators means no electorate: nothing can settle.
None => return VoteOutcome::Pending,
}
};
if tally.approve() >= threshold {
VoteOutcome::Approved
} else if tally.reject() > total_eligible - threshold {
VoteOutcome::Rejected
} else {
VoteOutcome::Pending
}
}
/// Applies the quorum rules to a fresh tally and records whatever they decide.
async fn settle(&self, proposal: &Proposal, tally: &Tally) -> Result<VoteOutcome, Error> {
let outcome = Self::evaluate_quorum(tally, proposal.kind.requires_full_quorum());
match outcome {
VoteOutcome::Approved => self.announce_approval(proposal).await?,
VoteOutcome::Rejected => {
self.store
.set_status(proposal.id, ProposalStatus::Rejected)
.await?;
}
VoteOutcome::Pending => {}
}
Ok(outcome)
}
/// Marks the proposal approved and hands the outcome to whoever owns that kind.
///
/// The outcome is published, not executed: this actor coordinates voting and nothing
/// else. Executors subscribe on the bus, so a vote is answered once the quorum is
/// recorded rather than once the effect has landed.
async fn announce_approval(&self, proposal: &Proposal) -> Result<(), Error> {
self.store
.set_status(proposal.id, ProposalStatus::Approved)
.await?;
let kind = self.store.load_kind(proposal.id, proposal.kind).await?;
let _ = self
.events
.tell(Publish(ProposalApproved {
id: proposal.id,
kind,
}))
.await;
Ok(())
}
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,11 @@
use crate::db::{models::ProposalId, proposal::ProposalKind};
/// Published once a proposal reaches its approval threshold.
///
/// Executors subscribe on the global `MessageBus` and act on the kinds they own;
/// `ProposalManager` does not know who acts on an outcome, or whether anyone does.
#[derive(Debug, Clone)]
pub struct ProposalApproved {
pub id: ProposalId,
pub kind: ProposalKind,
}

View File

@@ -0,0 +1,431 @@
//! Database access for [`super::ProposalManager`], behind a trait.
//!
//! The actor holds a `dyn ProposalStore` rather than a connection pool, so the quorum
//! rules can be exercised against a mock instead of a live SQLite file.
use super::{Error, ProposalSummary};
use crate::db::{
self,
models::{
NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest,
OperatorIdentityId, Proposal, ProposalId, ProposalStatus, RecoveryOperatorIdentityId,
SqliteTimestamp,
},
proposal::{ProposalKind, ProposalKindTag},
schema,
};
use async_trait::async_trait;
use chrono::Utc;
use diesel::{
ExpressionMethods as _, QueryDsl,
dsl::{exists, select},
};
use diesel_async::{AsyncConnection as _, RunQueryDsl};
use std::collections::HashMap;
use strum::IntoDiscriminant as _;
/// Everything the quorum rules need to know about one proposal's votes.
///
/// Votes are kept per electorate rather than pre-summed: an electorate can stop counting
/// between the vote and the tally (§3.6 -- recovery goes back to sleep the moment a wake-up
/// is cancelled), and the votes it already cast have to leave with it. A single `approve`
/// field would carry them past [`Tally::drop_recovery`] into a threshold computed for the
/// ordinary committee alone.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Tally {
pub ordinary_approve: i64,
pub ordinary_reject: i64,
pub recovery_approve: i64,
pub recovery_reject: i64,
pub total_ordinary: i64,
pub total_recovery: i64,
}
impl Tally {
/// Approvals from every electorate that still counts.
pub const fn approve(&self) -> i64 {
self.ordinary_approve + self.recovery_approve
}
/// Rejections from every electorate that still counts.
pub const fn reject(&self) -> i64 {
self.ordinary_reject + self.recovery_reject
}
/// Takes the recovery committee out of the electorate, votes and all. The three numbers
/// go together: leaving the votes behind counts them against a threshold derived from an
/// electorate they are no longer part of.
pub const fn drop_recovery(&mut self) {
self.recovery_approve = 0;
self.recovery_reject = 0;
self.total_recovery = 0;
}
}
#[cfg_attr(test, mockall::automock)]
#[async_trait]
pub trait ProposalStore: Send + Sync + 'static {
/// Writes the proposal and its kind-specific rows in one transaction.
async fn create(
&self,
kind: ProposalKind,
initiator_id: OperatorIdentityId,
expires_at: SqliteTimestamp,
) -> Result<ProposalId, Error>;
async fn load(&self, id: ProposalId) -> Result<Proposal, Error>;
async fn load_kind(&self, id: ProposalId, tag: ProposalKindTag) -> Result<ProposalKind, Error>;
async fn has_voted(
&self,
id: ProposalId,
operator_id: OperatorIdentityId,
) -> Result<bool, Error>;
async fn has_recovery_voted(
&self,
id: ProposalId,
recovery_operator_id: RecoveryOperatorIdentityId,
) -> Result<bool, Error>;
async fn operator_public_key(&self, id: OperatorIdentityId) -> Result<Vec<u8>, Error>;
async fn recovery_operator_public_key(
&self,
id: RecoveryOperatorIdentityId,
) -> Result<Vec<u8>, Error>;
async fn record_vote(&self, vote: NewProposalVote) -> Result<(), Error>;
async fn record_recovery_vote(&self, vote: NewRecoveryProposalVote) -> Result<(), Error>;
/// Vote counts for one proposal, alongside the size of each electorate.
async fn tally(&self, id: ProposalId) -> Result<Tally, Error>;
async fn set_status(&self, id: ProposalId, status: ProposalStatus) -> Result<(), Error>;
/// Pending, unexpired proposals this operator has not voted on yet.
async fn pending_for(
&self,
operator_id: OperatorIdentityId,
) -> Result<Vec<ProposalSummary>, Error>;
/// True once an uncancelled wake-up request has outlived the dispute window.
async fn is_recovery_active(&self) -> Result<bool, Error>;
/// True while any wake-up request stands, whether or not the window has elapsed.
async fn has_uncancelled_wakeup(&self) -> Result<bool, Error>;
async fn request_wakeup(&self, operator_id: OperatorIdentityId) -> Result<(), Error>;
/// Returns false when there was no uncancelled request to cancel.
async fn cancel_wakeup(&self, operator_id: OperatorIdentityId) -> Result<bool, Error>;
}
pub struct DieselProposalStore {
db: db::DatabasePool,
}
impl DieselProposalStore {
pub const fn new(db: db::DatabasePool) -> Self {
Self { db }
}
}
/// `NotFound` means the row is absent, which every caller reports as its own error.
fn missing(absent: Error) -> impl FnOnce(diesel::result::Error) -> Error {
move |e| match e {
diesel::result::Error::NotFound => absent,
other => Error::DatabaseQuery(other),
}
}
#[async_trait]
impl ProposalStore for DieselProposalStore {
async fn create(
&self,
kind: ProposalKind,
initiator_id: OperatorIdentityId,
expires_at: SqliteTimestamp,
) -> Result<ProposalId, Error> {
let id = self
.db
.get()
.await?
.transaction(async |conn| {
let id: ProposalId = diesel::insert_into(schema::proposal::table)
.values(&NewProposal {
kind: kind.discriminant(),
initiator_id,
expires_at,
})
.returning(schema::proposal::id)
.get_result(conn)
.await?;
db::proposal::insert_kind(conn, id, &kind).await?;
Ok::<_, diesel::result::Error>(id)
})
.await?;
Ok(id)
}
async fn load(&self, id: ProposalId) -> Result<Proposal, Error> {
let mut conn = self.db.get().await?;
schema::proposal::table
.find(id)
.first(&mut conn)
.await
.map_err(missing(Error::ProposalNotFound))
}
async fn load_kind(&self, id: ProposalId, tag: ProposalKindTag) -> Result<ProposalKind, Error> {
let mut conn = self.db.get().await?;
db::proposal::load_kind(&mut conn, id, tag)
.await
.map_err(Error::from)
}
async fn has_voted(
&self,
id: ProposalId,
operator_id: OperatorIdentityId,
) -> Result<bool, Error> {
let mut conn = self.db.get().await?;
select(exists(
schema::proposal_vote::table
.filter(schema::proposal_vote::proposal_id.eq(id))
.filter(schema::proposal_vote::operator_id.eq(operator_id)),
))
.get_result(&mut conn)
.await
.map_err(Error::from)
}
async fn has_recovery_voted(
&self,
id: ProposalId,
recovery_operator_id: RecoveryOperatorIdentityId,
) -> Result<bool, Error> {
let mut conn = self.db.get().await?;
select(exists(
schema::recovery_proposal_vote::table
.filter(schema::recovery_proposal_vote::proposal_id.eq(id))
.filter(
schema::recovery_proposal_vote::recovery_operator_id.eq(recovery_operator_id),
),
))
.get_result(&mut conn)
.await
.map_err(Error::from)
}
async fn operator_public_key(&self, id: OperatorIdentityId) -> Result<Vec<u8>, Error> {
let mut conn = self.db.get().await?;
schema::operator_identity::table
.find(id)
.select(schema::operator_identity::public_key)
.first(&mut conn)
.await
.map_err(missing(Error::OperatorNotFound))
}
async fn recovery_operator_public_key(
&self,
id: RecoveryOperatorIdentityId,
) -> Result<Vec<u8>, Error> {
let mut conn = self.db.get().await?;
schema::recovery_operator_identity::table
.find(id)
.select(schema::recovery_operator_identity::public_key)
.first(&mut conn)
.await
.map_err(missing(Error::OperatorNotFound))
}
async fn record_vote(&self, vote: NewProposalVote) -> Result<(), Error> {
let mut conn = self.db.get().await?;
diesel::insert_into(schema::proposal_vote::table)
.values(&vote)
.execute(&mut conn)
.await?;
Ok(())
}
async fn record_recovery_vote(&self, vote: NewRecoveryProposalVote) -> Result<(), Error> {
let mut conn = self.db.get().await?;
diesel::insert_into(schema::recovery_proposal_vote::table)
.values(&vote)
.execute(&mut conn)
.await?;
Ok(())
}
async fn tally(&self, id: ProposalId) -> Result<Tally, Error> {
let mut conn = self.db.get().await?;
let ordinary_approve: i64 = schema::proposal_vote::table
.filter(schema::proposal_vote::proposal_id.eq(id))
.filter(schema::proposal_vote::approve.eq(true))
.count()
.get_result(&mut conn)
.await?;
let recovery_approve: i64 = schema::recovery_proposal_vote::table
.filter(schema::recovery_proposal_vote::proposal_id.eq(id))
.filter(schema::recovery_proposal_vote::approve.eq(true))
.count()
.get_result(&mut conn)
.await?;
let ordinary_reject: i64 = schema::proposal_vote::table
.filter(schema::proposal_vote::proposal_id.eq(id))
.filter(schema::proposal_vote::approve.eq(false))
.count()
.get_result(&mut conn)
.await?;
let recovery_reject: i64 = schema::recovery_proposal_vote::table
.filter(schema::recovery_proposal_vote::proposal_id.eq(id))
.filter(schema::recovery_proposal_vote::approve.eq(false))
.count()
.get_result(&mut conn)
.await?;
let total_ordinary: i64 = schema::operator_identity::table
.count()
.get_result(&mut conn)
.await?;
let total_recovery: i64 = schema::recovery_operator_identity::table
.count()
.get_result(&mut conn)
.await?;
Ok(Tally {
ordinary_approve,
ordinary_reject,
recovery_approve,
recovery_reject,
total_ordinary,
total_recovery,
})
}
async fn set_status(&self, id: ProposalId, status: ProposalStatus) -> Result<(), Error> {
let mut conn = self.db.get().await?;
diesel::update(schema::proposal::table.find(id))
.set(schema::proposal::status.eq(status))
.execute(&mut conn)
.await?;
Ok(())
}
async fn pending_for(
&self,
operator_id: OperatorIdentityId,
) -> Result<Vec<ProposalSummary>, Error> {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #84; this will break in 2038"
)]
let now_ts = Utc::now().timestamp() as i32;
let mut conn = self.db.get().await?;
let voted_ids: Vec<ProposalId> = schema::proposal_vote::table
.filter(schema::proposal_vote::operator_id.eq(operator_id))
.select(schema::proposal_vote::proposal_id)
.load(&mut conn)
.await?;
let proposals: Vec<Proposal> = schema::proposal::table
.filter(schema::proposal::status.eq(ProposalStatus::Pending))
.filter(schema::proposal::expires_at.gt(now_ts))
.filter(diesel::dsl::not(schema::proposal::id.eq_any(&voted_ids)))
.load(&mut conn)
.await?;
let ids: Vec<ProposalId> = proposals.iter().map(|p| p.id).collect();
let tallies: Vec<(ProposalId, bool, i64)> = schema::proposal_vote::table
.filter(schema::proposal_vote::proposal_id.eq_any(&ids))
.group_by((
schema::proposal_vote::proposal_id,
schema::proposal_vote::approve,
))
.select((
schema::proposal_vote::proposal_id,
schema::proposal_vote::approve,
diesel::dsl::count_star(),
))
.load(&mut conn)
.await?;
let mut by_proposal: HashMap<ProposalId, (i64, i64)> = HashMap::new();
for (proposal_id, approve, count) in tallies {
let entry = by_proposal.entry(proposal_id).or_insert((0, 0));
if approve {
entry.0 += count;
} else {
entry.1 += count;
}
}
Ok(proposals
.into_iter()
.map(|p| {
let (approve_count, reject_count) =
by_proposal.get(&p.id).copied().unwrap_or((0, 0));
ProposalSummary {
id: p.id,
kind: p.kind,
initiator_id: p.initiator_id,
expires_at: p.expires_at,
approve_count,
reject_count,
}
})
.collect())
}
async fn is_recovery_active(&self) -> Result<bool, Error> {
let mut conn = self.db.get().await?;
db::recovery::is_active(&mut conn)
.await
.map_err(Error::from)
}
async fn has_uncancelled_wakeup(&self) -> Result<bool, Error> {
let mut conn = self.db.get().await?;
select(exists(schema::recovery_wakeup_request::table.filter(
schema::recovery_wakeup_request::cancelled_at.is_null(),
)))
.get_result(&mut conn)
.await
.map_err(Error::from)
}
async fn request_wakeup(&self, operator_id: OperatorIdentityId) -> Result<(), Error> {
let mut conn = self.db.get().await?;
diesel::insert_into(schema::recovery_wakeup_request::table)
.values(&NewRecoveryWakeupRequest {
requested_by: operator_id,
})
.execute(&mut conn)
.await?;
Ok(())
}
async fn cancel_wakeup(&self, operator_id: OperatorIdentityId) -> Result<bool, Error> {
let mut conn = self.db.get().await?;
let rows = diesel::update(schema::recovery_wakeup_request::table)
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
.set((
schema::recovery_wakeup_request::cancelled_by.eq(Some(operator_id)),
schema::recovery_wakeup_request::cancelled_at.eq(Some(SqliteTimestamp::now())),
))
.execute(&mut conn)
.await?;
Ok(rows > 0)
}
}

View File

@@ -0,0 +1,395 @@
//! The quorum rules, exercised without a database.
//!
//! These assertions are the point of [`super::store::ProposalStore`]: until the actor took
//! its data through a trait, checking that two of three operators carry an ordinary
//! proposal meant opening SQLite and registering operators first.
use super::{
ProposalManager, VoteOutcome,
store::{MockProposalStore, Tally},
};
use crate::{
actors::GlobalActors,
crypto::governance::vote_message,
db::{
models::{OperatorIdentityId, Proposal, ProposalId, ProposalStatus, SqliteTimestamp},
proposal::ProposalKindTag,
},
};
use arbiter_crypto::authn::{SigningContext, SigningKey};
use chrono::{Duration, Utc};
use std::sync::Arc;
/// A tally where every vote came from the ordinary committee.
const fn tally(approve: i64, reject: i64, ordinary: i64, recovery: i64) -> Tally {
Tally {
ordinary_approve: approve,
ordinary_reject: reject,
recovery_approve: 0,
recovery_reject: 0,
total_ordinary: ordinary,
total_recovery: recovery,
}
}
/// A tally with votes from both committees, in the order approve/reject per committee.
const fn mixed_tally(
ordinary_approve: i64,
ordinary_reject: i64,
recovery_approve: i64,
recovery_reject: i64,
total_ordinary: i64,
total_recovery: i64,
) -> Tally {
Tally {
ordinary_approve,
ordinary_reject,
recovery_approve,
recovery_reject,
total_ordinary,
total_recovery,
}
}
#[test]
fn simple_majority_approves_at_two_of_three() {
assert_eq!(
ProposalManager::evaluate_quorum(&tally(2, 0, 3, 0), false),
VoteOutcome::Approved
);
}
#[test]
fn one_of_three_is_not_yet_a_majority() {
assert_eq!(
ProposalManager::evaluate_quorum(&tally(1, 0, 3, 0), false),
VoteOutcome::Pending
);
}
#[test]
fn full_quorum_kind_needs_every_voter() {
assert_eq!(
ProposalManager::evaluate_quorum(&tally(2, 0, 3, 0), true),
VoteOutcome::Pending,
"two of three must not carry a key-rotation proposal"
);
}
#[test]
fn recovery_voters_count_towards_full_quorum() {
assert_eq!(
ProposalManager::evaluate_quorum(&mixed_tally(2, 0, 1, 0, 2, 1), true),
VoteOutcome::Approved
);
assert_eq!(
ProposalManager::evaluate_quorum(&mixed_tally(2, 0, 0, 0, 2, 1), true),
VoteOutcome::Pending,
"the sleeping recovery operator still owes a vote"
);
}
/// An empty committee cannot approve anything. Both arms have to say so: the full-quorum arm
/// derives its threshold from the electorate, so with nobody eligible it would compare 0
/// approvals against a threshold of 0 and call that unanimous.
#[test]
fn an_empty_electorate_settles_nothing() {
assert_eq!(
ProposalManager::evaluate_quorum(&tally(0, 0, 0, 0), true),
VoteOutcome::Pending,
"a full-quorum proposal must not pass with no eligible voters"
);
assert_eq!(
ProposalManager::evaluate_quorum(&tally(0, 0, 0, 0), false),
VoteOutcome::Pending
);
}
#[test]
fn rejection_is_decided_once_approval_is_unreachable() {
// Threshold is 2 of 3, so two rejections leave at most one approval available.
assert_eq!(
ProposalManager::evaluate_quorum(&tally(0, 2, 3, 0), false),
VoteOutcome::Rejected
);
assert_eq!(
ProposalManager::evaluate_quorum(&tally(0, 1, 3, 0), false),
VoteOutcome::Pending,
"one rejection still leaves two approvals reachable"
);
}
#[test]
fn a_single_rejection_sinks_a_full_quorum_proposal() {
assert_eq!(
ProposalManager::evaluate_quorum(&tally(2, 1, 3, 0), true),
VoteOutcome::Rejected
);
}
fn pending_proposal(id: ProposalId, kind: ProposalKindTag) -> Proposal {
let now = Utc::now();
Proposal {
id,
kind,
initiator_id: OperatorIdentityId::from_raw(1),
created_at: SqliteTimestamp::from(now),
expires_at: SqliteTimestamp::from(now + Duration::days(1)),
status: ProposalStatus::Pending,
}
}
/// The mock earns its keep here: reaching quorum must flip the stored status to
/// `Approved` exactly once. Signature verification stays real -- only the database is
/// stubbed out.
#[tokio::test]
async fn reaching_quorum_marks_the_proposal_approved() {
let id = ProposalId::from_raw(1);
let voter = OperatorIdentityId::from_raw(1);
let key = SigningKey::generate();
let signature = key
.sign_message(&vote_message(id, true), SigningContext::GovernanceVote)
.expect("signing a vote must succeed");
let public_key = key.public_key().to_bytes();
let mut store = MockProposalStore::new();
store
.expect_load()
.returning(move |id| Ok(pending_proposal(id, ProposalKindTag::TriggerRekey)));
store.expect_has_voted().returning(|_, _| Ok(false));
store
.expect_operator_public_key()
.returning(move |_| Ok(public_key.clone()));
store.expect_record_vote().returning(|_| Ok(()));
store.expect_is_recovery_active().returning(|| Ok(false));
store.expect_tally().returning(|_| Ok(tally(1, 0, 1, 0)));
store
.expect_set_status()
.withf(move |got, status| *got == id && *status == ProposalStatus::Approved)
.times(1)
.returning(|_, _| Ok(()));
store
.expect_load_kind()
.returning(|_, _| Ok(crate::db::proposal::ProposalKind::TriggerRekey));
let mut manager =
ProposalManager::with_store(Arc::new(store), GlobalActors::spawn_message_bus());
let outcome = manager
.cast_vote(id, voter, true, signature.to_bytes())
.await
.expect("a valid vote must be accepted");
assert_eq!(outcome, VoteOutcome::Approved);
}
/// A vote that does not reach the threshold must leave the stored status alone.
#[tokio::test]
async fn a_vote_short_of_quorum_does_not_touch_the_status() {
let id = ProposalId::from_raw(7);
let voter = OperatorIdentityId::from_raw(2);
let key = SigningKey::generate();
let signature = key
.sign_message(&vote_message(id, true), SigningContext::GovernanceVote)
.expect("signing a vote must succeed");
let public_key = key.public_key().to_bytes();
let mut store = MockProposalStore::new();
store
.expect_load()
.returning(move |id| Ok(pending_proposal(id, ProposalKindTag::ApproveSdkClient)));
store.expect_has_voted().returning(|_, _| Ok(false));
store
.expect_operator_public_key()
.returning(move |_| Ok(public_key.clone()));
store.expect_record_vote().returning(|_| Ok(()));
store.expect_is_recovery_active().returning(|| Ok(false));
store.expect_tally().returning(|_| Ok(tally(1, 0, 3, 0)));
store.expect_set_status().never();
let mut manager =
ProposalManager::with_store(Arc::new(store), GlobalActors::spawn_message_bus());
let outcome = manager
.cast_vote(id, voter, true, signature.to_bytes())
.await
.expect("a valid vote must be accepted");
assert_eq!(outcome, VoteOutcome::Pending);
}
/// Drives one `cast_vote` on a proposal of the given `kind` through a mocked store and
/// returns the outcome. `recovery_active` decides what `is_recovery_active` reports;
/// `expected_status` is the status a settled outcome must be persisted under, or `None` for
/// a caller that expects the vote to leave the proposal pending.
///
/// `set_status` carries an argument matcher but no `.times()`, and `None` relaxes even the
/// matcher: the caller's own `assert_eq!` on the outcome is what pins the behaviour, so a
/// regression fails on a readable diff rather than on a mockall cardinality panic that hides
/// what the actor actually computed.
async fn settle_vote_with(
kind: ProposalKindTag,
tally: Tally,
recovery_active: bool,
expected_status: Option<ProposalStatus>,
) -> VoteOutcome {
let id = ProposalId::from_raw(11);
let voter = OperatorIdentityId::from_raw(1);
let key = SigningKey::generate();
let signature = key
.sign_message(&vote_message(id, true), SigningContext::GovernanceVote)
.expect("signing a vote must succeed");
let public_key = key.public_key().to_bytes();
let mut store = MockProposalStore::new();
store
.expect_load()
.returning(move |id| Ok(pending_proposal(id, kind)));
store.expect_has_voted().returning(|_, _| Ok(false));
store
.expect_operator_public_key()
.returning(move |_| Ok(public_key.clone()));
store.expect_record_vote().returning(|_| Ok(()));
store
.expect_is_recovery_active()
.returning(move || Ok(recovery_active));
store.expect_tally().returning(move |_| Ok(tally));
store
.expect_set_status()
.withf(move |_, status| {
expected_status
.as_ref()
.is_none_or(|expected| status == expected)
})
.returning(|_, _| Ok(()));
store.expect_load_kind().returning(move |_, _| {
Ok(match kind {
ProposalKindTag::TriggerRekey => crate::db::proposal::ProposalKind::TriggerRekey,
ProposalKindTag::ApproveSdkClient => {
crate::db::proposal::ProposalKind::ApproveSdkClient(
crate::db::proposal::approve_sdk_client::Settings { client_id: 1 },
)
}
ProposalKindTag::ReplaceOperator => crate::db::proposal::ProposalKind::ReplaceOperator(
crate::db::proposal::replace_operator::Settings {
old_operator_id: OperatorIdentityId::from_raw(1),
new_pubkey: vec![0u8; 32],
},
),
other => unreachable!("settle_vote_with has no load_kind fixture for {other:?}"),
})
});
let mut manager =
ProposalManager::with_store(Arc::new(store), GlobalActors::spawn_message_bus());
manager
.cast_vote(id, voter, true, signature.to_bytes())
.await
.expect("a valid vote must be accepted")
}
/// The full-quorum rejection path is insensitive to electorate size by construction:
/// `threshold == total_eligible` there, so `total_eligible - threshold` is always 0 and any
/// single rejection settles the proposal, whether or not recovery operators are (wrongly)
/// counted. This does not exercise the electorate-narrowing fix -- see
/// `unanimous_ordinary_rejection_rejects_a_non_full_quorum_proposal_while_recovery_is_awake`
/// below for the test that does -- it just pins that `cast_vote` still writes `Rejected`
/// through `settle` for a full-quorum kind.
#[tokio::test]
async fn unanimous_rejection_settles_a_full_quorum_rekey_via_cast_vote() {
let outcome = settle_vote_with(
ProposalKindTag::TriggerRekey,
tally(0, 3, 3, 2),
/* recovery_active */ true,
Some(ProposalStatus::Rejected),
)
.await;
assert_eq!(outcome, VoteOutcome::Rejected);
}
/// §3.3 full quorum for a rekey means every *ordinary* operator, not every identity on file.
#[tokio::test]
async fn unanimous_ordinary_approval_approves_a_rekey_while_recovery_is_awake() {
let outcome = settle_vote_with(
ProposalKindTag::TriggerRekey,
tally(3, 0, 3, 2),
/* recovery_active */ true,
Some(ProposalStatus::Approved),
)
.await;
assert_eq!(outcome, VoteOutcome::Approved);
}
/// §3.5: recovery operators do not vote on `ApproveSdkClient`, so they must not inflate its
/// electorate. Before the fix, `total_eligible` counted them anyway (5, not 3), so the
/// rejection test `reject > total_eligible - threshold` became `3 > 5 - 2 = 3`, which is
/// false -- three unanimous rejections left the proposal `Pending` forever, since a fourth
/// vote could never arrive. After the fix, `total_eligible` is 3 and the same test becomes
/// `3 > 3 - 2 = 1`, which settles it.
#[tokio::test]
async fn unanimous_ordinary_rejection_rejects_a_non_full_quorum_proposal_while_recovery_is_awake() {
let outcome = settle_vote_with(
ProposalKindTag::ApproveSdkClient,
tally(0, 3, 3, 2),
/* recovery_active */ true,
Some(ProposalStatus::Rejected),
)
.await;
assert_eq!(outcome, VoteOutcome::Rejected);
}
/// §3.5/§3.6: `ReplaceOperator` is the one kind recovery may vote on, so it is the only kind
/// where whether recovery is awake is observable at all -- for every other kind
/// `narrow_electorate` zeroes `total_recovery` regardless of `is_recovery_active`, short-
/// circuiting before that call. A sleeping recovery electorate must not raise the bar here:
/// with 1 ordinary operator and 2 (asleep) recovery operators, the lone ordinary approval
/// must already reach full quorum.
#[tokio::test]
async fn sleeping_recovery_operators_do_not_count_towards_quorum() {
let outcome = settle_vote_with(
ProposalKindTag::ReplaceOperator,
tally(1, 0, 1, 2),
/* recovery_active */ false,
Some(ProposalStatus::Approved),
)
.await;
assert_eq!(outcome, VoteOutcome::Approved);
}
/// The sequence the whole-branch review worked through, on a `ReplaceOperator` with 3
/// ordinary and 2 recovery operators (§3.3: full quorum). Both recovery operators approve
/// while awake; one ordinary operator approves; another ordinary operator then cancels the
/// wake-up -- `cancel_wakeup` cancels an uncancelled request whether or not its window has
/// elapsed, so the committee goes straight back to sleep with its votes on the record; a
/// second ordinary operator approves.
///
/// The store now reports 4 approvals, 2 of them from a committee that is no longer eligible.
/// Narrowing the electorate has to drop those votes along with the voters: what is left is 2
/// of 3 ordinary approvals, and a full quorum needs all three. Counting the electorate down
/// to 3 while keeping all 4 votes would replace an operator on two ordinary approvals.
#[tokio::test]
async fn recovery_votes_leave_with_the_committee_that_cast_them() {
let outcome = settle_vote_with(
ProposalKindTag::ReplaceOperator,
mixed_tally(
/* ordinary_approve */ 2, /* ordinary_reject */ 0,
/* recovery_approve */ 2, /* recovery_reject */ 0,
/* total_ordinary */ 3, /* total_recovery */ 2,
),
/* recovery_active */ false,
None,
)
.await;
assert_eq!(
outcome,
VoteOutcome::Pending,
"two of three ordinary approvals must not carry a full-quorum proposal, whatever a \
sleeping recovery committee voted earlier"
);
}

View File

@@ -1,29 +1,32 @@
use crate::{
actors::proposal_manager::events::ProposalApproved,
crypto::{
KeyCell,
encryption::v1::{self, Nonce},
integrity::{self, v1::HmacSha256},
},
db::{
self,
models::{self, RootKeyHistory, RootKeyHistoryId},
proposal::ProposalKind,
schema,
},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use chrono::Utc;
use diesel::{
ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper,
dsl::{insert_into, update},
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::Mac as _;
use kameo::{Actor, Reply, actor::ActorRef, messages};
use hmac::{KeyInit as _, Mac as _};
use kameo::{Actor, Reply, actor::ActorRef, messages, prelude::Message};
use kameo_actors::message_bus::{MessageBus, Publish};
use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info};
use crate::crypto::{
KeyCell, derive_key,
encryption::v1::{self, Nonce},
integrity::v1::HmacSha256,
};
use crate::db::{
self,
models::{self, RootKeyHistory},
schema::{self},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
pub mod events {
#[derive(Clone, Copy)]
pub struct Bootstrapped;
@@ -62,7 +65,7 @@ pub enum Error {
}
struct Unsealed {
root_key_history_id: i32,
root_key_history_id: RootKeyHistoryId,
root_key: KeyCell,
}
@@ -71,13 +74,15 @@ struct Unsealed {
enum State {
#[default]
Unbootstrapped,
Sealed {
root_key_history_id: i32,
root_key_history_id: RootKeyHistoryId,
},
Unsealed(Unsealed),
}
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
///
/// Provides API for encrypting and decrypting data using the vault root key.
/// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor.
#[derive(Actor)]
@@ -87,7 +92,6 @@ pub struct Vault {
events: ActorRef<MessageBus>,
}
#[messages]
impl Vault {
pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
let state = {
@@ -110,100 +114,105 @@ impl Vault {
Ok(Self { db, state, events })
}
// Exclusive transaction to avoid race condtions if multiple vaults write
// Exclusive transaction to avoid race conditions if multiple vaults write
// additional layer of protection against nonce-reuse
async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> {
async fn get_new_nonce(
pool: &db::DatabasePool,
root_key_id: RootKeyHistoryId,
) -> Result<Nonce, Error> {
let mut conn = pool.get().await?;
let nonce = conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let current_nonce: Vec<u8> = schema::root_key_history::table
.filter(schema::root_key_history::id.eq(root_key_id))
.select(schema::root_key_history::data_encryption_nonce)
.first(conn)
.await?;
.exclusive_transaction(async |conn| {
let current_nonce: Vec<u8> = schema::root_key_history::table
.filter(schema::root_key_history::id.eq(root_key_id))
.select(schema::root_key_history::data_encryption_nonce)
.first(&mut *conn)
.await?;
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|_| {
error!(
"Broken database: invalid nonce for root key history id={}",
root_key_id
);
Error::BrokenDatabase
})?;
nonce.increment();
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
error!(
"Broken database: invalid nonce for root key history id={:#?}",
root_key_id
);
Error::BrokenDatabase
})?;
nonce.increment();
update(schema::root_key_history::table)
.filter(schema::root_key_history::id.eq(root_key_id))
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
.execute(conn)
.await?;
update(schema::root_key_history::table)
.filter(schema::root_key_history::id.eq(root_key_id))
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
.execute(&mut *conn)
.await?;
Result::<_, Error>::Ok(nonce)
})
Result::<_, Error>::Ok(nonce)
})
.await?;
Ok(nonce)
}
fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
const fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
match state {
State::Unsealed(unsealed) => Ok(unsealed),
State::Unbootstrapped => Err(Error::NotBootstrapped),
State::Sealed { .. } => Err(Error::Sealed),
}
}
}
#[messages]
impl Vault {
#[message]
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if !matches!(self.state, State::Unbootstrapped) {
pub async fn bootstrap(&mut self, mut seal_key: KeyCell) -> 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
let root_key_nonce = Nonce::default();
let data_encryption_nonce = Nonce::default();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
let root_key_reader = reader.as_slice();
// Generate salt (kept for schema compat)
let root_key_salt = v1::generate_salt();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|rk| {
seal_key
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader)
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, rk.as_slice())
.map_err(|err| {
error!(?err, "Fatal bootstrap error");
Error::Encryption(err)
})
})?;
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let mut conn = self.db.get().await?;
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let root_key_history_id = conn
.transaction(|conn| {
Box::pin(async move {
let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: root_key_ciphertext,
tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: root_key_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce_bytes,
schema_version: 1,
salt: salt.to_vec(),
})
.returning(schema::root_key_history::id)
.get_result(conn)
.await?;
.transaction(async |conn| {
let root_key_history_id = 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: root_key_salt.to_vec(),
})
.returning(schema::root_key_history::id)
.get_result(&mut *conn)
.await?;
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
.execute(conn)
.await?;
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
.execute(&mut *conn)
.await?;
Result::<_, diesel::result::Error>::Ok(root_key_history_id)
})
Result::<_, diesel::result::Error>::Ok(RootKeyHistoryId::from_raw(
root_key_history_id,
))
})
.await?;
@@ -213,67 +222,133 @@ impl Vault {
});
info!("Vault bootstrapped successfully");
self.events.tell(Publish(events::Bootstrapped)).await;
if let Err(err) = self.events.tell(Publish(events::Bootstrapped)).await {
error!(?err, "Failed to publish Bootstrapped event");
}
Ok(())
}
#[message]
pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
pub async fn try_unseal(&mut self, mut seal_key: KeyCell) -> Result<(), Error> {
let State::Sealed {
root_key_history_id,
} = &self.state
else {
return Err(Error::NotBootstrapped);
};
let root_key_history_id = *root_key_history_id;
// We don't want to hold connection while doing expensive KDF work
// We don't want to hold connection while doing expensive work
let current_key = {
let mut conn = self.db.get().await?;
schema::root_key_history::table
.filter(schema::root_key_history::id.eq(*root_key_history_id))
.filter(schema::root_key_history::id.eq(root_key_history_id))
.select(RootKeyHistory::as_select())
.first(&mut conn)
.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 = v1::Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(
|_| {
let nonce =
Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| {
error!("Broken database: invalid nonce for root key");
Error::BrokenDatabase
},
)?;
})?;
let mut root_key_bytes = SafeCell::new(current_key.ciphertext.clone());
seal_key
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key_bytes)
.map_err(|err| {
error!(?err, "Failed to unseal root key: invalid seal key");
Error::InvalidKey
})?;
let root_key = KeyCell::try_from(root_key_bytes).map_err(|()| {
error!("Broken database: invalid encryption key size");
Error::BrokenDatabase
})?;
self.state = State::Unsealed(Unsealed {
root_key_history_id: current_key.id,
root_key: KeyCell::try_from(root_key).map_err(|err| {
error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase
})?,
root_key,
});
info!("Vault unsealed successfully");
self.events.tell(Publish(events::Unsealed)).await;
let _ = self.events.tell(Publish(events::Unsealed)).await;
Ok(())
}
/// Re-encrypts the root key with `new_seal_key`, updating its `root_key_history` row in
/// place. Called after a Shamir re-key, so the old seal key is no longer sufficient to
/// unseal. The root key itself does not change, so its row identity (and the nonce counter
/// and integrity envelopes bound to it) must not change either.
#[message]
pub async fn rekey_root_key(&mut self, mut new_seal_key: KeyCell) -> Result<(), Error> {
let Unsealed {
root_key,
root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?;
let new_nonce = Nonce::default();
let new_salt = v1::generate_salt();
let new_ciphertext: Vec<u8> = root_key.0.read_inline(|rk| {
new_seal_key
.encrypt(&new_nonce, v1::ROOT_KEY_TAG, rk.as_slice())
.map_err(|err| {
error!(?err, "Fatal rekey error");
Error::Encryption(err)
})
})?;
let mut conn = self.db.get().await?;
// The root key is unchanged, so its row keeps its identity: `data_encryption_nonce`
// keeps counting up, and every integrity envelope stays bound to the same key version.
// Only the seal-key material is replaced, retiring the previous one. `tag` and
// `schema_version` are deliberately left untouched: the seal-key encryption scheme
// itself is unchanged by a re-key, so there is nothing new for them to describe.
let rows_updated = update(schema::root_key_history::table)
.filter(schema::root_key_history::id.eq(*root_key_history_id))
.set((
schema::root_key_history::ciphertext.eq(new_ciphertext),
schema::root_key_history::root_key_encryption_nonce.eq(new_nonce.to_vec()),
schema::root_key_history::salt.eq(new_salt.to_vec()),
))
.execute(&mut conn)
.await?;
if rows_updated == 0 {
error!(
"Broken database: rekey matched no root_key_history row id={:#?}",
root_key_history_id
);
return Err(Error::BrokenDatabase);
}
info!("Vault root key rekeyed successfully");
Ok(())
}
#[message]
pub async fn seal(&mut self) -> Result<(), Error> {
let Unsealed {
root_key_history_id,
..
} = Self::expect_unsealed(&mut self.state)?;
self.state = State::Sealed {
root_key_history_id: *root_key_history_id,
};
let _ = self.events.tell(Publish(events::VaultResealed)).await;
Ok(())
}
}
// Server-side cryptographic operations
#[messages]
impl Vault {
#[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?;
@@ -289,7 +364,7 @@ impl Vault {
.ok_or(Error::NotFound)?
};
let nonce = v1::Nonce::try_from(row.current_nonce.as_slice()).map_err(|_| {
let nonce = Nonce::try_from(row.current_nonce.as_slice()).map_err(|()| {
error!(
"Broken database: invalid nonce for aead_encrypted id={}",
aead_id
@@ -342,19 +417,20 @@ impl Vault {
}
#[message]
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
pub fn sign_integrity(
&mut self,
mac_input: Vec<u8>,
) -> Result<(RootKeyHistoryId, Vec<u8>), Error> {
let Unsealed {
root_key,
root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?;
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
hmac.update(&root_key_history_id.to_be_bytes());
let mut hmac = root_key.0.read_inline(|k| {
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(&mac_input);
let mac = hmac.finalize().into_bytes().to_vec();
@@ -366,7 +442,7 @@ impl Vault {
&mut self,
mac_input: Vec<u8>,
expected_mac: Vec<u8>,
key_version: i32,
key_version: RootKeyHistoryId,
) -> Result<bool, Error> {
let Unsealed {
root_key,
@@ -377,44 +453,76 @@ impl Vault {
return Ok(false);
}
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
hmac.update(&key_version.to_be_bytes());
let mut hmac = root_key.0.read_inline(|k| {
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(&mac_input);
Ok(hmac.verify_slice(&expected_mac).is_ok())
}
}
#[message]
pub async fn seal(&mut self) -> Result<(), Error> {
let Unsealed {
root_key_history_id,
..
} = Self::expect_unsealed(&mut self.state)?;
impl Message<ProposalApproved> for Vault {
type Reply = ();
self.state = State::Sealed {
root_key_history_id: *root_key_history_id,
/// Every subscriber sees every approval and acts only on the kinds it owns.
async fn handle(
&mut self,
msg: ProposalApproved,
_ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
) -> Self::Reply {
let ProposalKind::ApproveSdkClient(settings) = msg.kind else {
return;
};
self.events.tell(Publish(events::VaultResealed)).await;
if let Err(error) = self.approve_sdk_client(settings.client_id).await {
error!(
?error,
proposal_id = msg.id.to_raw(),
"Failed to execute an approved proposal"
);
}
}
}
impl Vault {
/// Attests an approved SDK client with the root key.
///
/// Builds the envelope from its parts rather than calling `integrity::sign_entity`,
/// which would have this actor ask itself for a signature and deadlock.
async fn approve_sdk_client(&mut self, client_id: i32) -> Result<(), Error> {
use crate::peers::client::ClientCredentials;
use arbiter_crypto::authn;
// Cloned so the connection does not hold a borrow of `self` across `sign_integrity`.
let db = self.db.clone();
let mut conn = db.get().await?;
let pubkey_bytes: Vec<u8> = schema::program_client::table
.find(client_id)
.select(schema::program_client::public_key)
.first(&mut conn)
.await?;
let pubkey =
authn::PublicKey::try_from(pubkey_bytes.as_slice()).map_err(|()| Error::InvalidKey)?;
let credentials = ClientCredentials { pubkey };
let (entity_id, mac_input) = integrity::envelope_input(&credentials, client_id);
let (key_version, mac) = self.sign_integrity(mac_input)?;
integrity::store_envelope::<ClientCredentials>(&mut conn, entity_id, key_version, mac)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use diesel::SelectableHelper;
use diesel_async::RunQueryDsl;
use crate::{
actors::GlobalActors,
db::{self},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use crate::actors::GlobalActors;
use super::*;
@@ -422,8 +530,7 @@ mod tests {
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.bootstrap(seal_key).await.unwrap();
actor.bootstrap(KeyCell::from([0u8; 32])).await.unwrap();
actor
}
@@ -432,12 +539,12 @@ mod tests {
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await;
let root_key_history_id = match actor.state {
State::Unsealed(Unsealed {
root_key_history_id,
..
}) => root_key_history_id,
_ => panic!("expected unsealed state"),
let State::Unsealed(Unsealed {
root_key_history_id,
..
}) = actor.state
else {
panic!("expected unsealed state");
};
let n1 = Vault::get_new_nonce(&db, root_key_history_id)
@@ -449,8 +556,8 @@ mod tests {
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");
let mut conn = db.get().await.unwrap();
let root_row: models::RootKeyHistory = schema::root_key_history::table
.select(models::RootKeyHistory::as_select())
let root_row: RootKeyHistory = schema::root_key_history::table
.select(RootKeyHistory::as_select())
.first(&mut conn)
.await
.unwrap();
@@ -471,4 +578,96 @@ mod tests {
"next write must advance nonce"
);
}
#[tokio::test]
#[test_log::test]
async fn rekey_does_not_restart_the_data_nonce_counter() {
let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await;
let before = actor
.create_new(SafeCell::new(b"before-rekey".to_vec()))
.await
.unwrap();
actor.rekey_root_key(KeyCell::from([7u8; 32])).await.unwrap();
let after = actor
.create_new(SafeCell::new(b"after-rekey".to_vec()))
.await
.unwrap();
let mut conn = db.get().await.unwrap();
// One root key, one row: the root key never changed, so its history did not fork.
let rows: i64 = schema::root_key_history::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(rows, 1, "a seal-key re-key must not append a root key row");
// Fetch each nonce by its own id, rather than `eq_any` (whose row order is
// unspecified), and assert the counter strictly advanced. A weaker `assert_ne!` would
// still pass if the counter reset, as long as the two nonces happened to differ.
let before_nonce: Vec<u8> = schema::aead_encrypted::table
.find(before)
.select(schema::aead_encrypted::current_nonce)
.first(&mut conn)
.await
.unwrap();
let after_nonce: Vec<u8> = schema::aead_encrypted::table
.find(after)
.select(schema::aead_encrypted::current_nonce)
.first(&mut conn)
.await
.unwrap();
assert!(
after_nonce > before_nonce,
"nonce counter must keep advancing across a rekey, not reset"
);
}
#[tokio::test]
#[test_log::test]
async fn rekey_invalidates_the_old_seal_key() {
let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await;
actor.rekey_root_key(KeyCell::from([7u8; 32])).await.unwrap();
actor.seal().await.unwrap();
// A no-op rekey would leave the old seal key working; it must not.
let err = actor
.try_unseal(KeyCell::from([0u8; 32]))
.await
.unwrap_err();
assert!(
matches!(err, Error::InvalidKey),
"old seal key must no longer unseal after a rekey, got {err:?}"
);
// A failed unseal must leave the sealed state intact: the new seal key must still be
// able to unseal on the next attempt.
actor.try_unseal(KeyCell::from([7u8; 32])).await.unwrap();
}
#[tokio::test]
#[test_log::test]
async fn integrity_envelopes_survive_a_rekey() {
let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await;
let mac_input = b"operator_credentials/1".to_vec();
let (key_version, mac) = actor.sign_integrity(mac_input.clone()).unwrap();
actor.rekey_root_key(KeyCell::from([9u8; 32])).await.unwrap();
assert!(
actor
.verify_integrity(mac_input, mac, key_version)
.unwrap(),
"a seal-key re-key must not invalidate existing attestations"
);
}
}

View File

@@ -0,0 +1,923 @@
use std::collections::HashMap;
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{Actor, actor::ActorRef, messages, prelude::Message};
use rand_core::{OsRng, RngCore as _};
use tracing::error;
use crate::{
actors::{
proposal_manager::events::ProposalApproved,
vault::{Bootstrap, RekeyRootKey, TryUnseal, Vault},
},
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir, shamir::shamir_threshold},
db::{
self, models,
proposal::{ProposalKind, replace_operator},
schema,
},
};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Already coordinating a bootstrap")]
AlreadyBootstrapping,
#[error("Already coordinating an unseal")]
AlreadyUnsealing,
#[error("Rekey not in progress")]
NotRekeying,
#[error("Bootstrap not in progress")]
NotBootstrapping,
#[error("Unseal not in progress")]
NotUnsealing,
#[error("Operator already contributed")]
DuplicateContribution,
#[error("Operator not found in database")]
OperatorNotFound,
#[error("Invalid passphrase (decryption failed)")]
InvalidPassphrase,
#[error("Shamir error: {0}")]
Shamir(String),
#[error("Database connection error: {0}")]
DatabaseConnection(#[from] db::PoolError),
#[error("Database query error: {0}")]
DatabaseQuery(#[from] diesel::result::Error),
#[error("Encryption error")]
Encryption,
#[error("Vault error")]
VaultError,
#[error("Two-operator vaults require at least one recovery share")]
TwoOperatorsRequireRecovery,
#[error("Broken database")]
BrokenDatabase,
#[error("A committee must have at least one ordinary operator")]
EmptyCommittee,
#[error("Recovery operators are sleeping")]
RecoveryNotActive,
}
// Passphrases stored as plain Vec<u8> (not SafeCell) so CoordinatorState is Sync.
// They are ephemeral and dropped immediately after use.
enum CoordinatorState {
Idle,
Bootstrapping {
declared_count: usize,
recovery_count: usize,
passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
},
Unsealing {
threshold: usize,
ordinary_passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
},
/// Shamir re-key after `replace_operator` or `trigger_rekey` is approved (§3.3).
/// Collects new passphrases from all current operators, then generates a fresh seal key,
/// re-splits it, and re-encrypts the vault root key.
Rekeying {
ordinary_count: usize,
recovery_count: usize,
passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
},
}
#[derive(Actor)]
pub struct VaultCoordinator {
db: db::DatabasePool,
vault: ActorRef<Vault>,
state: CoordinatorState,
}
impl VaultCoordinator {
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
Self {
db,
vault,
state: CoordinatorState::Idle,
}
}
}
const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
fn encrypt_share(
passphrase_bytes: Vec<u8>,
share: &[u8],
) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), Error> {
let mut share_salt = vec![0u8; 32];
OsRng.fill_bytes(&mut share_salt);
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt);
let nonce = Nonce::default();
let encrypted_share = share_seal_key
.encrypt(&nonce, SHARE_AAD, share)
.map_err(|_| Error::Encryption)?;
Ok((encrypted_share, nonce.to_vec(), share_salt))
}
fn decrypt_share(
passphrase_bytes: Vec<u8>,
encrypted_share: Vec<u8>,
share_nonce_bytes: &[u8],
share_salt: &[u8],
operator_id: i32,
) -> Result<Vec<u8>, Error> {
let nonce = Nonce::try_from(share_nonce_bytes).map_err(|()| {
error!(operator_id, "Invalid nonce in DB");
Error::BrokenDatabase
})?;
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
let mut share_seal_key = derive_key(&mut passphrase_cell, share_salt);
let mut share_buffer = SafeCell::new(encrypted_share);
share_seal_key
.decrypt_in_place(&nonce, SHARE_AAD, &mut share_buffer)
.map_err(|_| Error::InvalidPassphrase)?;
Ok(share_buffer.read().clone())
}
/// Records the threshold of the split that produced the stored shares.
async fn store_threshold(conn: &mut db::DatabaseConnection, threshold: usize) -> Result<(), Error> {
// A threshold that doesn't fit in the column is a bug, not a real empty-committee refusal.
let threshold = i32::try_from(threshold).map_err(|_| Error::BrokenDatabase)?;
let rows_updated = diesel::update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::shamir_threshold.eq(Some(threshold)))
.execute(conn)
.await?;
// The singleton row always exists (up.sql seeds it), so anything else means the update did
// not land -- bootstrap would then report success while the threshold stays NULL forever.
if rows_updated != 1 {
return Err(Error::BrokenDatabase);
}
Ok(())
}
/// Reads back the threshold recorded at bootstrap or re-key time.
async fn load_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?;
// A missing or out-of-range value here means the recorded threshold is corrupt, not that the
// committee is genuinely empty -- `EmptyCommittee` is reserved for the real domain refusal.
stored
.and_then(|threshold| usize::try_from(threshold).ok())
.ok_or(Error::BrokenDatabase)
}
/// §3.4: Split the seal key across ordinary + recovery operators.
/// Threshold = `shamir_threshold(ordinary_count)`; total shares = ordinary + recovery.
/// When `ordinary_count` == 1 (threshold = 1), vsss-rs does not support a proper split,
/// so each share is the seal key itself — any single participant can reconstruct.
async fn finalize_bootstrap(
db: db::DatabasePool,
vault: ActorRef<Vault>,
ordinary_passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
) -> Result<(), Error> {
let ordinary_count = ordinary_passphrases.len();
let recovery_count = recovery_passphrases.len();
let total = ordinary_count + recovery_count;
let threshold = shamir_threshold(ordinary_count).ok_or(Error::EmptyCommittee)?;
let mut seal_key_bytes = [0u8; 32];
OsRng.fill_bytes(&mut seal_key_bytes);
// threshold == 1 means any single share reconstructs the key (degenerate split).
// vsss-rs requires threshold >= 2, so we store the key directly in this case.
let shares: Vec<Vec<u8>> = if threshold >= 2 {
shamir::split_key(threshold, total, &seal_key_bytes, OsRng)
.map_err(|e| Error::Shamir(e.to_string()))?
} else {
std::iter::repeat_with(|| seal_key_bytes.to_vec()).take(total).collect()
};
let seal_key = KeyCell::from(seal_key_bytes);
let mut conn = db.get().await?;
let mut shares_iter = shares.into_iter();
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
let share = shares_iter
.next()
.expect("split_key returned enough shares");
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
diesel::replace_into(schema::operator::table)
.values((
schema::operator::id.eq(Some(operator_id_raw)),
schema::operator::share.eq(&encrypted_share),
schema::operator::share_nonce.eq(&nonce_bytes),
schema::operator::share_salt.eq(&share_salt),
schema::operator::created_at.eq(models::SqliteTimestamp::now()),
schema::operator::updated_at.eq(models::SqliteTimestamp::now()),
))
.execute(&mut conn)
.await?;
}
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
let share = shares_iter
.next()
.expect("split_key returned enough shares");
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
diesel::replace_into(schema::recovery_operator::table)
.values((
schema::recovery_operator::id.eq(recovery_id_raw),
schema::recovery_operator::share.eq(&encrypted_share),
schema::recovery_operator::share_nonce.eq(&nonce_bytes),
schema::recovery_operator::share_salt.eq(&share_salt),
schema::recovery_operator::created_at.eq(models::SqliteTimestamp::now()),
schema::recovery_operator::updated_at.eq(models::SqliteTimestamp::now()),
))
.execute(&mut conn)
.await?;
}
store_threshold(&mut conn, threshold).await?;
vault.ask(Bootstrap { seal_key }).await.map_err(|err| {
error!(?err, "Vault bootstrap failed");
Error::VaultError
})?;
Ok(())
}
/// §3.5: Unseal using any threshold-sized mix of ordinary + recovery shares.
async fn finalize_unseal(
db: db::DatabasePool,
vault: ActorRef<Vault>,
ordinary_passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
) -> Result<(), Error> {
let mut conn = db.get().await?;
// Determine whether shares were stored as raw keys (threshold=1) or vsss-rs splits (threshold>=2).
let threshold = load_threshold(&mut conn).await?;
let mut shares: Vec<Vec<u8>> = Vec::new();
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
schema::operator::table
.filter(schema::operator::id.eq(Some(operator_id_raw)))
.select((
schema::operator::share,
schema::operator::share_nonce,
schema::operator::share_salt,
))
.first(&mut conn)
.await
.map_err(|_| Error::OperatorNotFound)?;
shares.push(decrypt_share(
passphrase_bytes,
encrypted_share,
&share_nonce_bytes,
&share_salt,
operator_id_raw,
)?);
}
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
schema::recovery_operator::table
.find(recovery_id_raw)
.select((
schema::recovery_operator::share,
schema::recovery_operator::share_nonce,
schema::recovery_operator::share_salt,
))
.first(&mut conn)
.await
.map_err(|_| Error::OperatorNotFound)?;
shares.push(decrypt_share(
passphrase_bytes,
encrypted_share,
&share_nonce_bytes,
&share_salt,
recovery_id_raw,
)?);
}
// When threshold==1, shares are raw 32-byte seal keys (vsss-rs cannot split 1-of-N).
// Any single decrypted share is the key itself.
let seal_key_bytes: [u8; 32] = if threshold <= 1 {
let raw = shares
.into_iter()
.next()
.ok_or_else(|| Error::Shamir("No shares available".into()))?;
raw.try_into()
.map_err(|_| Error::Shamir("Invalid share length".into()))?
} else {
shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))?
};
let seal_key = KeyCell::from(seal_key_bytes);
vault.ask(TryUnseal { seal_key }).await.map_err(|err| {
error!(?err, "Vault unseal failed");
Error::VaultError
})?;
Ok(())
}
/// §3.3: Generate a fresh seal key, split across current operators, re-encrypt the vault root key.
/// Called after `replace_operator` or `trigger_rekey` is approved and all contributors submit.
async fn finalize_rekey(
db: db::DatabasePool,
vault: ActorRef<Vault>,
ordinary_passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
) -> Result<(), Error> {
let ordinary_count = ordinary_passphrases.len();
let recovery_count = recovery_passphrases.len();
let total = ordinary_count + recovery_count;
let threshold = shamir_threshold(ordinary_count).ok_or(Error::EmptyCommittee)?;
let mut new_seal_key_bytes = [0u8; 32];
OsRng.fill_bytes(&mut new_seal_key_bytes);
let shares: Vec<Vec<u8>> = if threshold >= 2 {
shamir::split_key(threshold, total, &new_seal_key_bytes, OsRng)
.map_err(|e| Error::Shamir(e.to_string()))?
} else {
std::iter::repeat_with(|| new_seal_key_bytes.to_vec())
.take(total)
.collect()
};
let mut conn = db.get().await?;
let mut shares_iter = shares.into_iter();
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
let share = shares_iter
.next()
.expect("split_key returned enough shares");
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
diesel::replace_into(schema::operator::table)
.values((
schema::operator::id.eq(Some(operator_id_raw)),
schema::operator::share.eq(&encrypted_share),
schema::operator::share_nonce.eq(&nonce_bytes),
schema::operator::share_salt.eq(&share_salt),
schema::operator::created_at.eq(models::SqliteTimestamp::now()),
schema::operator::updated_at.eq(models::SqliteTimestamp::now()),
))
.execute(&mut conn)
.await?;
}
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
let share = shares_iter
.next()
.expect("split_key returned enough shares");
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
diesel::replace_into(schema::recovery_operator::table)
.values((
schema::recovery_operator::id.eq(recovery_id_raw),
schema::recovery_operator::share.eq(&encrypted_share),
schema::recovery_operator::share_nonce.eq(&nonce_bytes),
schema::recovery_operator::share_salt.eq(&share_salt),
schema::recovery_operator::created_at.eq(models::SqliteTimestamp::now()),
schema::recovery_operator::updated_at.eq(models::SqliteTimestamp::now()),
))
.execute(&mut conn)
.await?;
}
store_threshold(&mut conn, threshold).await?;
drop(conn);
let new_seal_key = KeyCell::from(new_seal_key_bytes);
vault
.ask(RekeyRootKey { new_seal_key })
.await
.map_err(|err| {
error!(?err, "Vault rekey failed");
Error::VaultError
})?;
Ok(())
}
#[messages]
impl VaultCoordinator {
/// Phase 1 of multi-operator bootstrap: declare the committee size.
#[message]
#[expect(clippy::unused_async, reason = "kameo requires messages to be async")]
pub async fn start_bootstrap(
&mut self,
operator_id: i32,
declared_count: usize,
recovery_count: usize,
) -> Result<(), Error> {
let _ = operator_id; // fixme!: any authenticated operator may announce the committee size. the first call wins
if !matches!(self.state, CoordinatorState::Idle) {
return Err(Error::AlreadyBootstrapping);
}
if declared_count == 0 {
return Err(Error::EmptyCommittee);
}
if declared_count == 2 && recovery_count == 0 {
return Err(Error::TwoOperatorsRequireRecovery);
}
self.state = CoordinatorState::Bootstrapping {
declared_count,
recovery_count,
passphrases: HashMap::new(),
recovery_passphrases: HashMap::new(),
};
Ok(())
}
/// Phase 2 of multi-operator bootstrap: ordinary operator contributes a passphrase.
/// Returns Ok(true) when all ordinary + recovery operators contributed and bootstrap finalized.
#[message]
pub async fn contribute_bootstrap(
&mut self,
operator_id: i32,
mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
let CoordinatorState::Bootstrapping {
declared_count,
recovery_count,
passphrases,
recovery_passphrases,
} = &mut self.state
else {
return Err(Error::NotBootstrapping);
};
if passphrases.contains_key(&operator_id) {
return Err(Error::DuplicateContribution);
}
let passphrase_bytes = passphrase.read().to_vec();
passphrases.insert(operator_id, passphrase_bytes);
if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count {
return Ok(false);
}
let CoordinatorState::Bootstrapping {
passphrases,
recovery_passphrases,
..
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
else {
unreachable!()
};
finalize_bootstrap(
self.db.clone(),
self.vault.clone(),
passphrases,
recovery_passphrases,
)
.await?;
Ok(true)
}
/// Phase 2 of multi-operator bootstrap: recovery operator contributes a passphrase.
/// Returns Ok(true) when all contributors are in and bootstrap finalized.
#[message]
pub async fn contribute_recovery_bootstrap(
&mut self,
recovery_operator_id: i32,
mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
let CoordinatorState::Bootstrapping {
declared_count,
recovery_count,
passphrases,
recovery_passphrases,
} = &mut self.state
else {
return Err(Error::NotBootstrapping);
};
if recovery_passphrases.contains_key(&recovery_operator_id) {
return Err(Error::DuplicateContribution);
}
let passphrase_bytes = passphrase.read().to_vec();
recovery_passphrases.insert(recovery_operator_id, passphrase_bytes);
if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count {
return Ok(false);
}
let CoordinatorState::Bootstrapping {
passphrases,
recovery_passphrases,
..
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
else {
unreachable!()
};
finalize_bootstrap(
self.db.clone(),
self.vault.clone(),
passphrases,
recovery_passphrases,
)
.await?;
Ok(true)
}
/// Contribute a passphrase for vault unseal (ordinary operator).
/// Returns Ok(true) when threshold reached and vault is unsealed.
#[message]
pub async fn contribute_unseal(
&mut self,
operator_id: i32,
mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
self.ensure_unsealing_state().await?;
let CoordinatorState::Unsealing {
threshold,
ordinary_passphrases,
recovery_passphrases,
} = &mut self.state
else {
return Err(Error::NotUnsealing);
};
if ordinary_passphrases.contains_key(&operator_id) {
return Err(Error::DuplicateContribution);
}
let passphrase_bytes = passphrase.read().to_vec();
ordinary_passphrases.insert(operator_id, passphrase_bytes);
if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold {
return Ok(false);
}
self.do_finalize_unseal().await
}
/// Contribute a passphrase for vault unseal (recovery operator, §3.5).
/// Recovery operators may contribute during unseal when recovery is active.
/// Returns Ok(true) when threshold reached and vault is unsealed.
#[message]
pub async fn contribute_recovery_unseal(
&mut self,
recovery_operator_id: i32,
mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
{
let mut conn = self.db.get().await?;
if !db::recovery::is_active(&mut conn).await? {
return Err(Error::RecoveryNotActive);
}
}
self.ensure_unsealing_state().await?;
let CoordinatorState::Unsealing {
threshold,
ordinary_passphrases,
recovery_passphrases,
} = &mut self.state
else {
return Err(Error::NotUnsealing);
};
if recovery_passphrases.contains_key(&recovery_operator_id) {
return Err(Error::DuplicateContribution);
}
let passphrase_bytes = passphrase.read().to_vec();
recovery_passphrases.insert(recovery_operator_id, passphrase_bytes);
if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold {
return Ok(false);
}
self.do_finalize_unseal().await
}
}
impl VaultCoordinator {
/// Initializes `CoordinatorState::Unsealing` on first call if still `Idle`.
/// Threshold comes from the recorded split parameters (§3.4), not from a live row count.
async fn ensure_unsealing_state(&mut self) -> Result<(), Error> {
if matches!(self.state, CoordinatorState::Idle) {
let mut conn = self.db.get().await?;
let threshold = load_threshold(&mut conn).await?;
drop(conn);
self.state = CoordinatorState::Unsealing {
threshold,
ordinary_passphrases: HashMap::new(),
recovery_passphrases: HashMap::new(),
};
}
Ok(())
}
/// Moves state back to Idle and calls finalize_unseal.
async fn do_finalize_unseal(&mut self) -> Result<bool, Error> {
let CoordinatorState::Unsealing {
ordinary_passphrases,
recovery_passphrases,
..
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
else {
unreachable!()
};
finalize_unseal(
self.db.clone(),
self.vault.clone(),
ordinary_passphrases,
recovery_passphrases,
)
.await?;
Ok(true)
}
async fn do_finalize_rekey(&mut self) -> Result<bool, Error> {
let CoordinatorState::Rekeying {
passphrases,
recovery_passphrases,
..
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
else {
unreachable!()
};
finalize_rekey(
self.db.clone(),
self.vault.clone(),
passphrases,
recovery_passphrases,
)
.await?;
Ok(true)
}
}
#[messages]
impl VaultCoordinator {
/// Begin Shamir re-key after a key-rotation proposal is approved (§3.3).
/// Queries the current operator and recovery operator counts from the DB,
/// then transitions to Rekeying state awaiting contributions from all of them.
#[message]
pub async fn start_rekey(&mut self) -> Result<(), Error> {
self.ensure_idle()?;
let mut conn = self.db.get().await?;
let ordinary_count: i64 = schema::operator_identity::table
.count()
.get_result(&mut conn)
.await?;
let recovery_count: i64 = schema::recovery_operator_identity::table
.count()
.get_result(&mut conn)
.await?;
self.state = CoordinatorState::Rekeying {
ordinary_count: ordinary_count as usize,
recovery_count: recovery_count as usize,
passphrases: HashMap::new(),
recovery_passphrases: HashMap::new(),
};
Ok(())
}
/// Contribute an ordinary operator passphrase for the re-key.
/// Returns Ok(true) when all contributors have submitted and the re-key is complete.
#[message]
pub async fn contribute_rekey(
&mut self,
operator_id: i32,
mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
let CoordinatorState::Rekeying {
ordinary_count,
recovery_count,
passphrases,
recovery_passphrases,
} = &mut self.state
else {
return Err(Error::NotRekeying);
};
if passphrases.contains_key(&operator_id) {
return Err(Error::DuplicateContribution);
}
passphrases.insert(operator_id, passphrase.read().to_vec());
if passphrases.len() < *ordinary_count || recovery_passphrases.len() < *recovery_count {
return Ok(false);
}
self.do_finalize_rekey().await
}
/// Contribute a recovery operator passphrase for the re-key.
/// Returns Ok(true) when all contributors have submitted and the re-key is complete.
#[message]
pub async fn contribute_recovery_rekey(
&mut self,
recovery_operator_id: i32,
mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
let CoordinatorState::Rekeying {
ordinary_count,
recovery_count,
passphrases,
recovery_passphrases,
} = &mut self.state
else {
return Err(Error::NotRekeying);
};
if recovery_passphrases.contains_key(&recovery_operator_id) {
return Err(Error::DuplicateContribution);
}
recovery_passphrases.insert(recovery_operator_id, passphrase.read().to_vec());
if passphrases.len() < *ordinary_count || recovery_passphrases.len() < *recovery_count {
return Ok(false);
}
self.do_finalize_rekey().await
}
}
impl Message<ProposalApproved> for VaultCoordinator {
type Reply = ();
/// Every subscriber sees every approval and acts only on the kinds it owns.
async fn handle(
&mut self,
msg: ProposalApproved,
_ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
) -> Self::Reply {
let result = match msg.kind {
ProposalKind::ReplaceOperator(settings) => self.replace_operator(&settings).await,
ProposalKind::TriggerRekey => self.start_rekey().await,
_ => return,
};
if let Err(error) = result {
error!(
?error,
proposal_id = msg.id.to_raw(),
"Failed to execute an approved proposal"
);
}
}
}
impl VaultCoordinator {
/// The coordinator runs one ceremony at a time; anything that starts a new one has to say
/// so before it changes any state the ceremony depends on.
const fn ensure_idle(&self) -> Result<(), Error> {
if matches!(self.state, CoordinatorState::Idle) {
Ok(())
} else {
Err(Error::AlreadyBootstrapping)
}
}
/// Replaces the operator's public key in place, keeping their id and history, drops the
/// share that key no longer matches, then begins a coordinated re-key (§3.3).
async fn replace_operator(
&mut self,
settings: &replace_operator::Settings,
) -> Result<(), Error> {
// Checked before anything is written. The re-key is what gives the replaced operator
// a share they can use; if the coordinator is mid-ceremony, `start_rekey` refuses, and
// swapping the key and destroying the share first would leave that operator locked
// out with no re-key running and nothing to undo it -- the caller only logs the error.
self.ensure_idle()?;
let mut conn = self.db.get().await?;
diesel::update(schema::operator_identity::table)
.filter(schema::operator_identity::id.eq(settings.old_operator_id))
.set(schema::operator_identity::public_key.eq(&settings.new_pubkey))
.execute(&mut conn)
.await?;
// Drop the stale Shamir share; finalize_rekey stores a fresh one.
diesel::delete(schema::operator::table)
.filter(schema::operator::id.eq(Some(settings.old_operator_id)))
.execute(&mut conn)
.await?;
drop(conn);
self.start_rekey().await
}
}
#[cfg(test)]
mod tests {
use super::{CoordinatorState, Error, VaultCoordinator};
use crate::{
actors::{GlobalActors, vault::Vault},
db::{self, models::OperatorIdentityId, proposal::replace_operator, schema},
};
use diesel::{ExpressionMethods as _, QueryDsl as _, dsl::insert_into};
use diesel_async::RunQueryDsl;
use kameo::actor::Spawn as _;
use std::collections::HashMap;
/// An approved `ReplaceOperator` that arrives while another ceremony is running must
/// change nothing. Swapping the public key and deleting the share are only safe because a
/// re-key follows and hands the operator a share for the new key; when `start_rekey`
/// refuses, the operator would otherwise be left holding a key with no share, and the
/// caller does nothing with the error but log it.
#[tokio::test]
async fn a_refused_rekey_leaves_the_operator_untouched() {
let pool = db::create_test_pool().await;
let mut conn = pool.get().await.unwrap();
let old_key = rand::random::<[u8; 32]>().to_vec();
let operator_id: OperatorIdentityId = insert_into(schema::operator_identity::table)
.values(schema::operator_identity::public_key.eq(&old_key))
.returning(schema::operator_identity::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(schema::operator::table)
.values((
schema::operator::id.eq(Some(operator_id)),
schema::operator::share.eq(vec![1u8; 32]),
schema::operator::share_nonce.eq(vec![2u8; 24]),
schema::operator::share_salt.eq(vec![3u8; 32]),
))
.execute(&mut conn)
.await
.unwrap();
drop(conn);
let vault = Vault::spawn(
Vault::new(pool.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap(),
);
let mut coordinator = VaultCoordinator::new(pool.clone(), vault);
coordinator.state = CoordinatorState::Rekeying {
ordinary_count: 2,
recovery_count: 0,
passphrases: HashMap::new(),
recovery_passphrases: HashMap::new(),
};
let result = coordinator
.replace_operator(&replace_operator::Settings {
old_operator_id: operator_id,
new_pubkey: vec![9u8; 32],
})
.await;
assert!(
matches!(result, Err(Error::AlreadyBootstrapping)),
"a busy coordinator must refuse the replacement, got {result:?}"
);
let mut conn = pool.get().await.unwrap();
let stored_key: Vec<u8> = schema::operator_identity::table
.find(operator_id)
.select(schema::operator_identity::public_key)
.first(&mut conn)
.await
.unwrap();
assert_eq!(
stored_key, old_key,
"the public key must not be swapped when no re-key can follow"
);
let shares: i64 = schema::operator::table
.filter(schema::operator::id.eq(Some(operator_id)))
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(shares, 1, "the operator's share must not be destroyed");
}
}

View File

@@ -1,13 +1,12 @@
use std::sync::Arc;
use thiserror::Error;
use crate::{
actors::GlobalActors,
context::tls::TlsManager,
db::{self},
};
use std::sync::Arc;
use thiserror::Error;
pub mod tls;
#[derive(Error, Debug)]
@@ -31,16 +30,16 @@ pub enum InitError {
Io(#[from] std::io::Error),
}
pub struct _ServerContextInner {
pub struct __ServerContextInner {
pub db: db::DatabasePool,
pub tls: TlsManager,
pub actors: GlobalActors,
}
#[derive(Clone)]
pub struct ServerContext(Arc<_ServerContextInner>);
pub struct ServerContext(Arc<__ServerContextInner>);
impl std::ops::Deref for ServerContext {
type Target = _ServerContextInner;
type Target = __ServerContextInner;
fn deref(&self) -> &Self::Target {
&self.0
@@ -49,7 +48,7 @@ impl std::ops::Deref for ServerContext {
impl ServerContext {
pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> {
Ok(Self(Arc::new(_ServerContextInner {
Ok(Self(Arc::new(__ServerContextInner {
actors: GlobalActors::spawn(db.clone()).await?,
tls: TlsManager::new(db.clone()).await?,
db,

View File

@@ -1,17 +1,3 @@
use std::{net::Ipv4Addr, string::FromUtf8Error};
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _};
use diesel_async::{AsyncConnection, RunQueryDsl};
use pem::Pem;
use rcgen::{
BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType,
IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
};
use rustls::pki_types::pem::PemObject;
use thiserror::Error;
use tonic::transport::CertificateDer;
use crate::db::{
self,
models::{NewTlsHistory, TlsHistory},
@@ -21,10 +7,23 @@ use crate::db::{
},
};
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _};
use diesel_async::{AsyncConnection, RunQueryDsl};
use pem::Pem;
use rcgen::{
BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType,
IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
};
use rustls::pki_types::pem::PemObject;
use std::{net::Ipv4Addr, string::FromUtf8Error};
use thiserror::Error;
use tonic::transport::CertificateDer;
const ENCODE_CONFIG: pem::EncodeConfig = {
let line_ending = match cfg!(target_family = "windows") {
true => pem::LineEnding::CRLF,
false => pem::LineEnding::LF,
let line_ending = if cfg!(target_family = "windows") {
pem::LineEnding::CRLF
} else {
pem::LineEnding::LF
};
pem::EncodeConfig::new().set_line_ending(line_ending)
};
@@ -52,11 +51,14 @@ pub enum InitError {
pub type PemCert = String;
pub fn encode_cert_to_pem(cert: &CertificateDer) -> PemCert {
pub fn encode_cert_to_pem(cert: &CertificateDer<'_>) -> PemCert {
pem::encode_config(&Pem::new("CERTIFICATE", cert.to_vec()), ENCODE_CONFIG)
}
#[allow(unused)]
#[expect(
unused,
reason = "may be needed for future cert rotation implementation"
)]
struct SerializedTls {
cert_pem: PemCert,
cert_key_pem: String,
@@ -85,7 +87,7 @@ impl TlsCa {
let cert_key_pem = certified_issuer.key().serialize_pem();
#[allow(
#[expect(
clippy::unwrap_used,
reason = "Broken cert couldn't bootstrap server anyway"
)]
@@ -124,7 +126,11 @@ impl TlsCa {
})
}
#[allow(unused)]
#[expect(
unused,
clippy::unnecessary_wraps,
reason = "may be needed for future cert rotation implementation"
)]
fn serialize(&self) -> Result<SerializedTls, InitError> {
let cert_key_pem = self.issuer.key().serialize_pem();
Ok(SerializedTls {
@@ -133,7 +139,10 @@ impl TlsCa {
})
}
#[allow(unused)]
#[expect(
unused,
reason = "may be needed for future cert rotation implementation"
)]
fn try_deserialize(cert_pem: &str, cert_key_pem: &str) -> Result<Self, InitError> {
let keypair =
KeyPair::from_pem(cert_key_pem).map_err(InitError::KeyDeserializationError)?;
@@ -165,28 +174,26 @@ impl TlsManager {
{
let mut conn = db.get().await?;
conn.transaction(|conn| {
Box::pin(async {
let new_tls_history = NewTlsHistory {
cert: new_cert.cert.pem(),
cert_key: new_cert.cert_key.serialize_pem(),
ca_cert: encode_cert_to_pem(&ca.cert),
ca_key: ca.issuer.key().serialize_pem(),
};
conn.transaction(async |conn| {
let new_tls_history = NewTlsHistory {
cert: new_cert.cert.pem(),
cert_key: new_cert.cert_key.serialize_pem(),
ca_cert: encode_cert_to_pem(&ca.cert),
ca_key: ca.issuer.key().serialize_pem(),
};
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table)
.values(&new_tls_history)
.returning(tls_history::id)
.get_result(conn)
.await?;
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table)
.values(&new_tls_history)
.returning(tls_history::id)
.get_result(&mut *conn)
.await?;
diesel::update(arbiter_settings::table)
.set(arbiter_settings::tls_id.eq(inserted_tls_history))
.execute(conn)
.await?;
diesel::update(arbiter_settings::table)
.set(arbiter_settings::tls_id.eq(inserted_tls_history))
.execute(&mut *conn)
.await?;
Result::<_, diesel::result::Error>::Ok(())
})
Result::<_, diesel::result::Error>::Ok(())
})
.await?;
}
@@ -234,10 +241,10 @@ impl TlsManager {
}
}
pub fn cert(&self) -> &CertificateDer<'static> {
pub const fn cert(&self) -> &CertificateDer<'static> {
&self.cert
}
pub fn ca_cert(&self) -> &CertificateDer<'static> {
pub const fn ca_cert(&self) -> &CertificateDer<'static> {
&self.ca_cert
}

View File

@@ -1,12 +1,11 @@
use argon2::password_hash::Salt as ArgonSalt;
use rand::{
Rng as _, SeedableRng,
rngs::{StdRng, SysRng},
};
pub const ROOT_KEY_TAG: &[u8] = "arbiter/seal/v1".as_bytes();
pub const TAG: &[u8] = "arbiter/private-key/v1".as_bytes();
pub const ROOT_KEY_TAG: &[u8] = b"arbiter/seal/v1";
pub const TAG: &[u8] = b"arbiter/private-key/v1";
pub const NONCE_LENGTH: usize = 24;
@@ -15,11 +14,13 @@ pub struct Nonce(pub [u8; NONCE_LENGTH]);
impl Nonce {
pub fn increment(&mut self) {
for i in (0..self.0.len()).rev() {
if self.0[i] == 0xFF {
self.0[i] = 0;
} else {
self.0[i] += 1;
break;
if let Some(byte) = self.0.get_mut(i) {
if *byte == 0xFF {
*byte = 0;
} else {
*byte += 1;
break;
}
}
}
}
@@ -45,55 +46,49 @@ pub type Salt = [u8; ArgonSalt::RECOMMENDED_LENGTH];
pub fn generate_salt() -> Salt {
let mut salt = Salt::default();
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
let mut rng =
StdRng::try_from_rng(&mut SysRng).expect("Rng failure is unrecoverable and should panic");
rng.fill_bytes(&mut salt);
salt
}
#[cfg(test)]
mod tests {
use std::ops::Deref as _;
use super::*;
use crate::crypto::derive_key;
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
#[test]
pub fn derive_seal_key_deterministic() {
fn derive_seal_key_deterministic() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let password2 = SafeCell::new(PASSWORD.to_vec());
let mut password = SafeCell::new(PASSWORD.to_vec());
let mut password2 = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key1 = derive_key(password, &salt);
let mut key2 = derive_key(password2, &salt);
let mut key1 = derive_key(&mut password, &salt);
let mut key2 = derive_key(&mut password2, &salt);
let key1_reader = key1.0.read();
let key2_reader = key2.0.read();
assert_eq!(key1_reader.deref(), key2_reader.deref());
assert_eq!(&*key1_reader, &*key2_reader);
}
#[test]
pub fn successful_derive() {
fn successful_derive() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let mut password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_key(password, &salt);
let mut key = derive_key(&mut password, &salt);
let key_reader = key.0.read();
let key_ref = key_reader.deref();
assert_ne!(key_ref.as_slice(), &[0u8; 32][..]);
assert_ne!(key_reader.as_slice(), &[0u8; 32][..]);
}
#[test]
// We should fuzz this
pub fn test_nonce_increment() {
pub fn nonce_increment() {
let mut nonce = Nonce([0u8; NONCE_LENGTH]);
nonce.increment();

View File

@@ -0,0 +1,99 @@
//! Canonical encoding and verification of governance vote signatures (§3.3).
use crate::db::models::ProposalId;
use arbiter_crypto::authn::{self, SigningContext};
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum VerifyError {
#[error("Malformed operator public key")]
PublicKey,
#[error("Malformed vote signature")]
Signature,
#[error("Signature does not match this vote")]
Mismatch,
}
/// Canonical bytes an operator signs when voting: `proposal_id` as i64 big-endian,
/// followed by the approve flag as one byte.
///
/// The flag is part of the message on purpose: without it an approval could be
/// replayed as a rejection of the same proposal.
#[must_use]
pub fn vote_message(proposal_id: ProposalId, approve: bool) -> Vec<u8> {
let mut message = Vec::with_capacity(9);
message.extend_from_slice(&i64::from(proposal_id.to_raw()).to_be_bytes());
message.push(u8::from(approve));
message
}
/// Verifies a vote signature against an operator's stored public key.
pub fn verify_vote(
public_key: &[u8],
proposal_id: ProposalId,
approve: bool,
signature: &[u8],
) -> Result<(), VerifyError> {
let public_key = authn::PublicKey::try_from(public_key).map_err(|()| VerifyError::PublicKey)?;
let signature = authn::Signature::try_from(signature).map_err(|()| VerifyError::Signature)?;
if public_key.verify_message(
&vote_message(proposal_id, approve),
SigningContext::GovernanceVote,
&signature,
) {
Ok(())
} else {
Err(VerifyError::Mismatch)
}
}
#[cfg(test)]
mod tests {
use super::{VerifyError, verify_vote, vote_message};
use crate::db::models::ProposalId;
use arbiter_crypto::authn::{SigningContext, SigningKey};
#[test]
fn vote_message_is_the_id_then_the_approve_flag() {
let message = vote_message(ProposalId::from_raw(0x0102), true);
assert_eq!(message, vec![0, 0, 0, 0, 0, 0, 1, 2, 1]);
}
#[test]
fn verify_vote_accepts_a_matching_signature() {
let key = SigningKey::generate();
let id = ProposalId::from_raw(42);
let signature = key
.sign_message(&vote_message(id, true), SigningContext::GovernanceVote)
.unwrap();
verify_vote(
&key.public_key().to_bytes(),
id,
true,
&signature.to_bytes(),
)
.expect("a signature over this exact vote must verify");
}
/// The decisive one: an approval must not verify as a rejection of the same
/// proposal, or a captured vote could be replayed with its meaning flipped.
#[test]
fn verify_vote_rejects_a_flipped_approve_flag() {
let key = SigningKey::generate();
let id = ProposalId::from_raw(42);
let signature = key
.sign_message(&vote_message(id, true), SigningContext::GovernanceVote)
.unwrap();
assert!(matches!(
verify_vote(
&key.public_key().to_bytes(),
id,
false,
&signature.to_bytes()
),
Err(VerifyError::Mismatch)
));
}
}

View File

@@ -1,25 +1,18 @@
use crate::{
actors::vault::{self, GetState},
crypto::integrity::hashing::Hashable,
};
use hmac::Hmac;
use sha2::Sha256;
use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{actor::ActorRef, error::SendError};
use sha2::Digest as _;
pub mod hashing;
use crate::{
actors::vault::{SignIntegrity, Vault, VerifyIntegrity},
actors::vault::{self, GetState, SignIntegrity, Vault, VerifyIntegrity},
db::{
self,
models::{IntegrityEnvelope, NewIntegrityEnvelope},
models::{IntegrityEnvelope, NewIntegrityEnvelope, RootKeyHistoryId},
schema::integrity_envelope,
},
};
use arbiter_crypto::hashing::Hashable;
use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::Hmac;
use kameo::{actor::ActorRef, error::SendError};
use sha2::{Digest as _, Sha256};
#[derive(Debug, thiserror::Error)]
pub enum Error {
@@ -71,6 +64,11 @@ fn payload_hash(payload: &impl Hashable) -> [u8; 32] {
}
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #85"
)]
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
@@ -111,28 +109,49 @@ pub async fn sign_entity<E: Integrable>(
entity: &E,
entity_id: impl IntoId,
) -> Result<(), Error> {
let payload_hash = payload_hash(&entity);
let entity_id = entity_id.into_id();
let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash);
let (entity_id, mac_input) = envelope_input::<E>(entity, entity_id);
let (key_version, mac) =
vault
.ask(SignIntegrity { mac_input })
.await
.map_err(|err| match err {
kameo::error::SendError::HandlerError(inner) => Error::Vault(inner),
SendError::HandlerError(inner) => Error::Vault(inner),
_ => Error::VaultSend,
})?;
store_envelope::<E>(conn, entity_id, key_version, mac)
.await
.map_err(db::DatabaseError::from)?;
Ok(())
}
/// The entity id and the bytes the root key covers, as a pair.
///
/// Split out of [`sign_entity`] so the `Vault` actor can build an envelope from inside a
/// message handler, where asking itself for a signature would deadlock.
pub fn envelope_input<E: Integrable>(entity: &E, entity_id: impl IntoId) -> (Vec<u8>, Vec<u8>) {
let payload_hash = payload_hash(entity);
let entity_id = entity_id.into_id();
let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash);
(entity_id, mac_input)
}
/// Stores the integrity envelope for one entity, replacing any envelope it already has.
pub async fn store_envelope<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
entity_id: Vec<u8>,
key_version: RootKeyHistoryId,
mac: Vec<u8>,
) -> Result<(), diesel::result::Error> {
insert_into(integrity_envelope::table)
.values(NewIntegrityEnvelope {
entity_kind: E::KIND.to_owned(),
entity_id,
payload_version: E::VERSION,
key_version,
mac: mac.to_vec(),
mac: mac.clone(),
})
.on_conflict((
integrity_envelope::entity_id,
@@ -145,8 +164,7 @@ pub async fn sign_entity<E: Integrable>(
integrity_envelope::mac.eq(mac),
))
.execute(conn)
.await
.map_err(db::DatabaseError::from)?;
.await?;
Ok(())
}
@@ -194,9 +212,7 @@ pub async fn verify_entity<E: Integrable>(
Ok(false) => Err(Error::MacMismatch {
entity_kind: E::KIND,
}),
Err(SendError::HandlerError(vault::Error::Sealed)) => {
Ok(AttestationStatus::Unavailable)
}
Err(SendError::HandlerError(vault::Error::Sealed)) => Ok(AttestationStatus::Unavailable),
Err(_) => Err(Error::VaultSend),
}
}
@@ -212,8 +228,6 @@ mod tests {
use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
use sha2::Digest;
use crate::{
actors::{
GlobalActors,
@@ -221,23 +235,12 @@ mod tests {
},
db::{self, schema},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use super::hashing::Hashable;
use super::{Error, Integrable, sign_entity, verify_entity};
#[derive(Clone)]
#[derive(Clone, arbiter_macros::Hashable)]
struct DummyEntity {
payload_version: i32,
payload: Vec<u8>,
}
impl Hashable for DummyEntity {
fn hash<H: Digest>(&self, hasher: &mut H) {
self.payload_version.hash(hasher);
self.payload.hash(hasher);
}
}
impl Integrable for DummyEntity {
const KIND: &'static str = "dummy_entity";
}
@@ -250,7 +253,7 @@ mod tests {
);
actor
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
seal_key: crate::crypto::KeyCell::from([0u8; 32]),
})
.await
.unwrap();
@@ -259,12 +262,12 @@ mod tests {
#[tokio::test]
async fn sign_writes_envelope_and_verify_passes() {
const ENTITY_ID: &[u8] = b"entity-id-7";
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-7";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
@@ -290,12 +293,12 @@ mod tests {
#[tokio::test]
async fn tampered_mac_fails_verification() {
const ENTITY_ID: &[u8] = b"entity-id-11";
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-11";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
@@ -321,12 +324,12 @@ mod tests {
#[tokio::test]
async fn changed_payload_fails_verification() {
const ENTITY_ID: &[u8] = b"entity-id-21";
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-21";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),

View File

@@ -1,4 +1,5 @@
use std::ops::Deref as _;
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use encryption::v1::Nonce;
use argon2::{Algorithm, Argon2};
use chacha20poly1305::{
@@ -10,12 +11,10 @@ use rand::{
rngs::{StdRng, SysRng},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
pub mod encryption;
pub mod governance;
pub mod integrity;
use encryption::v1::{Nonce, Salt};
pub mod shamir;
pub struct KeyCell(pub SafeCell<Key>);
impl From<SafeCell<Key>> for KeyCell {
@@ -23,6 +22,15 @@ 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_default(|key: &mut Key| {
key.copy_from_slice(&bytes);
});
Self(cell)
}
}
impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
type Error = ();
@@ -31,7 +39,7 @@ impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
if value.len() != size_of::<Key>() {
return Err(());
}
let cell = SafeCell::new_inline(|cell_write: &mut Key| {
let cell = SafeCell::new_inline_default(|cell_write: &mut Key| {
cell_write.copy_from_slice(&value);
});
Ok(Self(cell))
@@ -40,12 +48,9 @@ impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
impl KeyCell {
pub fn new_secure_random() -> Self {
let key = SafeCell::new_inline(|key_buffer: &mut Key| {
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
let key = SafeCell::new_inline_default(|key_buffer: &mut Key| {
let mut rng = StdRng::try_from_rng(&mut SysRng)
.expect("Rng failure is unrecoverable and should panic");
rng.fill_bytes(key_buffer);
});
@@ -59,8 +64,7 @@ impl KeyCell {
mut buffer: impl AsMut<Vec<u8>>,
) -> Result<(), Error> {
let key_reader = self.0.read();
let key_ref = key_reader.deref();
let cipher = XChaCha20Poly1305::new(key_ref);
let cipher = XChaCha20Poly1305::new(&key_reader);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let buffer = buffer.as_mut();
cipher.encrypt_in_place(nonce, associated_data, buffer)
@@ -72,8 +76,7 @@ impl KeyCell {
buffer: &mut SafeCell<Vec<u8>>,
) -> Result<(), Error> {
let key_reader = self.0.read();
let key_ref = key_reader.deref();
let cipher = XChaCha20Poly1305::new(key_ref);
let cipher = XChaCha20Poly1305::new(&key_reader);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let mut buffer = buffer.write();
let buffer: &mut Vec<u8> = buffer.as_mut();
@@ -87,8 +90,7 @@ impl KeyCell {
plaintext: impl AsRef<[u8]>,
) -> Result<Vec<u8>, Error> {
let key_reader = self.0.read();
let key_ref = key_reader.deref();
let mut cipher = XChaCha20Poly1305::new(key_ref);
let mut cipher = XChaCha20Poly1305::new(&key_reader);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let ciphertext = cipher.encrypt(
@@ -103,7 +105,7 @@ impl 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 {
pub fn derive_key(password: &mut SafeCell<Vec<u8>>, salt: &[u8]) -> KeyCell {
let params = {
#[cfg(debug_assertions)]
{
@@ -116,20 +118,15 @@ pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
}
};
#[allow(clippy::unwrap_used)]
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = SafeCell::new(Key::default());
password.read_inline(|password_source| {
let mut key_buffer = key.write();
let key_buffer: &mut [u8] = key_buffer.as_mut();
#[allow(
clippy::unwrap_used,
reason = "Better fail completely than return a weak key"
)]
hasher
.hash_password_into(password_source.deref(), salt, key_buffer)
.unwrap();
.hash_password_into(password_source, salt, key_buffer)
.expect("Better fail completely than return a weak key");
});
key.into()
@@ -144,12 +141,12 @@ mod tests {
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
#[test]
pub fn encrypt_decrypt() {
fn encrypt_decrypt() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let mut password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_key(password, &salt);
let mut key = derive_key(&mut 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

@@ -0,0 +1,60 @@
use vsss_rs::Gf256;
#[derive(Debug, thiserror::Error)]
pub enum ShamirError {
#[error("Failed to split key: {0}")]
Split(String),
#[error("Failed to combine shares: {0}")]
Combine(String),
}
/// Split `key` into `total` shares where any `threshold` shares can reconstruct it.
/// Each returned Vec<u8> is a share with format [`identifier_byte`, `value_bytes`...].
pub fn split_key(
threshold: usize,
total: usize,
key: &[u8; 32],
rng: impl rand_core::RngCore + rand_core::CryptoRng,
) -> Result<Vec<Vec<u8>>, ShamirError> {
Gf256::split_array(threshold, total, key.as_slice(), rng)
.map_err(|e| ShamirError::Split(format!("{e:?}")))
}
/// Returns the minimum number of shares required to reconstruct the secret
/// for a committee of `n` operators, or `None` for an empty committee.
#[must_use]
pub const fn shamir_threshold(n: usize) -> Option<usize> {
match n {
0 => None,
1 => Some(1),
2 => Some(2),
n => Some(n / 2 + 1),
}
}
/// Reconstruct the secret from `threshold` or more shares.
pub fn combine_shares(shares: &[Vec<u8>]) -> Result<[u8; 32], ShamirError> {
let bytes = Gf256::combine_array(shares)
.map_err(|e| ShamirError::Combine(format!("{e:?}")))?;
<[u8; 32]>::try_from(bytes.as_slice())
.map_err(|_| ShamirError::Combine("unexpected reconstructed key length".to_owned()))
}
#[cfg(test)]
mod tests {
use super::shamir_threshold;
#[test]
fn empty_committee_has_no_threshold() {
assert_eq!(shamir_threshold(0), None);
}
#[test]
fn threshold_follows_the_ordinary_quorum() {
// ARCHITECTURE.md §3.1/§3.4: 1 decides alone, 2 need consensus, N needs N/2 + 1.
assert_eq!(shamir_threshold(1), Some(1));
assert_eq!(shamir_threshold(2), Some(2));
assert_eq!(shamir_threshold(3), Some(2));
assert_eq!(shamir_threshold(4), Some(3));
}
}

View File

@@ -0,0 +1,12 @@
//! Typed bindings for the SQLite scalar functions used in Diesel expressions.
use diesel::sql_types::Text;
diesel::define_sql_function! {
/// SQLite `unixepoch(modifier)` -- seconds since the Unix epoch.
///
/// Declared so timestamp comparisons are built by the query DSL instead of by
/// `format!`-ing a SQL fragment: the argument becomes a bind parameter and the
/// result type is checked against the column it is compared with.
fn unixepoch(modifier: Text) -> Integer;
}

View File

@@ -5,11 +5,13 @@ use diesel_async::{
sync_connection_wrapper::SyncConnectionWrapper,
};
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use thiserror::Error;
use tracing::info;
pub mod functions;
pub mod models;
pub mod proposal;
pub mod recovery;
pub mod schema;
pub type DatabaseConnection = SyncConnectionWrapper<SqliteConnection>;
@@ -23,14 +25,14 @@ const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
#[derive(Error, Debug)]
pub enum DatabaseSetupError {
#[error("Failed to determine home directory")]
HomeDir(std::io::Error),
#[error(transparent)]
ConcurrencySetup(diesel::result::Error),
#[error(transparent)]
Connection(diesel::ConnectionError),
#[error(transparent)]
ConcurrencySetup(diesel::result::Error),
#[error("Failed to determine home directory")]
HomeDir(std::io::Error),
#[error(transparent)]
Migration(Box<dyn std::error::Error + Send + Sync>),
@@ -41,10 +43,11 @@ pub enum DatabaseSetupError {
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("Database connection error")]
Pool(#[from] PoolError),
#[error("Database query error")]
Connection(#[from] diesel::result::Error),
#[error("Database connection error")]
Pool(#[from] PoolError),
}
#[tracing::instrument(level = "info")]
@@ -56,25 +59,39 @@ fn database_path() -> Result<std::path::PathBuf, DatabaseSetupError> {
Ok(db_path)
}
/// The pragmas `SQLite` scopes to one connection. They are defined once and run on every
/// connection that reaches the database -- the migration connection below and each pooled
/// connection in `create_pool` -- because a value set on one connection is invisible to the
/// next, and every real write happens on a pooled one.
const CONNECTION_PRAGMAS: &str = "
-- sleep if the database is busy; this corresponds to up to 9 seconds sleeping time.
-- see https://fractaledmind.github.io/2023/09/07/enhancing-rails-sqlite-fine-tuning/
PRAGMA busy_timeout = 9000;
-- fsync only in critical moments
PRAGMA synchronous = NORMAL;
-- write WAL changes back every 1000 pages, for an in average 1MB WAL file.
-- May affect readers if number is increased
PRAGMA wal_autocheckpoint = 1000;
-- sqlite foreign keys are disabled by default, enable them for safety
PRAGMA foreign_keys = ON;
-- overwrite freed pages instead of leaving encrypted shares, nonces and salts
-- readable in the file
PRAGMA secure_delete = ON;
";
#[tracing::instrument(level = "info", skip(conn))]
fn db_config(conn: &mut SqliteConnection) -> Result<(), diesel::result::Error> {
// fsync only in critical moments
conn.batch_execute("PRAGMA synchronous = NORMAL;")?;
// write WAL changes back every 1000 pages, for an in average 1MB WAL file.
// May affect readers if number is increased
conn.batch_execute("PRAGMA wal_autocheckpoint = 1000;")?;
conn.batch_execute(CONNECTION_PRAGMAS)?;
// The rest belong to the database file rather than the connection, so the one-shot
// migration connection is the right and only place for them.
// free some space by truncating possibly massive WAL files from the last run
conn.batch_execute("PRAGMA wal_checkpoint(TRUNCATE);")?;
// sqlite foreign keys are disabled by default, enable them for safety
conn.batch_execute("PRAGMA foreign_keys = ON;")?;
// better space reclamation
conn.batch_execute("PRAGMA auto_vacuum = FULL;")?;
// secure delete, overwrite deleted content with zeros to prevent recovery
conn.batch_execute("PRAGMA secure_delete = ON;")?;
Ok(())
}
@@ -93,14 +110,22 @@ fn initialize_database(url: &str) -> Result<(), DatabaseSetupError> {
}
#[tracing::instrument(level = "info")]
/// Creates a connection pool for the `SQLite` database.
///
/// # Panics
/// Panics if the database path is not valid UTF-8.
pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetupError> {
let database_url = url.map(String::from).unwrap_or(
#[allow(clippy::expect_used)]
database_path()?
// Matched rather than `unwrap_or`, whose argument is evaluated even when `url` is `Some`:
// `database_path` resolves the real home directory and creates `~/.arbiter` as a side
// effect, so an eager call reaches the developer's home from every test that passes an
// explicit temp path, and fails outright wherever no home directory is writable.
let database_url = match url {
Some(url) => url.to_owned(),
None => database_path()?
.to_str()
.expect("database path is not valid UTF-8")
.to_string(),
);
.to_owned(),
};
initialize_database(&database_url)?;
@@ -109,13 +134,13 @@ pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetu
Box::pin(async move {
let mut conn = DatabaseConnection::establish(url).await?;
// see https://fractaledmind.github.io/2023/09/07/enhancing-rails-sqlite-fine-tuning/
// sleep if the database is busy, this corresponds to up to 9 seconds sleeping time.
conn.batch_execute("PRAGMA busy_timeout = 9000;")
// better write-concurrency; a property of the file, but harmless to reassert
conn.batch_execute("PRAGMA journal_mode = WAL;")
.await
.map_err(diesel::ConnectionError::CouldntSetupConfiguration)?;
// better write-concurrency
conn.batch_execute("PRAGMA journal_mode = WAL;")
// The migration connection setting these is not enough: SQLite scopes them to
// one connection, and every real query runs on a pooled one.
conn.batch_execute(CONNECTION_PRAGMAS)
.await
.map_err(diesel::ConnectionError::CouldntSetupConfiguration)?;
@@ -134,20 +159,100 @@ pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetu
}
#[mutants::skip]
#[expect(clippy::missing_panics_doc, reason = "Tests oriented function")]
/// Creates a test database pool with a temporary `SQLite` database file.
pub async fn create_test_pool() -> DatabasePool {
use rand::distr::{Alphanumeric, SampleString as _};
let tempfile_name = Alphanumeric.sample_string(&mut rand::rng(), 16);
let file = std::env::temp_dir().join(tempfile_name);
#[allow(clippy::expect_used)]
let url = file
.to_str()
.expect("temp file path is not valid UTF-8")
.to_string();
.to_owned();
#[allow(clippy::expect_used)]
create_pool(Some(&url))
.await
.expect("Failed to create test database pool")
}
#[cfg(test)]
mod tests {
use super::*;
use diesel::{
ExpressionMethods as _,
dsl::insert_into,
result::{DatabaseErrorKind, Error as DieselError},
};
use diesel_async::RunQueryDsl;
/// `operator.id` references `operator_identity(id)`. Without `PRAGMA foreign_keys = ON`
/// on the pooled connection, SQLite accepts a share row for an operator that does not exist.
#[tokio::test]
async fn pooled_connections_enforce_foreign_keys() {
let pool = create_test_pool().await;
let mut conn = pool.get().await.unwrap();
let result = insert_into(schema::operator::table)
.values((
schema::operator::id.eq(4242),
schema::operator::share.eq(vec![0u8; 32]),
schema::operator::share_nonce.eq(vec![0u8; 24]),
schema::operator::share_salt.eq(vec![0u8; 32]),
))
.execute(&mut conn)
.await;
// Specifically a foreign-key violation, not any error: a `NOT NULL` failure or a
// renamed column would also make `result.is_err()` true without proving the pragma
// is what rejected the insert.
assert!(
matches!(
result,
Err(DieselError::DatabaseError(
DatabaseErrorKind::ForeignKeyViolation,
_
))
),
"expected a foreign-key violation for a dangling operator_identity reference, got {result:?}"
);
}
#[derive(diesel::QueryableByName)]
struct PragmaValue {
#[diesel(sql_type = diesel::sql_types::Integer)]
value: i32,
}
async fn pragma(conn: &mut DatabaseConnection, name: &str) -> i32 {
diesel::sql_query(format!("select {name} as value from pragma_{name}()"))
.get_result::<PragmaValue>(conn)
.await
.unwrap()
.value
}
/// `foreign_keys` had to be repeated on the pooled connection because `SQLite` scopes it
/// there; its siblings in `CONNECTION_PRAGMAS` are scoped the same way and were being
/// left behind on the migration connection. `secure_delete` is the one that matters in a
/// key-custody database: off by default, it leaves freed pages holding encrypted shares,
/// nonces and salts readable in the file.
#[tokio::test]
async fn pooled_connections_carry_the_shared_pragmas() {
let pool = create_test_pool().await;
let mut conn = pool.get().await.unwrap();
assert_eq!(
pragma(&mut conn, "secure_delete").await,
1,
"freed pages must be overwritten on the connection that does the writing"
);
assert_eq!(
pragma(&mut conn, "synchronous").await,
1,
"synchronous must be NORMAL (1), not the default FULL (2)"
);
assert_eq!(pragma(&mut conn, "foreign_keys").await, 1);
}
}

View File

@@ -1,23 +1,26 @@
#![allow(unused)]
#![allow(clippy::all)]
#![allow(
clippy::duplicated_attributes,
reason = "restructed's #[view] causes false positives"
)]
use crate::db::schema::{
self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant,
evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant,
evm_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet,
integrity_envelope, root_key_history, tls_history,
};
use chrono::{DateTime, Utc};
use crate::db::proposal::ProposalKindTag;
use diesel::{prelude::*, sqlite::Sqlite};
use restructed::Models;
pub mod types {
use chrono::{DateTime, Utc};
use diesel::{
backend::Backend,
deserialize::{FromSql, FromSqlRow},
expression::AsExpression,
serialize::{IsNull, ToSql},
sql_types::Integer,
sql_types::{Integer, Text},
sqlite::{Sqlite, SqliteType},
};
@@ -27,16 +30,16 @@ pub mod types {
pub struct SqliteTimestamp(pub DateTime<Utc>);
impl SqliteTimestamp {
pub fn now() -> Self {
SqliteTimestamp(Utc::now())
Self(Utc::now())
}
}
impl From<chrono::DateTime<Utc>> for SqliteTimestamp {
fn from(dt: chrono::DateTime<Utc>) -> Self {
SqliteTimestamp(dt)
impl From<DateTime<Utc>> for SqliteTimestamp {
fn from(dt: DateTime<Utc>) -> Self {
Self(dt)
}
}
impl From<SqliteTimestamp> for chrono::DateTime<Utc> {
impl From<SqliteTimestamp> for DateTime<Utc> {
fn from(ts: SqliteTimestamp) -> Self {
ts.0
}
@@ -47,6 +50,11 @@ pub mod types {
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #84; this will break up in 2038 :3"
)]
let unix_timestamp = self.0.timestamp() as i32;
out.set_value(unix_timestamp);
Ok(IsNull::No)
@@ -55,7 +63,7 @@ pub mod types {
impl FromSql<Integer, Sqlite> for SqliteTimestamp {
fn from_sql(
mut bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
mut bytes: <Sqlite as Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
let Some(SqliteType::Long) = bytes.value_type() else {
return Err(format!(
@@ -69,7 +77,106 @@ pub mod types {
let datetime =
DateTime::from_timestamp(unix_timestamp, 0).ok_or("Timestamp is out of bounds")?;
Ok(SqliteTimestamp(datetime))
Ok(Self(datetime))
}
}
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)
}
}
};
}
declare_id!(ChainId);
#[expect(
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants"
)]
const _: () = {
impl From<ChainId> for alloy::primitives::ChainId {
fn from(chain_id: ChainId) -> Self {
chain_id.0 as Self
}
}
impl From<alloy::primitives::ChainId> for ChainId {
fn from(chain_id: alloy::primitives::ChainId) -> Self {
Self(chain_id as _)
}
}
};
declare_id!(OperatorId);
declare_id!(OperatorIdentityId);
declare_id!(AeadEncryptedId);
declare_id!(RootKeyHistoryId);
declare_id!(TlsHistoryId);
declare_id!(EvmWalletId);
declare_id!(ClientId);
declare_id!(ProposalId);
declare_id!(RecoveryOperatorIdentityId);
#[derive(Debug, Clone, PartialEq, Eq, AsExpression, FromSqlRow)]
#[diesel(sql_type = Text)]
pub enum ProposalStatus {
Pending,
Approved,
Rejected,
}
impl ToSql<Text, Sqlite> for ProposalStatus {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
let s: &str = match self {
Self::Pending => "pending",
Self::Approved => "approved",
Self::Rejected => "rejected",
};
<str as ToSql<Text, Sqlite>>::to_sql(s, out)
}
}
impl FromSql<Text, Sqlite> for ProposalStatus {
fn from_sql(bytes: <Sqlite as Backend>::RawValue<'_>) -> diesel::deserialize::Result<Self> {
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
match s.as_str() {
"pending" => Ok(Self::Pending),
"approved" => Ok(Self::Approved),
"rejected" => Ok(Self::Rejected),
other => Err(format!("Unknown proposal status: {other}").into()),
}
}
}
}
@@ -84,12 +191,12 @@ pub use types::*;
)]
#[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))]
pub struct AeadEncrypted {
pub id: i32,
pub id: AeadEncryptedId,
pub ciphertext: Vec<u8>,
pub tag: Vec<u8>,
pub current_nonce: Vec<u8>,
pub schema_version: i32,
pub associated_root_key_id: i32, // references root_key_history.id
pub associated_root_key_id: RootKeyHistoryId,
pub created_at: SqliteTimestamp,
}
@@ -102,7 +209,7 @@ pub struct AeadEncrypted {
attributes_with = "deriveless"
)]
pub struct RootKeyHistory {
pub id: i32,
pub id: RootKeyHistoryId,
pub ciphertext: Vec<u8>,
pub tag: Vec<u8>,
pub root_key_encryption_nonce: Vec<u8>,
@@ -120,7 +227,7 @@ pub struct RootKeyHistory {
attributes_with = "deriveless"
)]
pub struct TlsHistory {
pub id: i32,
pub id: TlsHistoryId,
pub cert: String,
pub cert_key: String, // PEM Encoded private key
pub ca_cert: String, // PEM Encoded certificate for cert signing
@@ -145,7 +252,7 @@ pub struct ArbiterSettings {
attributes_with = "deriveless"
)]
pub struct EvmWallet {
pub id: i32,
pub id: EvmWalletId,
pub address: Vec<u8>,
pub aead_encrypted_id: i32,
pub created_at: SqliteTimestamp,
@@ -156,19 +263,22 @@ pub struct EvmWallet {
#[view(
NewEvmWalletAccess,
derive(Insertable),
omit(id, created_at),
omit(id, created_at, revoked_at),
attributes_with = "deriveless"
)]
#[view(
CoreEvmWalletAccess,
derive(Insertable),
omit(created_at),
omit(created_at, revoked_at),
attributes_with = "deriveless"
)]
pub struct EvmWalletAccess {
pub id: i32,
pub wallet_id: i32,
pub wallet_id: EvmWalletId,
pub client_id: i32,
// Grants, transaction logs, and persistent-grant proposals reference this row
// `on delete restrict`, so revocation cannot delete it -- it marks it revoked instead.
pub revoked_at: Option<SqliteTimestamp>,
pub created_at: SqliteTimestamp,
}
@@ -194,7 +304,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: i32,
pub id: ClientId,
pub public_key: Vec<u8>,
pub metadata_id: i32,
pub created_at: SqliteTimestamp,
@@ -202,14 +312,25 @@ pub struct ProgramClient {
}
#[derive(Queryable, Debug)]
#[diesel(table_name = schema::useragent_client, check_for_backend(Sqlite))]
pub struct UseragentClient {
pub id: i32,
#[diesel(table_name = schema::operator_client, check_for_backend(Sqlite))]
pub struct OperatorClient {
pub id: OperatorIdentityId,
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(
@@ -235,7 +356,7 @@ pub struct EvmEtherTransferLimit {
pub struct EvmBasicGrant {
pub id: i32,
pub wallet_access_id: i32, // references evm_wallet_access.id
pub chain_id: i32,
pub chain_id: ChainId,
pub valid_from: Option<SqliteTimestamp>,
pub valid_until: Option<SqliteTimestamp>,
pub max_gas_fee_per_gas: Option<Vec<u8>>,
@@ -258,7 +379,7 @@ pub struct EvmTransactionLog {
pub id: i32,
pub grant_id: i32,
pub wallet_access_id: i32,
pub chain_id: i32,
pub chain_id: ChainId,
pub eth_value: Vec<u8>,
pub signed_at: SqliteTimestamp,
}
@@ -333,7 +454,7 @@ pub struct EvmTokenTransferLog {
pub id: i32,
pub grant_id: i32,
pub log_id: i32,
pub chain_id: i32,
pub chain_id: ChainId,
pub token_contract: Vec<u8>,
pub recipient_address: Vec<u8>,
pub value: Vec<u8>,
@@ -353,8 +474,63 @@ pub struct IntegrityEnvelope {
pub entity_kind: String,
pub entity_id: Vec<u8>,
pub payload_version: i32,
pub key_version: i32,
pub key_version: RootKeyHistoryId,
pub mac: Vec<u8>,
pub signed_at: SqliteTimestamp,
pub created_at: SqliteTimestamp,
}
#[derive(Debug, Queryable, Selectable, Identifiable)]
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
pub struct Proposal {
pub id: ProposalId,
pub kind: ProposalKindTag,
pub initiator_id: OperatorIdentityId,
pub created_at: SqliteTimestamp,
pub expires_at: SqliteTimestamp,
pub status: ProposalStatus,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
pub struct NewProposal {
pub kind: ProposalKindTag,
pub initiator_id: OperatorIdentityId,
// status defaults to 'pending' at the DB layer
pub expires_at: SqliteTimestamp,
}
#[derive(Debug, Queryable, Selectable, Identifiable)]
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
pub struct ProposalVote {
pub id: i32,
pub proposal_id: ProposalId,
pub operator_id: OperatorIdentityId,
pub approve: bool,
pub signature: Vec<u8>,
pub voted_at: SqliteTimestamp,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
pub struct NewProposalVote {
pub proposal_id: ProposalId,
pub operator_id: OperatorIdentityId,
pub approve: bool,
pub signature: Vec<u8>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::recovery_proposal_vote, check_for_backend(Sqlite))]
pub struct NewRecoveryProposalVote {
pub proposal_id: ProposalId,
pub recovery_operator_id: RecoveryOperatorIdentityId,
pub approve: bool,
pub signature: Vec<u8>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::recovery_wakeup_request, check_for_backend(Sqlite))]
pub struct NewRecoveryWakeupRequest {
pub requested_by: OperatorIdentityId,
}

View File

@@ -0,0 +1,48 @@
//! Approving an SDK client so it may authenticate against the vault.
use super::{Proposal, ProposalKindTag};
use crate::db::{
DatabaseConnection, models::ProposalId, schema::proposal_approve_sdk_client as table,
};
use diesel::{
ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable,
SelectableHelper as _, sqlite::Sqlite,
};
use diesel_async::RunQueryDsl as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Queryable, Selectable, Insertable)]
#[diesel(table_name = table, check_for_backend(Sqlite))]
pub struct Settings {
pub client_id: i32,
}
pub struct ApproveSdkClient;
impl Proposal for ApproveSdkClient {
const KIND: ProposalKindTag = ProposalKindTag::ApproveSdkClient;
type Settings = Settings;
async fn insert(
proposal_id: ProposalId,
settings: &Self::Settings,
conn: &mut DatabaseConnection,
) -> QueryResult<()> {
diesel::insert_into(table::table)
.values((table::proposal_id.eq(proposal_id), settings))
.execute(conn)
.await
.map(drop)
}
async fn load(
proposal_id: ProposalId,
conn: &mut DatabaseConnection,
) -> QueryResult<Self::Settings> {
table::table
.find(proposal_id)
.select(Settings::as_select())
.first(conn)
.await
}
}

View File

@@ -0,0 +1,49 @@
//! Granting an SDK client visibility of a wallet.
use super::{Proposal, ProposalKindTag};
use crate::db::{
DatabaseConnection, models::ProposalId, schema::proposal_grant_wallet_access as table,
};
use diesel::{
ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable,
SelectableHelper as _, sqlite::Sqlite,
};
use diesel_async::RunQueryDsl as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Queryable, Selectable, Insertable)]
#[diesel(table_name = table, check_for_backend(Sqlite))]
pub struct Settings {
pub wallet_id: i32,
pub client_id: i32,
}
pub struct GrantWalletAccess;
impl Proposal for GrantWalletAccess {
const KIND: ProposalKindTag = ProposalKindTag::GrantWalletAccess;
type Settings = Settings;
async fn insert(
proposal_id: ProposalId,
settings: &Self::Settings,
conn: &mut DatabaseConnection,
) -> QueryResult<()> {
diesel::insert_into(table::table)
.values((table::proposal_id.eq(proposal_id), settings))
.execute(conn)
.await
.map(drop)
}
async fn load(
proposal_id: ProposalId,
conn: &mut DatabaseConnection,
) -> QueryResult<Self::Settings> {
table::table
.find(proposal_id)
.select(Settings::as_select())
.first(conn)
.await
}
}

View File

@@ -0,0 +1,249 @@
//! Governed actions and the parameters they carry.
//!
//! Laid out the way [`crate::evm::policies::Policy`] is: a unit type per kind, its
//! parameters as an associated `Settings`, and the persistence for those parameters
//! implemented next to them. Everything downstream is generic over [`Proposal`], so a
//! new kind is a new module plus one arm in each dispatcher -- nothing else in the
//! codebase has to learn about it.
use crate::db::{DatabaseConnection, models::ProposalId};
use diesel::{
QueryResult,
backend::Backend,
deserialize::{FromSql, FromSqlRow},
expression::AsExpression,
serialize::ToSql,
sql_types::Text,
sqlite::Sqlite,
};
use strum::{Display, EnumDiscriminants, EnumString, IntoStaticStr};
pub mod approve_sdk_client;
pub mod grant_wallet_access;
pub mod one_off_transaction;
pub mod persistent_grant;
pub mod replace_operator;
pub mod trigger_rekey;
pub use approve_sdk_client::ApproveSdkClient;
pub use grant_wallet_access::GrantWalletAccess;
pub use one_off_transaction::OneOffTransaction;
pub use persistent_grant::PersistentGrant;
pub use replace_operator::ReplaceOperator;
pub use trigger_rekey::TriggerRekey;
/// A governed action that owns the child table holding its parameters.
pub trait Proposal: Sized {
/// The value stored in `proposal.kind` for this action.
const KIND: ProposalKindTag;
/// Parameters the action is voted on with.
type Settings: Send + Sync + 'static;
/// Writes the child row carrying `settings`.
fn insert(
proposal_id: ProposalId,
settings: &Self::Settings,
conn: &mut DatabaseConnection,
) -> impl Future<Output = QueryResult<()>> + Send;
/// Reads the child row back. A missing row surfaces as [`diesel::result::Error::NotFound`],
/// which is what a proposal without its parameters is.
fn load(
proposal_id: ProposalId,
conn: &mut DatabaseConnection,
) -> impl Future<Output = QueryResult<Self::Settings>> + Send;
}
/// Parameters of a proposal, in the one shape that can cross the actor boundary.
///
/// Every variant holds the `Settings` of the matching [`Proposal`] implementation, so
/// the two cannot drift.
#[derive(Debug, Clone, EnumDiscriminants)]
#[strum_discriminants(
name(ProposalKindTag),
vis(pub),
derive(Display, EnumString, IntoStaticStr, AsExpression, FromSqlRow),
diesel(sql_type = Text),
strum(serialize_all = "snake_case")
)]
pub enum ProposalKind {
ApproveSdkClient(approve_sdk_client::Settings),
GrantWalletAccess(grant_wallet_access::Settings),
ReplaceOperator(replace_operator::Settings),
TriggerRekey,
ApprovePersistentGrant(Box<persistent_grant::Settings>),
ApproveOneOffTransaction(Box<one_off_transaction::Settings>),
}
impl ProposalKindTag {
/// Key-rotation proposals require every operator to approve (§3.3).
#[must_use]
pub const fn requires_full_quorum(self) -> bool {
matches!(self, Self::ReplaceOperator | Self::TriggerRekey)
}
/// §3.5: recovery operators weigh in on operator replacement and nothing else.
#[must_use]
pub const fn recovery_may_vote(self) -> bool {
matches!(self, Self::ReplaceOperator)
}
}
/// Pins every implementation to the variant it is dispatched from. Without this a
/// mistyped `KIND` would compile and only show up as a proposal stored under the
/// wrong `proposal.kind`.
const _: () = {
assert!(
matches!(ApproveSdkClient::KIND, ProposalKindTag::ApproveSdkClient),
"ApproveSdkClient::KIND must be ProposalKindTag::ApproveSdkClient"
);
assert!(
matches!(GrantWalletAccess::KIND, ProposalKindTag::GrantWalletAccess),
"GrantWalletAccess::KIND must be ProposalKindTag::GrantWalletAccess"
);
assert!(
matches!(ReplaceOperator::KIND, ProposalKindTag::ReplaceOperator),
"ReplaceOperator::KIND must be ProposalKindTag::ReplaceOperator"
);
assert!(
matches!(TriggerRekey::KIND, ProposalKindTag::TriggerRekey),
"TriggerRekey::KIND must be ProposalKindTag::TriggerRekey"
);
assert!(
matches!(
PersistentGrant::KIND,
ProposalKindTag::ApprovePersistentGrant
),
"PersistentGrant::KIND must be ProposalKindTag::ApprovePersistentGrant"
);
assert!(
matches!(
OneOffTransaction::KIND,
ProposalKindTag::ApproveOneOffTransaction
),
"OneOffTransaction::KIND must be ProposalKindTag::ApproveOneOffTransaction"
);
};
/// Writes the child row carrying this proposal's parameters.
///
/// The only place the create path has to know every kind; each arm hands straight off
/// to the implementation that owns the table.
pub async fn insert_kind(
conn: &mut DatabaseConnection,
proposal_id: ProposalId,
kind: &ProposalKind,
) -> QueryResult<()> {
match kind {
ProposalKind::ApproveSdkClient(s) => ApproveSdkClient::insert(proposal_id, s, conn).await,
ProposalKind::GrantWalletAccess(s) => GrantWalletAccess::insert(proposal_id, s, conn).await,
ProposalKind::ReplaceOperator(s) => ReplaceOperator::insert(proposal_id, s, conn).await,
ProposalKind::TriggerRekey => TriggerRekey::insert(proposal_id, &(), conn).await,
ProposalKind::ApprovePersistentGrant(s) => {
PersistentGrant::insert(proposal_id, s, conn).await
}
ProposalKind::ApproveOneOffTransaction(s) => {
OneOffTransaction::insert(proposal_id, s, conn).await
}
}
}
/// Reads the parameters back for a `proposal.kind` that is only known at runtime.
pub async fn load_kind(
conn: &mut DatabaseConnection,
proposal_id: ProposalId,
tag: ProposalKindTag,
) -> QueryResult<ProposalKind> {
Ok(match tag {
ProposalKindTag::ApproveSdkClient => {
ProposalKind::ApproveSdkClient(ApproveSdkClient::load(proposal_id, conn).await?)
}
ProposalKindTag::GrantWalletAccess => {
ProposalKind::GrantWalletAccess(GrantWalletAccess::load(proposal_id, conn).await?)
}
ProposalKindTag::ReplaceOperator => {
ProposalKind::ReplaceOperator(ReplaceOperator::load(proposal_id, conn).await?)
}
ProposalKindTag::TriggerRekey => {
TriggerRekey::load(proposal_id, conn).await?;
ProposalKind::TriggerRekey
}
ProposalKindTag::ApprovePersistentGrant => ProposalKind::ApprovePersistentGrant(Box::new(
PersistentGrant::load(proposal_id, conn).await?,
)),
ProposalKindTag::ApproveOneOffTransaction => ProposalKind::ApproveOneOffTransaction(
Box::new(OneOffTransaction::load(proposal_id, conn).await?),
),
})
}
impl ToSql<Text, Sqlite> for ProposalKindTag {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
<str as ToSql<Text, Sqlite>>::to_sql(<&'static str>::from(*self), out)
}
}
impl FromSql<Text, Sqlite> for ProposalKindTag {
fn from_sql(bytes: <Sqlite as Backend>::RawValue<'_>) -> diesel::deserialize::Result<Self> {
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
s.parse()
.map_err(|_| format!("Unknown proposal kind: {s}").into())
}
}
/// SQLite has no unsigned integers; the column is `BigInt`, so a value that does not
/// round-trip is a corrupt row rather than something to silently wrap.
pub(crate) fn as_i64(value: u64) -> QueryResult<i64> {
i64::try_from(value).map_err(|_| diesel::result::Error::SerializationError(Box::new(Overflow)))
}
pub(crate) fn as_u64(value: i64) -> QueryResult<u64> {
u64::try_from(value)
.map_err(|_| diesel::result::Error::DeserializationError(Box::new(Overflow)))
}
pub(crate) fn fixed_bytes<const N: usize>(
bytes: &[u8],
column: &'static str,
) -> QueryResult<[u8; N]> {
<[u8; N]>::try_from(bytes)
.map_err(|_| diesel::result::Error::DeserializationError(Box::new(WrongLength(column))))
}
/// Reads a fixed-width column into an array, labelling failures with the column it came
/// from.
///
/// The label is taken from the field itself, so renaming a column cannot leave a stale
/// name behind in the error -- which is the whole reason this is a macro and not a
/// second argument.
///
/// - `fixed!(row.column)` for a `Vec<u8>` field
/// - `fixed!(opt row.column)` for a `Option<Vec<u8>>` one
/// - `fixed!(binding)` for a local
macro_rules! fixed {
(opt $src:ident.$field:ident) => {
$src.$field
.as_deref()
.map(|value| $crate::db::proposal::fixed_bytes(value, stringify!($field)))
.transpose()
};
($src:ident.$field:ident) => {
$crate::db::proposal::fixed_bytes(&$src.$field, stringify!($field))
};
($binding:ident) => {
$crate::db::proposal::fixed_bytes(&$binding, stringify!($binding))
};
}
pub(crate) use fixed;
#[derive(Debug, thiserror::Error)]
#[error("value does not fit a SQLite integer")]
struct Overflow;
#[derive(Debug, thiserror::Error)]
#[error("column {0} has the wrong byte length")]
struct WrongLength(&'static str);

View File

@@ -0,0 +1,138 @@
//! Signing a single EIP-1559 transaction.
use super::{Proposal, ProposalKindTag, as_i64, as_u64, fixed};
use crate::db::{
DatabaseConnection,
models::ProposalId,
schema::{proposal_one_off_transaction, proposal_one_off_transaction_result},
};
use diesel::{
Insertable, QueryDsl as _, QueryResult, Queryable, Selectable, SelectableHelper as _,
sqlite::Sqlite,
};
use diesel_async::RunQueryDsl as _;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settings {
pub client_id: i32,
pub wallet_address: [u8; 20],
pub chain_id: u64,
pub nonce: u64,
pub gas_limit: u64,
pub max_fee_per_gas: u128,
pub max_priority_fee_per_gas: u128,
pub to: [u8; 20],
pub value: [u8; 32],
pub input: Vec<u8>,
}
#[derive(Debug, Queryable, Selectable, Insertable)]
#[diesel(table_name = proposal_one_off_transaction, check_for_backend(Sqlite))]
struct Row {
proposal_id: ProposalId,
client_id: i32,
wallet_address: Vec<u8>,
chain_id: i64,
nonce: i64,
gas_limit: i64,
max_fee_per_gas: Vec<u8>,
max_priority_fee_per_gas: Vec<u8>,
to_address: Vec<u8>,
value: Vec<u8>,
input: Vec<u8>,
}
impl Row {
fn new(proposal_id: ProposalId, settings: &Settings) -> QueryResult<Self> {
Ok(Self {
proposal_id,
client_id: settings.client_id,
wallet_address: settings.wallet_address.to_vec(),
chain_id: as_i64(settings.chain_id)?,
nonce: as_i64(settings.nonce)?,
gas_limit: as_i64(settings.gas_limit)?,
max_fee_per_gas: settings.max_fee_per_gas.to_be_bytes().to_vec(),
max_priority_fee_per_gas: settings.max_priority_fee_per_gas.to_be_bytes().to_vec(),
to_address: settings.to.to_vec(),
value: settings.value.to_vec(),
input: settings.input.clone(),
})
}
fn into_settings(self) -> QueryResult<Settings> {
Ok(Settings {
client_id: self.client_id,
wallet_address: fixed!(self.wallet_address)?,
chain_id: as_u64(self.chain_id)?,
nonce: as_u64(self.nonce)?,
gas_limit: as_u64(self.gas_limit)?,
max_fee_per_gas: u128::from_be_bytes(fixed!(self.max_fee_per_gas)?),
max_priority_fee_per_gas: u128::from_be_bytes(fixed!(self.max_priority_fee_per_gas)?),
to: fixed!(self.to_address)?,
value: fixed!(self.value)?,
input: self.input,
})
}
}
pub struct OneOffTransaction;
impl Proposal for OneOffTransaction {
const KIND: ProposalKindTag = ProposalKindTag::ApproveOneOffTransaction;
type Settings = Settings;
async fn insert(
proposal_id: ProposalId,
settings: &Self::Settings,
conn: &mut DatabaseConnection,
) -> QueryResult<()> {
diesel::insert_into(proposal_one_off_transaction::table)
.values(&Row::new(proposal_id, settings)?)
.execute(conn)
.await
.map(drop)
}
async fn load(
proposal_id: ProposalId,
conn: &mut DatabaseConnection,
) -> QueryResult<Self::Settings> {
let row: Row = proposal_one_off_transaction::table
.find(proposal_id)
.select(Row::as_select())
.first(conn)
.await?;
row.into_settings()
}
}
/// The signature the vault produced for an approved transaction.
#[derive(Debug, Insertable)]
#[diesel(table_name = proposal_one_off_transaction_result, check_for_backend(Sqlite))]
struct SignatureRow {
proposal_id: ProposalId,
r: Vec<u8>,
s: Vec<u8>,
y_parity: i32,
}
/// Records the signature produced for an approved transaction, by component, so what
/// came back is as readable as what was signed.
pub async fn store_signature(
proposal_id: ProposalId,
signature: &alloy::signers::Signature,
conn: &mut DatabaseConnection,
) -> QueryResult<()> {
diesel::insert_into(proposal_one_off_transaction_result::table)
.values(&SignatureRow {
proposal_id,
r: signature.r().to_be_bytes::<32>().to_vec(),
s: signature.s().to_be_bytes::<32>().to_vec(),
y_parity: i32::from(signature.v()),
})
.execute(conn)
.await
.map(drop)
}

View File

@@ -0,0 +1,271 @@
//! Creating a standing EVM grant.
use super::{Proposal, ProposalKindTag, as_i64, as_u64, fixed};
use crate::db::{
DatabaseConnection,
models::ProposalId,
schema::{
proposal_persistent_grant, proposal_persistent_grant_ether,
proposal_persistent_grant_ether_target, proposal_persistent_grant_token,
proposal_persistent_grant_token_limit,
},
};
use diesel::{
ExpressionMethods as _, Insertable, OptionalExtension as _, QueryDsl as _, QueryResult,
Queryable, Selectable, SelectableHelper as _, sqlite::Sqlite,
};
use diesel_async::RunQueryDsl as _;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settings {
pub wallet_access_id: i32,
pub chain_id: u64,
pub valid_from_secs: Option<i64>,
pub valid_until_secs: Option<i64>,
pub max_gas_fee_per_gas: Option<[u8; 32]>,
pub max_priority_fee_per_gas: Option<[u8; 32]>,
pub rate_limit: Option<RateLimit>,
pub specific: Specific,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RateLimit {
pub count: u32,
pub window_secs: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VolumeLimit {
pub max_volume: [u8; 32],
pub window_secs: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Specific {
EtherTransfer {
targets: Vec<[u8; 20]>,
limit: VolumeLimit,
},
TokenTransfer {
token_contract: [u8; 20],
receiver: Option<[u8; 20]>,
volume_limits: Vec<VolumeLimit>,
},
}
/// Shared settings, mirroring `evm_basic_grant`.
#[derive(Debug, Queryable, Selectable, Insertable)]
#[diesel(table_name = proposal_persistent_grant, check_for_backend(Sqlite))]
struct BaseRow {
proposal_id: ProposalId,
wallet_access_id: i32,
chain_id: i64,
valid_from: Option<i64>,
valid_until: Option<i64>,
max_gas_fee_per_gas: Option<Vec<u8>>,
max_priority_fee_per_gas: Option<Vec<u8>>,
rate_limit_count: Option<i32>,
rate_limit_window_secs: Option<i64>,
}
#[derive(Debug, Queryable, Selectable, Insertable)]
#[diesel(table_name = proposal_persistent_grant_ether, check_for_backend(Sqlite))]
struct EtherRow {
proposal_id: ProposalId,
window_secs: i64,
max_volume: Vec<u8>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = proposal_persistent_grant_ether_target, check_for_backend(Sqlite))]
struct NewEtherTarget {
proposal_id: ProposalId,
address: Vec<u8>,
}
#[derive(Debug, Queryable, Selectable, Insertable)]
#[diesel(table_name = proposal_persistent_grant_token, check_for_backend(Sqlite))]
struct TokenRow {
proposal_id: ProposalId,
token_contract: Vec<u8>,
receiver: Option<Vec<u8>>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = proposal_persistent_grant_token_limit, check_for_backend(Sqlite))]
struct NewTokenLimit {
proposal_id: ProposalId,
window_secs: i64,
max_volume: Vec<u8>,
}
impl BaseRow {
fn new(proposal_id: ProposalId, settings: &Settings) -> QueryResult<Self> {
Ok(Self {
proposal_id,
wallet_access_id: settings.wallet_access_id,
chain_id: as_i64(settings.chain_id)?,
valid_from: settings.valid_from_secs,
valid_until: settings.valid_until_secs,
max_gas_fee_per_gas: settings.max_gas_fee_per_gas.map(|v| v.to_vec()),
max_priority_fee_per_gas: settings.max_priority_fee_per_gas.map(|v| v.to_vec()),
// SQLite stores integers signed; a rate-limit count is a `u32`, so it
// round-trips through the bit pattern rather than a fallible range check.
rate_limit_count: settings.rate_limit.map(|r| r.count.cast_signed()),
rate_limit_window_secs: settings.rate_limit.map(|r| r.window_secs),
})
}
fn into_settings(self, specific: Specific) -> QueryResult<Settings> {
Ok(Settings {
wallet_access_id: self.wallet_access_id,
chain_id: as_u64(self.chain_id)?,
valid_from_secs: self.valid_from,
valid_until_secs: self.valid_until,
max_gas_fee_per_gas: fixed!(opt self.max_gas_fee_per_gas)?,
max_priority_fee_per_gas: fixed!(opt self.max_priority_fee_per_gas)?,
rate_limit: self.rate_limit_count.zip(self.rate_limit_window_secs).map(
|(count, window_secs)| RateLimit {
count: count.cast_unsigned(),
window_secs,
},
),
specific,
})
}
}
pub struct PersistentGrant;
impl Proposal for PersistentGrant {
const KIND: ProposalKindTag = ProposalKindTag::ApprovePersistentGrant;
type Settings = Settings;
async fn insert(
proposal_id: ProposalId,
settings: &Self::Settings,
conn: &mut DatabaseConnection,
) -> QueryResult<()> {
diesel::insert_into(proposal_persistent_grant::table)
.values(&BaseRow::new(proposal_id, settings)?)
.execute(conn)
.await?;
match &settings.specific {
Specific::EtherTransfer { targets, limit } => {
diesel::insert_into(proposal_persistent_grant_ether::table)
.values(&EtherRow {
proposal_id,
window_secs: limit.window_secs,
max_volume: limit.max_volume.to_vec(),
})
.execute(conn)
.await?;
// Row at a time: SQLite has no multi-row VALUES clause in diesel-async.
for address in targets {
diesel::insert_into(proposal_persistent_grant_ether_target::table)
.values(&NewEtherTarget {
proposal_id,
address: address.to_vec(),
})
.execute(conn)
.await?;
}
}
Specific::TokenTransfer {
token_contract,
receiver,
volume_limits,
} => {
diesel::insert_into(proposal_persistent_grant_token::table)
.values(&TokenRow {
proposal_id,
token_contract: token_contract.to_vec(),
receiver: receiver.map(|r| r.to_vec()),
})
.execute(conn)
.await?;
for limit in volume_limits {
diesel::insert_into(proposal_persistent_grant_token_limit::table)
.values(&NewTokenLimit {
proposal_id,
window_secs: limit.window_secs,
max_volume: limit.max_volume.to_vec(),
})
.execute(conn)
.await?;
}
}
}
Ok(())
}
async fn load(
proposal_id: ProposalId,
conn: &mut DatabaseConnection,
) -> QueryResult<Self::Settings> {
let base: BaseRow = proposal_persistent_grant::table
.find(proposal_id)
.select(BaseRow::as_select())
.first(conn)
.await?;
let ether: Option<EtherRow> = proposal_persistent_grant_ether::table
.find(proposal_id)
.select(EtherRow::as_select())
.first(conn)
.await
.optional()?;
let specific = if let Some(ether) = ether {
let addresses: Vec<Vec<u8>> = proposal_persistent_grant_ether_target::table
.filter(proposal_persistent_grant_ether_target::proposal_id.eq(proposal_id))
.select(proposal_persistent_grant_ether_target::address)
.load(conn)
.await?;
let targets = addresses
.iter()
.map(|address| fixed!(address))
.collect::<QueryResult<Vec<_>>>()?;
Specific::EtherTransfer {
targets,
limit: VolumeLimit {
max_volume: fixed!(ether.max_volume)?,
window_secs: ether.window_secs,
},
}
} else {
let token: TokenRow = proposal_persistent_grant_token::table
.find(proposal_id)
.select(TokenRow::as_select())
.first(conn)
.await?;
let rows: Vec<(i64, Vec<u8>)> = proposal_persistent_grant_token_limit::table
.filter(proposal_persistent_grant_token_limit::proposal_id.eq(proposal_id))
.select((
proposal_persistent_grant_token_limit::window_secs,
proposal_persistent_grant_token_limit::max_volume,
))
.load(conn)
.await?;
let volume_limits = rows
.into_iter()
.map(|(window_secs, max_volume)| {
Ok(VolumeLimit {
max_volume: fixed!(max_volume)?,
window_secs,
})
})
.collect::<QueryResult<Vec<_>>>()?;
Specific::TokenTransfer {
token_contract: fixed!(token.token_contract)?,
receiver: fixed!(opt token.receiver)?,
volume_limits,
}
};
base.into_settings(specific)
}
}

View File

@@ -0,0 +1,51 @@
//! Replacing an operator's key, which also triggers a Shamir re-key (§3.3).
use super::{Proposal, ProposalKindTag};
use crate::db::{
DatabaseConnection,
models::{OperatorIdentityId, ProposalId},
schema::proposal_replace_operator as table,
};
use diesel::{
ExpressionMethods as _, Insertable, QueryDsl as _, QueryResult, Queryable, Selectable,
SelectableHelper as _, sqlite::Sqlite,
};
use diesel_async::RunQueryDsl as _;
#[derive(Debug, Clone, PartialEq, Eq, Queryable, Selectable, Insertable)]
#[diesel(table_name = table, check_for_backend(Sqlite))]
pub struct Settings {
pub old_operator_id: OperatorIdentityId,
pub new_pubkey: Vec<u8>,
}
pub struct ReplaceOperator;
impl Proposal for ReplaceOperator {
const KIND: ProposalKindTag = ProposalKindTag::ReplaceOperator;
type Settings = Settings;
async fn insert(
proposal_id: ProposalId,
settings: &Self::Settings,
conn: &mut DatabaseConnection,
) -> QueryResult<()> {
diesel::insert_into(table::table)
.values((table::proposal_id.eq(proposal_id), settings))
.execute(conn)
.await
.map(drop)
}
async fn load(
proposal_id: ProposalId,
conn: &mut DatabaseConnection,
) -> QueryResult<Self::Settings> {
table::table
.find(proposal_id)
.select(Settings::as_select())
.first(conn)
.await
}
}

View File

@@ -0,0 +1,28 @@
//! A Shamir re-key over the current operator set (§3.3).
use super::{Proposal, ProposalKindTag};
use crate::db::{DatabaseConnection, models::ProposalId};
use diesel::QueryResult;
pub struct TriggerRekey;
impl Proposal for TriggerRekey {
const KIND: ProposalKindTag = ProposalKindTag::TriggerRekey;
type Settings = ();
async fn insert(
_proposal_id: ProposalId,
_settings: &Self::Settings,
_conn: &mut DatabaseConnection,
) -> QueryResult<()> {
Ok(())
}
async fn load(
_proposal_id: ProposalId,
_conn: &mut DatabaseConnection,
) -> QueryResult<Self::Settings> {
Ok(())
}
}

View File

@@ -0,0 +1,102 @@
//! Whether the recovery committee is awake.
//!
//! §3.6: a wake-up request opens a dispute window; recovery powers only become active once
//! that window has elapsed without cancellation. Both the proposal manager (for voting) and
//! the vault coordinator (for unsealing) gate on this, so the rule lives in one place.
use crate::db::{functions::unixepoch, schema};
use diesel::{
ExpressionMethods as _, QueryDsl as _,
dsl::{exists, select},
};
use diesel_async::RunQueryDsl;
/// Recovery operators stay asleep for this long after a wake-up is requested, so the other
/// operators have time to dispute it (§3.6).
pub const WAKEUP_DELAY_SECS: i32 = 14 * 24 * 60 * 60;
/// True when an uncancelled wake-up request is older than the dispute window.
pub async fn is_active(
conn: &mut crate::db::DatabaseConnection,
) -> Result<bool, diesel::result::Error> {
select(exists(
schema::recovery_wakeup_request::table
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
.filter(
schema::recovery_wakeup_request::requested_at
.le(unixepoch("now") - WAKEUP_DELAY_SECS),
),
))
.get_result(conn)
.await
}
#[cfg(test)]
mod tests {
use super::{WAKEUP_DELAY_SECS, is_active};
use crate::db::{self, schema};
use diesel::{ExpressionMethods as _, insert_into};
use diesel_async::RunQueryDsl;
/// `recovery_wakeup_request.requested_by` references `operator_identity(id)`, and pooled
/// connections enforce foreign keys, so every wake-up row needs a real identity behind it.
async fn insert_operator(pool: &db::DatabasePool) -> i32 {
let mut conn = pool.get().await.unwrap();
insert_into(schema::operator_identity::table)
.values(schema::operator_identity::public_key.eq(vec![7u8; 32]))
.returning(schema::operator_identity::id)
.get_result(&mut conn)
.await
.unwrap()
}
/// Pins `.filter(requested_at.le(...))`: a wake-up requested moments ago must not be
/// active yet, even though nothing has cancelled it. Deleting that filter turns this
/// assertion false without touching any other test in the suite.
#[tokio::test]
async fn a_recent_wakeup_is_not_yet_active() {
let pool = db::create_test_pool().await;
let operator_id = insert_operator(&pool).await;
let mut conn = pool.get().await.unwrap();
diesel::sql_query(format!(
"INSERT INTO recovery_wakeup_request (requested_by, requested_at) \
VALUES ({operator_id}, unixepoch('now'))"
))
.execute(&mut conn)
.await
.unwrap();
assert!(
!is_active(&mut conn).await.unwrap(),
"a wake-up requested moments ago must still be asleep"
);
}
/// Pins `.filter(cancelled_at.is_null())`: a cancelled wake-up must not count towards
/// activity even once its original request has outlived the dispute window. Deleting
/// that filter turns this assertion false without touching any other test in the suite.
#[tokio::test]
async fn a_cancelled_wakeup_is_not_active_even_past_the_window() {
let pool = db::create_test_pool().await;
let operator_id = insert_operator(&pool).await;
let mut conn = pool.get().await.unwrap();
diesel::sql_query(format!(
"INSERT INTO recovery_wakeup_request \
(requested_by, requested_at, cancelled_by, cancelled_at) \
VALUES ({operator_id}, unixepoch('now') - {WAKEUP_DELAY_SECS} - 1, \
{operator_id}, unixepoch('now'))"
))
.execute(&mut conn)
.await
.unwrap();
assert!(
!is_active(&mut conn).await.unwrap(),
"a cancelled wake-up must not activate recovery even past the window"
);
}
}

View File

@@ -17,6 +17,7 @@ diesel::table! {
id -> Integer,
root_key_id -> Nullable<Integer>,
tls_id -> Nullable<Integer>,
shamir_threshold -> Nullable<Integer>,
}
}
@@ -135,6 +136,7 @@ diesel::table! {
id -> Integer,
wallet_id -> Integer,
client_id -> Integer,
revoked_at -> Nullable<Integer>,
created_at -> Integer,
}
}
@@ -152,6 +154,185 @@ diesel::table! {
}
}
diesel::table! {
operator (id) {
id -> Nullable<Integer>,
share -> Binary,
share_nonce -> Binary,
share_salt -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
operator_identity (id) {
id -> Integer,
public_key -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
proposal (id) {
id -> Integer,
kind -> Text,
initiator_id -> Integer,
created_at -> Integer,
expires_at -> Integer,
status -> Text,
}
}
diesel::table! {
proposal_approve_sdk_client (proposal_id) {
proposal_id -> Integer,
client_id -> Integer,
}
}
diesel::table! {
proposal_grant_wallet_access (proposal_id) {
proposal_id -> Integer,
wallet_id -> Integer,
client_id -> Integer,
}
}
diesel::table! {
proposal_replace_operator (proposal_id) {
proposal_id -> Integer,
old_operator_id -> Integer,
new_pubkey -> Binary,
}
}
diesel::table! {
proposal_one_off_transaction (proposal_id) {
proposal_id -> Integer,
client_id -> Integer,
wallet_address -> Binary,
chain_id -> BigInt,
nonce -> BigInt,
gas_limit -> BigInt,
max_fee_per_gas -> Binary,
max_priority_fee_per_gas -> Binary,
to_address -> Binary,
value -> Binary,
input -> Binary,
}
}
diesel::table! {
proposal_persistent_grant (proposal_id) {
proposal_id -> Integer,
wallet_access_id -> Integer,
chain_id -> BigInt,
valid_from -> Nullable<BigInt>,
valid_until -> Nullable<BigInt>,
max_gas_fee_per_gas -> Nullable<Binary>,
max_priority_fee_per_gas -> Nullable<Binary>,
rate_limit_count -> Nullable<Integer>,
rate_limit_window_secs -> Nullable<BigInt>,
}
}
diesel::table! {
proposal_persistent_grant_ether (proposal_id) {
proposal_id -> Integer,
window_secs -> BigInt,
max_volume -> Binary,
}
}
diesel::table! {
proposal_persistent_grant_ether_target (id) {
id -> Integer,
proposal_id -> Integer,
address -> Binary,
}
}
diesel::table! {
proposal_persistent_grant_token (proposal_id) {
proposal_id -> Integer,
token_contract -> Binary,
receiver -> Nullable<Binary>,
}
}
diesel::table! {
proposal_persistent_grant_token_limit (id) {
id -> Integer,
proposal_id -> Integer,
window_secs -> BigInt,
max_volume -> Binary,
}
}
diesel::table! {
proposal_one_off_transaction_result (proposal_id) {
proposal_id -> Integer,
r -> Binary,
s -> Binary,
y_parity -> Integer,
created_at -> Integer,
}
}
diesel::table! {
recovery_operator (id) {
id -> Integer,
share -> Binary,
share_nonce -> Binary,
share_salt -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
recovery_operator_identity (id) {
id -> Integer,
public_key -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
recovery_wakeup_request (id) {
id -> Integer,
requested_by -> Integer,
requested_at -> Integer,
cancelled_by -> Nullable<Integer>,
cancelled_at -> Nullable<Integer>,
}
}
diesel::table! {
recovery_proposal_vote (id) {
id -> Integer,
proposal_id -> Integer,
recovery_operator_id -> Integer,
approve -> Bool,
signature -> Binary,
voted_at -> Integer,
}
}
diesel::table! {
proposal_vote (id) {
id -> Integer,
proposal_id -> Integer,
operator_id -> Integer,
approve -> Bool,
signature -> Binary,
voted_at -> Integer,
}
}
diesel::table! {
program_client (id) {
id -> Integer,
@@ -185,15 +366,6 @@ diesel::table! {
}
}
diesel::table! {
useragent_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));
@@ -212,10 +384,40 @@ 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::joinable!(proposal -> operator_identity (initiator_id));
diesel::joinable!(proposal_one_off_transaction_result -> proposal_one_off_transaction (proposal_id));
diesel::joinable!(proposal_approve_sdk_client -> proposal (proposal_id));
diesel::joinable!(proposal_grant_wallet_access -> proposal (proposal_id));
diesel::joinable!(proposal_replace_operator -> proposal (proposal_id));
diesel::joinable!(proposal_one_off_transaction -> proposal (proposal_id));
diesel::joinable!(proposal_persistent_grant -> proposal (proposal_id));
diesel::joinable!(proposal_persistent_grant_ether -> proposal_persistent_grant (proposal_id));
diesel::joinable!(proposal_persistent_grant_token -> proposal_persistent_grant (proposal_id));
diesel::joinable!(proposal_vote -> proposal (proposal_id));
diesel::joinable!(proposal_vote -> operator_identity (operator_id));
diesel::joinable!(recovery_operator -> recovery_operator_identity (id));
diesel::joinable!(recovery_proposal_vote -> proposal (proposal_id));
diesel::joinable!(recovery_proposal_vote -> recovery_operator_identity (recovery_operator_id));
diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by));
diesel::allow_tables_to_appear_in_same_query!(
aead_encrypted,
proposal_one_off_transaction_result,
proposal_approve_sdk_client,
proposal_grant_wallet_access,
proposal_replace_operator,
proposal_one_off_transaction,
proposal_persistent_grant,
proposal_persistent_grant_ether,
proposal_persistent_grant_ether_target,
proposal_persistent_grant_token,
proposal_persistent_grant_token_limit,
recovery_operator,
recovery_operator_identity,
recovery_wakeup_request,
recovery_proposal_vote,
arbiter_settings,
client_metadata,
client_metadata_history,
@@ -230,8 +432,11 @@ diesel::allow_tables_to_appear_in_same_query!(
evm_wallet,
evm_wallet_access,
integrity_envelope,
operator,
operator_identity,
program_client,
proposal,
proposal_vote,
root_key_history,
tls_history,
useragent_client,
);

View File

@@ -45,7 +45,7 @@ sol! {
sol! {
/// Permit2 — Uniswap's canonical token approval manager.
/// Replaces per-contract ERC-20 approve() with a single approval hub.
/// Replaces per-contract ERC-20 `approve()` with a single approval hub.
#[derive(Debug)]
interface IPermit2 {
struct TokenPermissions {

View File

@@ -1,15 +1,3 @@
pub mod abi;
pub mod safe_signer;
use alloy::{
consensus::TxEip1559,
primitives::{TxKind, U256},
};
use chrono::Utc;
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use crate::{
actors::vault::Vault,
crypto::integrity,
@@ -27,6 +15,18 @@ use crate::{
},
};
use alloy::{
consensus::TxEip1559,
primitives::{TxKind, U256},
};
use chrono::Utc;
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
pub mod abi;
pub mod safe_signer;
pub mod policies;
mod utils;
@@ -34,7 +34,7 @@ mod utils;
#[derive(Debug, thiserror::Error)]
pub enum PolicyError {
#[error("Database error")]
Database(#[from] crate::db::DatabaseError),
Database(#[from] DatabaseError),
#[error("Transaction violates policy: {0:?}")]
Violations(Vec<EvalViolation>),
#[error("No matching grant found")]
@@ -66,7 +66,7 @@ pub enum AnalyzeError {
#[derive(Debug, thiserror::Error)]
pub enum ListError {
#[error("Database error")]
Database(#[from] crate::db::DatabaseError),
Database(#[from] DatabaseError),
#[error("Integrity verification failed for grant")]
Integrity(#[from] integrity::Error),
@@ -127,7 +127,7 @@ async fn check_shared_constraints(
.get_result(conn)
.await?;
if count >= rate_limit.count as i64 {
if count >= rate_limit.count.into() {
violations.push(EvalViolation::RateLimitExceeded);
}
}
@@ -179,24 +179,22 @@ impl Engine {
}
if run_kind == RunKind::Execution {
conn.transaction(|conn| {
Box::pin(async move {
let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog {
grant_id: grant.common_settings_id,
wallet_access_id: context.target.id,
chain_id: context.chain as i32,
eth_value: utils::u256_to_bytes(context.value).to_vec(),
signed_at: Utc::now().into(),
})
.returning(evm_transaction_log::id)
.get_result(conn)
.await?;
conn.transaction(async |conn| {
let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog {
grant_id: grant.common_settings_id,
wallet_access_id: context.target.id,
chain_id: context.chain.into(),
eth_value: utils::u256_to_bytes(context.value).to_vec(),
signed_at: Utc::now().into(),
})
.returning(evm_transaction_log::id)
.get_result(&mut *conn)
.await?;
P::record_transaction(&context, meaning, log_id, &grant, conn).await?;
P::record_transaction(&context, meaning, log_id, &grant, &mut *conn).await?;
QueryResult::Ok(())
})
QueryResult::Ok(())
})
.await
.map_err(DatabaseError::from)?;
@@ -207,7 +205,7 @@ impl Engine {
}
impl Engine {
pub fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
Self { db, vault }
}
@@ -222,48 +220,52 @@ impl Engine {
let vault = self.vault.clone();
let id = conn
.transaction(|conn| {
Box::pin(async move {
use schema::evm_basic_grant;
.transaction(async |conn| {
use schema::evm_basic_grant;
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant {
chain_id: full_grant.shared.chain as i32,
wallet_access_id: full_grant.shared.wallet_access_id,
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant
.shared
.max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant
.shared
.max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.window.num_seconds() as i32),
revoked_at: None,
})
.returning(evm_basic_grant::all_columns)
.get_result(conn)
.await?;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::as_conversions,
reason = "fixme! #86"
)]
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant {
chain_id: full_grant.shared.chain.into(),
wallet_access_id: full_grant.shared.wallet_access_id,
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant
.shared
.max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant
.shared
.max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.window.num_seconds() as i32),
revoked_at: None,
})
.returning(evm_basic_grant::all_columns)
.get_result(&mut *conn)
.await?;
P::create_grant(&basic_grant, &full_grant.specific, conn).await?;
P::create_grant(&basic_grant, &full_grant.specific, &mut *conn).await?;
integrity::sign_entity(conn, &vault, &full_grant, basic_grant.id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
integrity::sign_entity(&mut *conn, &vault, &full_grant, basic_grant.id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
QueryResult::Ok(basic_grant.id)
})
QueryResult::Ok(basic_grant.id)
})
.await?;
@@ -313,7 +315,7 @@ impl Engine {
let TxKind::Call(to) = transaction.to else {
return Err(VetError::ContractCreationNotSupported);
};
let context = policies::EvalContext {
let context = EvalContext {
target,
chain: transaction.chain_id,
to,
@@ -350,15 +352,17 @@ impl Engine {
mod tests {
use alloy::primitives::{Address, Bytes, U256, address};
use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into};
use diesel::{ExpressionMethods as _, SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
use rstest::rstest;
use crate::db::{
self, DatabaseConnection,
self, DatabaseConnection, models,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog,
SqliteTimestamp,
},
schema,
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::policies::{
@@ -375,8 +379,9 @@ mod tests {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
wallet_id: EvmWalletId::from_raw(5),
client_id: 20,
revoked_at: None,
created_at: SqliteTimestamp(Utc::now()),
},
chain: CHAIN_ID,
@@ -400,14 +405,95 @@ mod tests {
}
}
/// Creates the parent chain a fresh `evm_wallet_access` row needs under foreign-key
/// enforcement (a root key, an aead-encrypted secret, a wallet, and a client) and
/// returns the new access row's id.
async fn seed_wallet_access(conn: &mut DatabaseConnection) -> i32 {
let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: vec![0u8; 32],
tag: vec![0u8; 16],
root_key_encryption_nonce: vec![0u8; 24],
data_encryption_nonce: vec![0u8; 24],
schema_version: 1,
salt: vec![0u8; 16],
})
.returning(schema::root_key_history::id)
.get_result(conn)
.await
.unwrap();
let aead_id: i32 = insert_into(schema::aead_encrypted::table)
.values(&models::NewAeadEncrypted {
ciphertext: vec![0u8; 32],
tag: vec![0u8; 16],
current_nonce: vec![0u8; 24],
schema_version: 1,
associated_root_key_id: root_key_id,
created_at: Utc::now().into(),
})
.returning(schema::aead_encrypted::id)
.get_result(conn)
.await
.unwrap();
let wallet_id: EvmWalletId = insert_into(schema::evm_wallet::table)
.values((
schema::evm_wallet::address.eq(rand::random::<[u8; 20]>().to_vec()),
schema::evm_wallet::aead_encrypted_id.eq(aead_id),
))
.returning(schema::evm_wallet::id)
.get_result(conn)
.await
.unwrap();
let metadata_id: i32 = insert_into(schema::client_metadata::table)
.values(schema::client_metadata::name.eq("test"))
.returning(schema::client_metadata::id)
.get_result(conn)
.await
.unwrap();
let client_id: i32 = insert_into(schema::program_client::table)
.values((
schema::program_client::public_key.eq(rand::random::<[u8; 32]>().to_vec()),
schema::program_client::metadata_id.eq(metadata_id),
))
.returning(schema::program_client::id)
.get_result(conn)
.await
.unwrap();
insert_into(schema::evm_wallet_access::table)
.values((
schema::evm_wallet_access::wallet_id.eq(wallet_id),
schema::evm_wallet_access::client_id.eq(client_id),
))
.returning(schema::evm_wallet_access::id)
.get_result(conn)
.await
.unwrap()
}
async fn insert_basic_grant(
conn: &mut DatabaseConnection,
shared: &SharedGrantSettings,
) -> EvmBasicGrant {
// The seeded id deliberately wins over `shared.wallet_access_id`: every other field
// below is read from `shared`, but a caller-supplied access id would almost never
// reference a row that actually exists under foreign-key enforcement.
let wallet_access_id = seed_wallet_access(conn).await;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::as_conversions,
reason = "fixme! #86"
)]
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_access_id: shared.wallet_access_id,
chain_id: shared.chain as i32,
wallet_access_id,
chain_id: shared.chain.into(),
valid_from: shared.valid_from.map(SqliteTimestamp),
valid_until: shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: shared
@@ -570,8 +656,8 @@ mod tests {
insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog {
grant_id: basic_grant.id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
wallet_access_id: basic_grant.wallet_access_id,
chain_id: CHAIN_ID.into(),
eth_value: super::utils::u256_to_bytes(U256::ZERO).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})

View File

@@ -1,4 +1,8 @@
use std::fmt::Display;
use crate::{
crypto::integrity::v1::Integrable,
db::models::{EvmBasicGrant, EvmWalletAccess},
evm::utils,
};
use alloy::primitives::{Address, Bytes, ChainId, U256};
use chrono::{DateTime, Duration, Utc};
@@ -6,15 +10,9 @@ use diesel::{
ExpressionMethods as _, QueryDsl, SelectableHelper, result::QueryResult, sqlite::Sqlite,
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use std::fmt::Display;
use thiserror::Error;
use crate::{
crypto::integrity::v1::Integrable,
db::models::{self, EvmBasicGrant, EvmWalletAccess},
evm::utils,
};
pub mod ether_transfer;
pub mod token_transfers;
@@ -87,10 +85,10 @@ pub trait Policy: Sized {
// Create a new grant in the database based on the provided grant details, and return its ID
fn create_grant(
basic: &models::EvmBasicGrant,
basic: &EvmBasicGrant,
grant: &Self::Settings,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> impl std::future::Future<Output = QueryResult<DatabaseID>> + Send;
) -> impl Future<Output = QueryResult<DatabaseID>> + Send;
// Try to find an existing grant that matches the transaction context, and return its details if found
// Additionally, return ID of basic grant for shared-logic checks like rate limits and validity periods
@@ -127,19 +125,19 @@ pub enum SpecificMeaning {
TokenTransfer(token_transfers::Meaning),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, arbiter_macros::Hashable)]
pub struct TransactionRateLimit {
pub count: u32,
pub window: Duration,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, arbiter_macros::Hashable)]
pub struct VolumeRateLimit {
pub max_volume: U256,
pub window: Duration,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[derive(Clone, Debug, PartialEq, Eq, Hash, arbiter_macros::Hashable)]
pub struct SharedGrantSettings {
pub wallet_access_id: i32,
pub chain: ChainId,
@@ -157,7 +155,7 @@ impl SharedGrantSettings {
pub(crate) fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
Ok(Self {
wallet_access_id: model.wallet_access_id,
chain: model.chain_id as u64, // safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants
chain: model.chain_id.into(),
valid_from: model.valid_from.map(Into::into),
valid_until: model.valid_until.map(Into::into),
max_gas_fee_per_gas: model
@@ -168,10 +166,11 @@ impl SharedGrantSettings {
.max_priority_fee_per_gas
.map(|b| utils::try_bytes_to_u256(&b))
.transpose()?,
#[expect(clippy::cast_sign_loss, clippy::as_conversions, reason = "fixme! #86")]
rate_limit: match (model.rate_limit_count, model.rate_limit_window_secs) {
(Some(count), Some(window_secs)) => Some(TransactionRateLimit {
count: count as u32,
window: Duration::seconds(window_secs as i64),
window: Duration::seconds(window_secs.into()),
}),
_ => None,
},
@@ -181,7 +180,7 @@ impl SharedGrantSettings {
pub async fn query_by_id(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
id: i32,
) -> diesel::result::QueryResult<Self> {
) -> QueryResult<Self> {
use crate::db::schema::evm_basic_grant;
let basic_grant: EvmBasicGrant = evm_basic_grant::table
@@ -200,7 +199,7 @@ pub enum SpecificGrant {
TokenTransfer(token_transfers::Settings),
}
#[derive(Debug)]
#[derive(Debug, arbiter_macros::Hashable)]
pub struct CombinedSettings<PolicyGrant> {
pub shared: SharedGrantSettings,
pub specific: PolicyGrant,
@@ -219,38 +218,3 @@ impl<P: Integrable> Integrable for CombinedSettings<P> {
const KIND: &'static str = P::KIND;
const VERSION: i32 = P::VERSION;
}
use crate::crypto::integrity::hashing::Hashable;
impl Hashable for TransactionRateLimit {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.count.hash(hasher);
self.window.hash(hasher);
}
}
impl Hashable for VolumeRateLimit {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.max_volume.hash(hasher);
self.window.hash(hasher);
}
}
impl Hashable for SharedGrantSettings {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.wallet_access_id.hash(hasher);
self.chain.hash(hasher);
self.valid_from.hash(hasher);
self.valid_until.hash(hasher);
self.max_gas_fee_per_gas.hash(hasher);
self.max_priority_fee_per_gas.hash(hasher);
self.rate_limit.hash(hasher);
}
}
impl<P: Hashable> Hashable for CombinedSettings<P> {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.shared.hash(hasher);
self.specific.hash(hasher);
}
}

View File

@@ -1,30 +1,32 @@
use std::collections::HashMap;
use std::fmt::Display;
use alloy::primitives::{Address, U256};
use chrono::{DateTime, Duration, Utc};
use diesel::dsl::{auto_type, insert_into};
use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, JoinOnDsl, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::crypto::integrity::v1::Integrable;
use crate::db::models::{
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit,
NewEvmEtherTransferLimit, SqliteTimestamp,
};
use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log};
use crate::evm::policies::{
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
};
use super::{DatabaseID, EvalContext, EvalViolation};
use crate::{
crypto::integrity::v1::Integrable,
db::models::{
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit,
NewEvmEtherTransferLimit, SqliteTimestamp,
},
db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log},
db::{
models::{self, NewEvmEtherTransferGrant, NewEvmEtherTransferGrantTarget},
models::{NewEvmEtherTransferGrant, NewEvmEtherTransferGrantTarget},
schema::{evm_ether_transfer_grant, evm_ether_transfer_grant_target},
},
evm::policies::{
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
VolumeRateLimit,
},
evm::{policies::Policy, utils},
};
use alloy::primitives::{Address, U256};
use chrono::{DateTime, Duration, Utc};
use diesel::{
dsl::{auto_type, insert_into},
prelude::*,
sqlite::Sqlite,
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use std::{collections::HashMap, fmt::Display};
#[auto_type]
fn grant_join() -> _ {
evm_ether_transfer_grant::table.inner_join(
@@ -32,8 +34,6 @@ fn grant_join() -> _ {
)
}
use super::{DatabaseID, EvalContext, EvalViolation};
// Plain ether transfer
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning {
@@ -46,13 +46,13 @@ impl Display for Meaning {
}
}
impl From<Meaning> for SpecificMeaning {
fn from(val: Meaning) -> SpecificMeaning {
SpecificMeaning::EtherTransfer(val)
fn from(val: Meaning) -> Self {
Self::EtherTransfer(val)
}
}
// A grant for ether transfers, which can be scoped to specific target addresses and volume limits
#[derive(Debug, Clone)]
#[derive(Debug, Clone, arbiter_macros::Hashable)]
pub struct Settings {
pub target: Vec<Address>,
pub limit: VolumeRateLimit,
@@ -61,18 +61,9 @@ impl Integrable for Settings {
const KIND: &'static str = "EtherTransfer";
}
use crate::crypto::integrity::hashing::Hashable;
impl Hashable for Settings {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.target.hash(hasher);
self.limit.hash(hasher);
}
}
impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant {
SpecificGrant::EtherTransfer(val)
fn from(val: Settings) -> Self {
Self::EtherTransfer(val)
}
}
@@ -83,9 +74,7 @@ async fn query_relevant_past_transaction(
) -> QueryResult<Vec<(U256, DateTime<Utc>)>> {
let past_transactions: Vec<(Vec<u8>, SqliteTimestamp)> = evm_transaction_log::table
.filter(evm_transaction_log::grant_id.eq(grant_id))
.filter(
evm_transaction_log::signed_at.ge(SqliteTimestamp(chrono::Utc::now() - longest_window)),
)
.filter(evm_transaction_log::signed_at.ge(SqliteTimestamp(Utc::now() - longest_window)))
.select((
evm_transaction_log::eth_value,
evm_transaction_log::signed_at,
@@ -112,7 +101,7 @@ async fn check_rate_limits(
let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?;
let window_start = chrono::Utc::now() - grant.settings.specific.limit.window;
let window_start = Utc::now() - grant.settings.specific.limit.window;
let prospective_cumulative_volume: U256 = past_transaction
.iter()
.filter(|(_, timestamp)| timestamp >= &window_start)
@@ -162,10 +151,15 @@ impl Policy for EtherTransfer {
}
async fn create_grant(
basic: &models::EvmBasicGrant,
basic: &EvmBasicGrant,
grant: &Self::Settings,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> diesel::result::QueryResult<DatabaseID> {
) -> QueryResult<DatabaseID> {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #86"
)]
let limit_id: i32 = insert_into(evm_ether_transfer_limit::table)
.values(NewEvmEtherTransferLimit {
window_secs: grant.limit.window.num_seconds() as i32,
@@ -200,7 +194,7 @@ impl Policy for EtherTransfer {
async fn try_find_grant(
context: &EvalContext,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> diesel::result::QueryResult<Option<Grant<Self::Settings>>> {
) -> QueryResult<Option<Grant<Self::Settings>>> {
let target_bytes = context.to.to_vec();
// Find a grant where:
@@ -254,7 +248,7 @@ impl Policy for EtherTransfer {
limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume)
.map_err(|err| diesel::result::Error::DeserializationError(Box::new(err)))?,
window: chrono::Duration::seconds(limit.window_secs as i64),
window: Duration::seconds(limit.window_secs.into()),
},
};
@@ -274,7 +268,7 @@ impl Policy for EtherTransfer {
_log_id: i32,
_grant: &Grant<Self::Settings>,
_conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> diesel::result::QueryResult<()> {
) -> QueryResult<()> {
// Basic log is sufficient
Ok(())
@@ -327,7 +321,7 @@ impl Policy for EtherTransfer {
.map(|(basic, specific)| {
let targets: Vec<Address> = targets_by_grant
.get(&specific.id)
.map(|v| v.as_slice())
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.filter_map(|t| {
@@ -351,7 +345,7 @@ impl Policy for EtherTransfer {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
|e| diesel::result::Error::DeserializationError(Box::new(e)),
)?,
window: Duration::seconds(limit.window_secs as i64),
window: Duration::seconds(limit.window_secs.into()),
},
},
},

View File

@@ -1,27 +1,30 @@
use super::{EtherTransfer, Settings};
use crate::{
db::{
self, DatabaseConnection, models,
models::{
EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog,
SqliteTimestamp,
},
schema,
schema::{evm_basic_grant, evm_transaction_log},
},
evm::{
policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils,
},
};
use alloy::primitives::{Address, Bytes, U256, address};
use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into};
use diesel::{ExpressionMethods as _, SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
use crate::db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::{
policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils,
};
use super::{EtherTransfer, Settings};
const WALLET_ACCESS_ID: i32 = 1;
const CHAIN_ID: u64 = 1;
const CHAIN_ID: alloy::primitives::ChainId = 1;
const ALLOWED: Address = address!("1111111111111111111111111111111111111111");
const OTHER: Address = address!("2222222222222222222222222222222222222222");
@@ -30,8 +33,9 @@ fn ctx(to: Address, value: U256) -> EvalContext {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
wallet_id: EvmWalletId::from_raw(10),
client_id: 20,
revoked_at: None,
created_at: SqliteTimestamp(Utc::now()),
},
chain: CHAIN_ID,
@@ -43,11 +47,83 @@ fn ctx(to: Address, value: U256) -> EvalContext {
}
}
/// Creates the parent chain a fresh `evm_wallet_access` row needs under foreign-key
/// enforcement (a root key, an aead-encrypted secret, a wallet, and a client) and returns
/// the new access row's id.
async fn seed_wallet_access(conn: &mut DatabaseConnection) -> i32 {
let root_key_id: models::RootKeyHistoryId = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: vec![0u8; 32],
tag: vec![0u8; 16],
root_key_encryption_nonce: vec![0u8; 24],
data_encryption_nonce: vec![0u8; 24],
schema_version: 1,
salt: vec![0u8; 16],
})
.returning(schema::root_key_history::id)
.get_result(conn)
.await
.unwrap();
let aead_id: i32 = insert_into(schema::aead_encrypted::table)
.values(&models::NewAeadEncrypted {
ciphertext: vec![0u8; 32],
tag: vec![0u8; 16],
current_nonce: vec![0u8; 24],
schema_version: 1,
associated_root_key_id: root_key_id,
created_at: Utc::now().into(),
})
.returning(schema::aead_encrypted::id)
.get_result(conn)
.await
.unwrap();
let wallet_id: EvmWalletId = insert_into(schema::evm_wallet::table)
.values((
schema::evm_wallet::address.eq(rand::random::<[u8; 20]>().to_vec()),
schema::evm_wallet::aead_encrypted_id.eq(aead_id),
))
.returning(schema::evm_wallet::id)
.get_result(conn)
.await
.unwrap();
let metadata_id: i32 = insert_into(schema::client_metadata::table)
.values(schema::client_metadata::name.eq("test"))
.returning(schema::client_metadata::id)
.get_result(conn)
.await
.unwrap();
let client_id: i32 = insert_into(schema::program_client::table)
.values((
schema::program_client::public_key.eq(rand::random::<[u8; 32]>().to_vec()),
schema::program_client::metadata_id.eq(metadata_id),
))
.returning(schema::program_client::id)
.get_result(conn)
.await
.unwrap();
insert_into(schema::evm_wallet_access::table)
.values((
schema::evm_wallet_access::wallet_id.eq(wallet_id),
schema::evm_wallet_access::client_id.eq(client_id),
))
.returning(schema::evm_wallet_access::id)
.get_result(conn)
.await
.unwrap()
}
async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicGrant {
let wallet_access_id = seed_wallet_access(conn).await;
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
wallet_access_id,
chain_id: CHAIN_ID.into(),
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
@@ -159,8 +235,8 @@ async fn evaluate_passes_when_volume_within_limit() {
insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog {
grant_id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
wallet_access_id: basic.wallet_access_id,
chain_id: CHAIN_ID.into(),
eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
@@ -201,8 +277,8 @@ async fn evaluate_rejects_volume_over_limit() {
insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog {
grant_id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
wallet_access_id: basic.wallet_access_id,
chain_id: CHAIN_ID.into(),
eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
@@ -244,8 +320,8 @@ async fn evaluate_passes_at_exactly_volume_limit() {
insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog {
grant_id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
wallet_access_id: basic.wallet_access_id,
chain_id: CHAIN_ID.into(),
eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
@@ -340,7 +416,7 @@ proptest::proptest! {
) {
use rand::{SeedableRng, seq::SliceRandom};
use sha2::Digest;
use crate::crypto::integrity::hashing::Hashable;
use arbiter_crypto::hashing::Hashable;
let addrs: Vec<Address> = raw_addrs.iter().map(|b| Address::from(*b)).collect();
let mut shuffled = addrs.clone();

View File

@@ -1,16 +1,4 @@
use std::collections::HashMap;
use crate::db::schema::{
evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log,
evm_token_transfer_volume_limit,
};
use crate::evm::{
abi::IERC20::transferCall,
policies::{
Grant, Policy, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
},
utils,
};
use super::{DatabaseID, EvalContext, EvalViolation};
use crate::{
crypto::integrity::Integrable,
db::models::{
@@ -18,20 +6,33 @@ use crate::{
NewEvmTokenTransferGrant, NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit,
SqliteTimestamp,
},
db::schema::{
evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log,
evm_token_transfer_volume_limit,
},
evm::policies::CombinedSettings,
evm::{
abi::IERC20::transferCall,
policies::{
Grant, Policy, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
},
utils,
},
};
use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo};
use alloy::{
primitives::{Address, U256},
sol_types::SolCall,
};
use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo};
use chrono::{DateTime, Duration, Utc};
use diesel::dsl::{auto_type, insert_into};
use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, prelude::*};
use diesel::{
dsl::{auto_type, insert_into},
prelude::*,
sqlite::Sqlite,
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use super::{DatabaseID, EvalContext, EvalViolation};
use std::collections::HashMap;
#[auto_type]
fn grant_join() -> _ {
@@ -56,13 +57,13 @@ impl std::fmt::Display for Meaning {
}
}
impl From<Meaning> for SpecificMeaning {
fn from(val: Meaning) -> SpecificMeaning {
SpecificMeaning::TokenTransfer(val)
fn from(val: Meaning) -> Self {
Self::TokenTransfer(val)
}
}
// A grant for token transfers, which can be scoped to specific target addresses and volume limits
#[derive(Debug, Clone)]
#[derive(Debug, Clone, arbiter_macros::Hashable)]
pub struct Settings {
pub token_contract: Address,
pub target: Option<Address>,
@@ -72,19 +73,9 @@ impl Integrable for Settings {
const KIND: &'static str = "TokenTransfer";
}
use crate::crypto::integrity::hashing::Hashable;
impl Hashable for Settings {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.token_contract.hash(hasher);
self.target.hash(hasher);
self.volume_limits.hash(hasher);
}
}
impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant {
SpecificGrant::TokenTransfer(val)
fn from(val: Settings) -> Self {
Self::TokenTransfer(val)
}
}
@@ -95,10 +86,7 @@ async fn query_relevant_past_transfers(
) -> QueryResult<Vec<(U256, DateTime<Utc>)>> {
let past_logs: Vec<(Vec<u8>, SqliteTimestamp)> = evm_token_transfer_log::table
.filter(evm_token_transfer_log::grant_id.eq(grant_id))
.filter(
evm_token_transfer_log::created_at
.ge(SqliteTimestamp(chrono::Utc::now() - longest_window)),
)
.filter(evm_token_transfer_log::created_at.ge(SqliteTimestamp(Utc::now() - longest_window)))
.select((
evm_token_transfer_log::value,
evm_token_transfer_log::created_at,
@@ -138,7 +126,7 @@ async fn check_volume_rate_limits(
let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?;
for limit in &grant.settings.specific.volume_limits {
let window_start = chrono::Utc::now() - limit.window;
let window_start = Utc::now() - limit.window;
let prospective_cumulative_volume: U256 = past_transfers
.iter()
.filter(|(_, timestamp)| timestamp >= &window_start)
@@ -214,6 +202,11 @@ impl Policy for TokenTransfer {
.await?;
for limit in &grant.volume_limits {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #86"
)]
insert_into(evm_token_transfer_volume_limit::table)
.values(NewEvmTokenTransferVolumeLimit {
grant_id,
@@ -263,7 +256,7 @@ impl Policy for TokenTransfer {
max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(|err| {
diesel::result::Error::DeserializationError(Box::new(err))
})?,
window: Duration::seconds(row.window_secs as i64),
window: Duration::seconds(row.window_secs.into()),
})
})
.collect::<QueryResult<Vec<_>>>()?;
@@ -313,7 +306,7 @@ impl Policy for TokenTransfer {
.values(NewEvmTokenTransferLog {
grant_id: grant.id,
log_id,
chain_id: context.chain as i32,
chain_id: context.chain.into(),
token_contract: context.to.to_vec(),
recipient_address: meaning.to.to_vec(),
value: utils::u256_to_bytes(meaning.value).to_vec(),
@@ -362,7 +355,7 @@ impl Policy for TokenTransfer {
.map(|(basic, specific)| {
let volume_limits: Vec<VolumeRateLimit> = limits_by_grant
.get(&specific.id)
.map(|v| v.as_slice())
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.map(|row| {
@@ -370,7 +363,7 @@ impl Policy for TokenTransfer {
max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(|e| {
diesel::result::Error::DeserializationError(Box::new(e))
})?,
window: Duration::seconds(row.window_secs as i64),
window: Duration::seconds(row.window_secs.into()),
})
})
.collect::<QueryResult<Vec<_>>>()?;

Some files were not shown because too many files have changed in this diff Show More