Compare commits

..

109 Commits

Author SHA1 Message Date
CleverWild
62fb83469f refactor(custody): drop the single-implementation CustodyStore trait 2026-09-11 15:45:21 +02:00
CleverWild
d49c39130a fix(vault): apply Shamir custody review feedback 2026-09-11 15:02:59 +02:00
CleverWild
c722712166 feat(vault)!: add multi-operator Shamir custody 2026-09-11 10:48:01 +02:00
0677695b16 Merge pull request 'feat(proto)!: Shamir re-key, governance and bootstrapping vault state' (#102) from feat-shamir-protos into main
Reviewed-on: #102
Reviewed-by: Stas <business@jexter.tech>
2026-09-05 12:13:20 +00:00
Skipper
558910621b merge: rebase on main 2026-09-05 14:09:57 +02:00
CleverWild
d7fe3a6377 housekeeping: actualize AGENTS.md 2026-08-28 22:16:17 +02:00
Skipper
f97b8f9424 feat(grpc): governance contract 2026-08-26 13:01:44 +02:00
Skipper
27925a67ad merge: custom db ids for entities 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
a0ac63f05c Merge pull request 'security: batch of fixes' (#95) from zeroized-bootstrap-token into main
Reviewed-on: #95
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
CleverWild
8dabec893e fix(user-agent): zombie sessions #74 2026-08-26 13:01:44 +02:00
Clippy Bot
3d4bba3584 test(client-auth): update metadata test to reflect frozen-metadata behavior 2026-08-26 13:01:44 +02:00
Skipper
853a038363 fix(server): MacOS build version 2026-08-26 13:01:44 +02:00
3ff0875c48 Merge pull request 'security(server): bind grant revocation state (revoked_at) to integrity hash' (#83) from security-hash-revoke_at into main
Reviewed-on: #83
2026-08-26 13:01:44 +02:00
Clippy Bot
7cc745182f Merge branch 'main' into zeroized-bootstrap-token 2026-08-26 13:01:44 +02:00
Skipper
a31ef99338 housekeeping(server): removed unused deps 2026-08-26 13:01:44 +02:00
CleverWild
7de235f144 fix(smlang::statemachine): macro invocation requires inner types to be public 2026-08-26 13:01:44 +02:00
CleverWild
2b8acad415 fix: lints 2026-08-26 13:01:44 +02:00
Clippy Bot
95799e5da8 fix: lints 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
CleverWild
49e5f2c7f6 Merge branch 'main' into security-hash-revoke_at 2026-08-26 13:01:44 +02:00
CleverWild
00426e597d fix!: protect evm_wallet integrity and bind key ciphertext to wallet address
Three independent failures allowed an offline attacker with DB write access
to sign transactions using a different wallet's private key:
1. evm_wallet had no HMAC envelope — aead_encrypted_id could be swapped silently.
2. AEAD used a static tag as AAD — any valid ciphertext decrypted as any wallet key.
3. No post-decryption check that the derived address matched the requested wallet.

Fix: sign_entity covers (address, aead_encrypted_id) in a single transaction;
CreateNew/Decrypt take caller-provided AAD (wallet address bytes); after decryption
signer.address() is verified against the requested wallet address.
2026-08-26 13:01:44 +02:00
CleverWild
10486e6a32 fix(client-auth): freeze client metadata after initial operator approval 2026-08-26 13:01:44 +02:00
CleverWild
55b5b9361f fix(operator): bind sign-transaction to operator-approved client set 2026-08-26 13:01:44 +02:00
CleverWild
5824df16b1 fix(integrity): return AttestationStatus::Unavailable for key version mismatch 2026-08-26 13:01:44 +02:00
CleverWild
39d5dfd2e8 fix(wallet): delete access entries by entry ID instead of wallet ID 2026-08-26 13:01:44 +02:00
CleverWild
add058f1d5 security(bootstrap): use SafeCell for token storage instead of zeroize 2026-08-26 13:01:44 +02:00
CleverWild
5ecf3508d6 fix(UA): signing endpoint accepts arbitrary client_id 2026-08-26 13:01:44 +02:00
CleverWild
2b53023234 security: feat unseal and bootstrap handshake brute-force protection 2026-08-26 13:01:44 +02:00
CleverWild
850e2b8669 fix(bootstrap): token persists on disk with weak file permissions #59 2026-08-26 13:01:44 +02:00
CleverWild
4e11c27129 security(server): use SysRng directly for bootstrap token generation
Replace make_rng()/StdRng with UnwrapErr(SysRng) to eliminate the PRNG
intermediate layer and make OS entropy derivation explicit and unambiguous.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 13:01:44 +02:00
CleverWild
84e1ef7c85 security(memory): zeroization of bootstrap token 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
CleverWild
7e23b3b2ec security(evm): remove client-controlled wallet_access_id from grant revocation 2026-08-26 13:00:40 +02:00
Skipper
602be3aa11 merge: feat-lints into main 2026-08-26 13:00:40 +02:00
CleverWild
bb28d311a1 feat(evm): add wallet_access_id to grant deletion requests and revocation logic 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
3302bb8995 refactor(evm): implement revoke_grant method for grant revocation 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
CleverWild
772936937b Merge branch 'security-hash-revoke_at' of ssh://git.markettakers.org:22222/MarketTakers/arbiter into security-hash-revoke_at 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
CleverWild
7a64bcd1bf revert(server): bind grant revocation state (revoked_at) to integrity hash 2026-08-26 13:00:40 +02:00
CleverWild
943d0ee72f security(server): bind grant revocation state (revoked_at) to integrity hash 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
104 changed files with 4439 additions and 4904 deletions

2
.gitignore vendored
View File

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

107
AGENTS.md
View File

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

150
CLAUDE.md
View File

@@ -1,149 +1 @@
# CLAUDE.md Refer to @AGENTS.md for instructions.
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
```
### Code Conventions
**`#[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:
- 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.
## 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.

View File

@@ -4,7 +4,7 @@
## Security warning ## Security warning
Arbiter can't meaningfully protect against host compromise. Potential attack flow: Arbiter can't meaningfully protect against host compromise. Potential attack flow:
- Attacker steals TLS keys from database - 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 - Pretend to be in sealed state and performing DH with client
- Steals user password and derives seal key - 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: 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. - **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. - **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: All peers authenticate via public-key cryptography using a challenge-response protocol:
1. The peer sends its public key and requests a challenge. 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). 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 challenge with its private key and sends the signature back. 3. The peer signs the canonical challenge payload with its private key and sends the signature back.
4. The server verifies the signature: 4. The server verifies the signature:
- **Pass:** The connection is considered authenticated. - **Pass:** The connection is considered authenticated.
- **Fail:** The server closes the connection. - **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. - **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 ### 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 ## 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 ### 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: 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: 2. During bootstrap setup, that operator declares:
- the total number of ordinary operators - the total number of ordinary operators
- the total number of Recovery Operators - the total number of Recovery Operators
3. The vault enters **multi-bootstrap mode**. 3. The vault enters **multi-bootstrap mode**.
4. While in 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 - every Recovery Operator must also connect using the bootstrap token
- each participant is registered individually - each participant is registered individually
- each participant's share is created and protected with that participant's credentials - 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**: Peers verify the server by its **public key fingerprint**:
- **User Agent (local):** Receives the fingerprint automatically through the bootstrap token. - **Operator (local):** Receives the fingerprint automatically through the bootstrap token.
- **User Agent (remote) / SDK Client:** Must receive the fingerprint out-of-band. - **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. > 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 ### 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. 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: 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. - **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. - **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 ### 7.1 Fundamental Rules
- SDK clients have **no access by default**. - 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**. - 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. 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 autonumber
actor SDK as SDK Client actor SDK as SDK Client
participant Server participant Server
participant UA as User Agent participant operator as Operator
SDK->>Server: SignTransactionRequest SDK->>Server: SignTransactionRequest
Server->>Server: Resolve wallet and wallet visibility Server->>Server: Resolve wallet and wallet visibility
alt Visibility approval required alt Visibility approval required
Server->>UA: Ask for wallet visibility approval Server->>operator: Ask for wallet visibility approval
UA-->>Server: Vote result operator-->>Server: Vote result
end end
Server->>Server: Evaluate transaction Server->>Server: Evaluate transaction
Server->>Server: Load grant and limits context Server->>Server: Load grant and limits context
alt Grant approval required alt Grant approval required
Server->>UA: Ask for execution / grant approval Server->>operator: Ask for execution / grant approval
UA-->>Server: Vote result operator-->>Server: Vote result
opt Create persistent grant opt Create persistent grant
Server->>Server: Create and store grant Server->>Server: Create and store grant
end end

View File

@@ -8,10 +8,10 @@ This document covers concrete technology choices and dependencies. For the archi
### Authentication Result Semantics ### 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` - **Client:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `APPROVAL_DENIED`, `NO_OPERATORS_ONLINE`, or `INTERNAL`
- **User-agent:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `BOOTSTRAP_REQUIRED`, `TOKEN_INVALID`, 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: 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 ### 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 ```mermaid
flowchart TD flowchart TD
@@ -31,10 +31,10 @@ flowchart TD
C -- yes --> G[Generate AuthChallenge] C -- yes --> G[Generate AuthChallenge]
C -- no --> E[Ask all UserAgents:\nClientConnectionRequest] C -- no --> E[Ask all Operators:\nClientConnectionRequest]
E --> F{First response} E --> F{First response}
F -- denied --> Z([Reject connection]) F -- denied --> Z([Reject connection])
F -- approved --> F2[Cancel remaining\nUserAgent requests] F -- approved --> F2[Cancel remaining\nOperator requests]
F2 --> F3[INSERT client] F2 --> F3[INSERT client]
F3 --> G F3 --> G
@@ -45,7 +45,13 @@ flowchart TD
K -- yes --> J([Session started]) 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
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 - **Supported schemes:** ML-DSA
- **Why:** Secure Enclave (MacOS) support them natively, on other platforms we could emulate while they roll-out - **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 ### 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 request carries a monotonically increasing request ID
- Every normal response echoes the request ID it corresponds to - Every normal response echoes the request ID it corresponds to
@@ -135,7 +141,7 @@ flowchart TD
L -- Yes --> M[Check grant limits] L -- Yes --> M[Check grant limits]
L -- No --> N[Start execution or grant voting flow] 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 -- Reject --> Z4[Return no matching grant error]
O -- Allow once --> M O -- Allow once --> M
O -- Create grant --> P[Create grant with user-selected limits] 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 |

View File

@@ -1,51 +1,51 @@
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html # @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
[[tools.ast-grep]] [[tools.ast-grep]]
version = "0.42.0" version = "0.42.1"
backend = "aqua:ast-grep/ast-grep" backend = "aqua:ast-grep/ast-grep"
[tools.ast-grep."platforms.linux-arm64"] [tools.ast-grep."platforms.linux-arm64"]
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836" checksum = "sha256:3ba383839044cf9817929435f5ce0027f91d06931e8efb32d942e58d73d92be5"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.linux-arm64-musl"] [tools.ast-grep."platforms.linux-arm64-musl"]
checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836" checksum = "sha256:3ba383839044cf9817929435f5ce0027f91d06931e8efb32d942e58d73d92be5"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip" 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"] [tools.ast-grep."platforms.linux-x64"]
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651" checksum = "sha256:5de8b87cba67fc8dc3e239d54b6484802ad745a7ae3de76be4fe89661dc52657"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-unknown-linux-gnu.zip"
[tools.ast-grep."platforms.linux-x64-musl"] [tools.ast-grep."platforms.linux-x64-musl"]
checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651" checksum = "sha256:5de8b87cba67fc8dc3e239d54b6484802ad745a7ae3de76be4fe89661dc52657"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip" 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"] [tools.ast-grep."platforms.macos-arm64"]
checksum = "sha256:fc300d5293b1c770a5aece03a8a193b92e71e87cec726c28096990691a582620" checksum = "sha256:c3961d8e8a4ee0ce2d0d98c7beeb168bb331cdc766b53630118a7b6c4fd39015"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-apple-darwin.zip" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-aarch64-apple-darwin.zip"
[tools.ast-grep."platforms.macos-x64"] [tools.ast-grep."platforms.macos-x64"]
checksum = "sha256:979ffe611327056f4730a1ae71b0209b3b830f58b22c6ed194cda34f55400db2" checksum = "sha256:a038965bfd7fe44257c771cdf8918dc3467dd8ec0eef673b8b14f639b144cdbd"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-apple-darwin.zip" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-apple-darwin.zip"
[tools.ast-grep."platforms.windows-x64"] [tools.ast-grep."platforms.windows-x64"]
checksum = "sha256:55836fa1b2c65dc7d61615a4d9368622a0d2371a76d28b9a165e5a3ab6ae32a4" checksum = "sha256:fe34f631bb24c08ad146f92ca2a92971a53d179461b509fd8d32dc863bff9f83"
url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-pc-windows-msvc.zip" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.1/app-x86_64-pc-windows-msvc.zip"
[[tools."cargo:cargo-audit"]] [[tools."cargo:cargo-audit"]]
version = "0.22.1" version = "0.22.1"
backend = "cargo:cargo-audit" backend = "cargo:cargo-audit"
[[tools."cargo:cargo-edit"]] [[tools."cargo:cargo-edit"]]
version = "0.13.9" version = "0.13.10"
backend = "cargo:cargo-edit" backend = "cargo:cargo-edit"
[[tools."cargo:cargo-features-manager"]] [[tools."cargo:cargo-features-manager"]]
version = "0.11.1" version = "0.12.0"
backend = "cargo:cargo-features-manager" backend = "cargo:cargo-features-manager"
[[tools."cargo:cargo-insta"]] [[tools."cargo:cargo-insta"]]
version = "1.46.3" version = "1.47.2"
backend = "cargo:cargo-insta" backend = "cargo:cargo-insta"
[[tools."cargo:cargo-mutants"]] [[tools."cargo:cargo-mutants"]]
@@ -53,7 +53,7 @@ version = "27.0.0"
backend = "cargo:cargo-mutants" backend = "cargo:cargo-mutants"
[[tools."cargo:cargo-nextest"]] [[tools."cargo:cargo-nextest"]]
version = "0.9.126" version = "0.9.133"
backend = "cargo:cargo-nextest" backend = "cargo:cargo-nextest"
[[tools."cargo:cargo-shear"]] [[tools."cargo:cargo-shear"]]
@@ -65,7 +65,7 @@ version = "0.10.2"
backend = "cargo:cargo-vet" backend = "cargo:cargo-vet"
[[tools."cargo:diesel_cli"]] [[tools."cargo:diesel_cli"]]
version = "2.3.6" version = "2.3.7"
backend = "cargo:diesel_cli" backend = "cargo:diesel_cli"
[tools."cargo:diesel_cli".options] [tools."cargo:diesel_cli".options]
@@ -77,7 +77,7 @@ version = "2.12.0"
backend = "cargo:flutter_rust_bridge_codegen" backend = "cargo:flutter_rust_bridge_codegen"
[[tools.flutter]] [[tools.flutter]]
version = "3.38.9-stable" version = "3.41.7-stable"
backend = "asdf:flutter" backend = "asdf:flutter"
[[tools.protoc]] [[tools.protoc]]
@@ -113,44 +113,47 @@ checksum = "sha256:1ebd7c87baffb9f1c47169b640872bf5fb1e4408079c691af527be9561d8f
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-win64.zip" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-win64.zip"
[[tools.python]] [[tools.python]]
version = "3.14.3" version = "3.14.4"
backend = "core:python" backend = "core:python"
[tools.python."platforms.linux-arm64"] [tools.python."platforms.linux-arm64"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be" checksum = "sha256:b8b597fdb2f8dccdc502c11947b60a4b65eb6bce79cfa60c7ccf9b6e8352c60a"
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" 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" provenance = "github-attestations"
[tools.python."platforms.linux-arm64-musl"] [tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be" checksum = "sha256:b8b597fdb2f8dccdc502c11947b60a4b65eb6bce79cfa60c7ccf9b6e8352c60a"
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" 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" provenance = "github-attestations"
[tools.python."platforms.linux-x64"] [tools.python."platforms.linux-x64"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39" checksum = "sha256:fe9a9c32d13870af632cbac3dfc7528ae53597e94472aa4c7d6a42e8166136cd"
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" 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" provenance = "github-attestations"
[tools.python."platforms.linux-x64-musl"] [tools.python."platforms.linux-x64-musl"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39" checksum = "sha256:fe9a9c32d13870af632cbac3dfc7528ae53597e94472aa4c7d6a42e8166136cd"
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" 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" provenance = "github-attestations"
[tools.python."platforms.macos-arm64"] [tools.python."platforms.macos-arm64"]
checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9" checksum = "blake3:0314ec66e0f33ec04959583b5900bc8edae371a396aa96b8874e750d1fe936e6"
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" 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" provenance = "github-attestations"
[tools.python."platforms.macos-x64"] [tools.python."platforms.macos-x64"]
checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0" checksum = "sha256:d51250a32fa5d9f0799c7bcb71720c27b10a3afd4a7de288120f96085d508a5a"
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" 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" provenance = "github-attestations"
[tools.python."platforms.windows-x64"] [tools.python."platforms.windows-x64"]
checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09" checksum = "sha256:a976991dcd085c1bb5d9a8084823a6bc8b7f9b079d8c432574a6ddd68c3a6fe1"
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" 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" provenance = "github-attestations"
[[tools.rust]] [[tools.rust]]
version = "1.93.0" version = "1.98.1"
backend = "core:rust" backend = "core:rust"
[tools.rust.options]
components = "clippy,rust-analyzer"

View File

@@ -1,17 +1,17 @@
[tools] [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-audit" = "0.22.1"
"cargo:cargo-vet" = "0.10.2" "cargo:cargo-vet" = "0.10.2"
flutter = "3.38.9-stable" flutter = "3.41.7-stable"
protoc = "29.6" protoc = "29.6"
"rust" = {version = "1.93.0", components = "clippy,rust-analyzer"} rust = { version = "latest", components = "clippy,rust-analyzer" }
"cargo:cargo-features-manager" = "0.11.1" "cargo:cargo-features-manager" = "0.12.0"
"cargo:cargo-nextest" = "0.9.126" "cargo:cargo-nextest" = "0.9.133"
"cargo:cargo-shear" = "latest" "cargo:cargo-shear" = "latest"
"cargo:cargo-insta" = "1.46.3" "cargo:cargo-insta" = "1.47.2"
python = "3.14.3" python = "3.14.4"
ast-grep = "0.42.0" ast-grep = "0.42.1"
"cargo:cargo-edit" = "0.13.9" "cargo:cargo-edit" = "0.13.10"
"cargo:cargo-mutants" = "27.0.0" "cargo:cargo-mutants" = "27.0.0"
"cargo:flutter_rust_bridge_codegen" = "2.12.0" "cargo:flutter_rust_bridge_codegen" = "2.12.0"
@@ -22,3 +22,5 @@ run = '''
dart pub global activate protoc_plugin && \ dart pub global activate protoc_plugin && \
protoc --dart_out=grpc:useragent/lib/proto --proto_path=protobufs/ $(find protobufs -name '*.proto' | sort) protoc --dart_out=grpc:useragent/lib/proto --proto_path=protobufs/ $(find protobufs -name '*.proto' | sort)
''' '''
[tasks.generate_schema]

View File

@@ -3,7 +3,7 @@ syntax = "proto3";
package arbiter; package arbiter;
import "client.proto"; import "client.proto";
import "user_agent.proto"; import "operator.proto";
message ServerInfo { message ServerInfo {
string version = 1; string version = 1;
@@ -12,5 +12,5 @@ message ServerInfo {
service ArbiterService { service ArbiterService {
rpc Client(stream arbiter.client.ClientRequest) returns (stream arbiter.client.ClientResponse); 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_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3; AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_APPROVAL_DENIED = 4; AUTH_RESULT_APPROVAL_DENIED = 4;
AUTH_RESULT_NO_USER_AGENTS_ONLINE = 5; AUTH_RESULT_NO_OPERATORS_ONLINE = 5;
AUTH_RESULT_INTERNAL = 6; AUTH_RESULT_INTERNAL = 6;
} }

View File

@@ -75,7 +75,7 @@ message SpecificGrant {
} }
} }
// --- UserAgent grant management --- // --- Operator grant management ---
message EvmGrantCreateRequest { message EvmGrantCreateRequest {
SharedSettings shared = 1; SharedSettings shared = 1;
SpecificGrant specific = 2; 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"; syntax = "proto3";
package arbiter.user_agent.auth; package arbiter.operator.auth;
message AuthChallengeRequest { message AuthChallengeRequest {
bytes pubkey = 1; bytes pubkey = 1;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
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 {
int32 recovery_operator_id = 1;
bytes passphrase = 2;
}
enum BootstrapResult {
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
BOOTSTRAP_RESULT_SUCCESS = 1;
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
BOOTSTRAP_RESULT_INVALID_KEY = 3;
BOOTSTRAP_RESULT_AWAITING_CONTRIBUTIONS = 4;
}
message Request {
oneof payload {
BootstrapEncryptedKey encrypted_key = 2;
DeclareCommittee declare_committee = 3;
ContributePassphrase contribute_passphrase = 4;
ContributeRecoveryPassphrase contribute_recovery_passphrase = 5;
}
}
message Response {
BootstrapResult result = 1;
}

View File

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

View File

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

View File

@@ -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 { enum VaultState {
VAULT_STATE_UNSPECIFIED = 0; VAULT_STATE_UNSPECIFIED = 0;
VAULT_STATE_UNBOOTSTRAPPED = 1; VAULT_STATE_UNBOOTSTRAPPED = 1;
VAULT_STATE_SEALED = 2; VAULT_STATE_BOOSTRAPPING = 2;
VAULT_STATE_UNSEALED = 3; VAULT_STATE_SEALED = 3;
VAULT_STATE_ERROR = 4; 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"

930
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,30 +6,28 @@ resolver = "3"
[workspace.dependencies] [workspace.dependencies]
alloy = "2.0.0" alloy = "2.0.4"
async-trait = "0.1.89" async-trait = "0.1.89"
base64 = "0.22.1" base64 = "0.22.1"
chrono = { version = "0.4.44", features = ["serde"] } chrono = { version = "0.4.44", features = ["serde"] }
ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] }
futures = "0.3.32" futures = "0.3.32"
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] } k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"} kameo = {git = "https://github.com/hdbg/kameo.git", rev = "3bbebac"}
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"} kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "3bbebac"}
hmac = "0.13.0" hmac = "0.13.0"
miette = { version = "7.6.0", features = ["fancy", "serde"] } miette = { version = "7.6.0", features = ["fancy", "serde"] }
ml-dsa = { version = "0.1.0-rc.8", features = ["zeroize"] } ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] }
mutants = "0.0.4" mutants = "0.0.4"
prost = "0.14.3" prost = "0.14.3"
prost-types = { version = "0.14.3", features = ["chrono"] } prost-types = { version = "0.14.3", features = ["chrono"] }
rand = "0.10.1" rand = "0.10.1"
rand_core = "0.10.1"
rcgen = { version = "0.14.7", features = [ "aws_lc_rs", "pem", "x509-parser", "zeroize" ], default-features = false } rcgen = { version = "0.14.7", features = [ "aws_lc_rs", "pem", "x509-parser", "zeroize" ], default-features = false }
rsa = { version = "0.9", features = ["sha2"] }
rstest = "0.26.1" rstest = "0.26.1"
rustls = { version = "0.23.38", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false } rustls = { version = "0.23.40", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
rustls-pki-types = "1.14.0" rustls-pki-types = "1.14.1"
sha2 = "0.11" sha2 = "0.11"
smlang = "0.8.0" smlang = "0.8.0"
spki = "0.8"
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.52.1", features = ["full"] } tokio = { version = "1.52.1", features = ["full"] }
tokio-stream = { version = "0.1.18", features = ["full"] } tokio-stream = { version = "0.1.18", features = ["full"] }
@@ -79,6 +77,8 @@ needless_pass_by_ref_mut = "allow"
pub_underscore_fields = "allow" pub_underscore_fields = "allow"
redundant_pub_crate = "allow" redundant_pub_crate = "allow"
uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {}) uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {})
too-many-lines = "allow" # this is a very common pattern in server code, and it's not always possible to break it down into smaller modules without hurting readability
unused_async_trait_impl = "allow" # too pedantic
# restriction lints # restriction lints
alloc_instead_of_core = "warn" alloc_instead_of_core = "warn"

View File

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

View File

@@ -26,13 +26,13 @@ use chrono::DateTime;
pub enum AuthError { pub enum AuthError {
#[error("Server sent invalid auth challenge")] #[error("Server sent invalid auth challenge")]
InvalidChallenge, InvalidChallenge,
#[error("Client approval denied by User Agent")] #[error("Client approval denied by Operator")]
ApprovalDenied, ApprovalDenied,
#[error("Auth challenge was not returned by server")] #[error("Auth challenge was not returned by server")]
MissingAuthChallenge, MissingAuthChallenge,
#[error("No User Agents online to approve client")] #[error("No Operators online to approve client")]
NoUserAgentsOnline, NoOperatorsOnline,
#[error("Signing key storage error")] #[error("Signing key storage error")]
Storage(#[from] StorageError), Storage(#[from] StorageError),
@@ -44,7 +44,7 @@ pub enum AuthError {
fn map_auth_result(code: i32) -> AuthError { fn map_auth_result(code: i32) -> AuthError {
match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) { match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) {
AuthResult::ApprovalDenied => AuthError::ApprovalDenied, AuthResult::ApprovalDenied => AuthError::ApprovalDenied,
AuthResult::NoUserAgentsOnline => AuthError::NoUserAgentsOnline, AuthResult::NoOperatorsOnline => AuthError::NoOperatorsOnline,
AuthResult::Unspecified AuthResult::Unspecified
| AuthResult::Success | AuthResult::Success
| AuthResult::InvalidKey | AuthResult::InvalidKey
@@ -100,7 +100,7 @@ async fn send_auth_challenge_solution(
key: &SigningKey, key: &SigningKey,
challenge: AuthChallenge, challenge: AuthChallenge,
) -> Result<(), AuthError> { ) -> Result<(), AuthError> {
let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos as i64); let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos.cast_signed());
let challenge = authn::AuthChallenge { let challenge = authn::AuthChallenge {
nonce: *challenge nonce: *challenge
.random .random

View File

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

View File

@@ -20,3 +20,6 @@ workspace = true
default = ["authn", "safecell"] default = ["authn", "safecell"]
authn = ["dep:ml-dsa", "dep:rand"] authn = ["dep:ml-dsa", "dep:rand"]
safecell = ["dep:memsafe"] safecell = ["dep:memsafe"]
[lib]
doctest = false

View File

@@ -7,7 +7,7 @@ use ml_dsa::{
use rand::RngExt; use rand::RngExt;
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client"; pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
pub static USERAGENT_CONTEXT: &[u8] = b"arbiter_user_agent"; pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator";
const NONCE_SIZE: usize = 32; const NONCE_SIZE: usize = 32;
@@ -192,7 +192,7 @@ mod tests {
use crate::authn::AuthChallenge; use crate::authn::AuthChallenge;
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, USERAGENT_CONTEXT}; use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, OPERATOR_CONTEXT};
#[test] #[test]
fn public_key_round_trip_decodes() { fn public_key_round_trip_decodes() {
@@ -227,7 +227,7 @@ mod tests {
.expect("signature should be created"); .expect("signature should be created");
assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature)); assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature));
assert!(!public_key.verify(&challenge, USERAGENT_CONTEXT, &signature)); assert!(!public_key.verify(&challenge, OPERATOR_CONTEXT, &signature));
} }
#[test] #[test]

View File

@@ -5,6 +5,7 @@ edition = "2024"
[lib] [lib]
proc-macro = true proc-macro = true
doctest = false
[dependencies] [dependencies]
proc-macro2 = "1.0" proc-macro2 = "1.0"

View File

@@ -9,7 +9,6 @@ license = "Apache-2.0"
tonic.workspace = true tonic.workspace = true
tokio.workspace = true tokio.workspace = true
futures.workspace = true futures.workspace = true
hex = "0.4.3"
tonic-prost = "0.14.5" tonic-prost = "0.14.5"
prost.workspace = true prost.workspace = true
kameo.workspace = true kameo.workspace = true
@@ -19,18 +18,18 @@ thiserror.workspace = true
rustls-pki-types.workspace = true rustls-pki-types.workspace = true
base64.workspace = true base64.workspace = true
prost-types.workspace = true prost-types.workspace = true
tracing.workspace = true
async-trait.workspace = true async-trait.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
[build-dependencies] [build-dependencies]
tonic-prost-build = "0.14.5" tonic-prost-build = "0.14.5"
protoc-bin-vendored = "3"
[dev-dependencies] [dev-dependencies]
rstest.workspace = true rstest.workspace = true
rand.workspace = true
rcgen.workspace = true rcgen.workspace = true
[lib]
doctest = false
[package.metadata.cargo-shear] [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( .compile_protos(
&[ &[
format!("{}/arbiter.proto", PROTOBUF_DIR), format!("{}/arbiter.proto", PROTOBUF_DIR),
format!("{}/user_agent.proto", PROTOBUF_DIR), format!("{}/operator.proto", PROTOBUF_DIR),
format!("{}/client.proto", PROTOBUF_DIR), format!("{}/client.proto", PROTOBUF_DIR),
format!("{}/evm.proto", PROTOBUF_DIR), format!("{}/evm.proto", PROTOBUF_DIR),
], ],

View File

@@ -12,30 +12,38 @@ pub mod proto {
} }
} }
pub mod user_agent { pub mod operator {
tonic::include_proto!("arbiter.user_agent"); tonic::include_proto!("arbiter.operator");
pub mod auth { pub mod auth {
tonic::include_proto!("arbiter.user_agent.auth"); tonic::include_proto!("arbiter.operator.auth");
} }
pub mod evm { 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 { pub mod sdk_client {
tonic::include_proto!("arbiter.user_agent.sdk_client"); tonic::include_proto!("arbiter.operator.sdk_client");
} }
pub mod vault { pub mod vault {
tonic::include_proto!("arbiter.user_agent.vault"); tonic::include_proto!("arbiter.operator.vault");
pub mod bootstrap { 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 { pub mod unseal {
tonic::include_proto!("arbiter.user_agent.vault.unseal"); tonic::include_proto!("arbiter.operator.vault.unseal");
} }
} }
} }

View File

@@ -9,8 +9,8 @@ license = "Apache-2.0"
workspace = true workspace = true
[dependencies] [dependencies]
diesel = { version = "2.3.7", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] } diesel = { version = "2.3.9", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] }
diesel-async = { version = "0.8.0", features = [ diesel-async = { version = "0.9.0", features = [
"bb8", "bb8",
"migrations", "migrations",
"sqlite", "sqlite",
@@ -27,17 +27,13 @@ tokio.workspace = true
rustls.workspace = true rustls.workspace = true
smlang.workspace = true smlang.workspace = true
thiserror.workspace = true thiserror.workspace = true
fatality = "0.1.1" diesel_migrations = { version = "2.3.2", features = ["sqlite"] }
diesel_migrations = { version = "2.3.1", features = ["sqlite"] }
async-trait.workspace = true async-trait.workspace = true
secrecy = "0.10.3"
futures.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
dashmap = "6.1.0"
rand.workspace = true rand.workspace = true
rand_core.workspace = true
rcgen.workspace = true rcgen.workspace = true
chrono.workspace = true chrono.workspace = true
zeroize = { version = "1.8.2", features = ["std", "simd"] }
kameo.workspace = true kameo.workspace = true
chacha20poly1305 = { version = "0.10.1", features = ["std"] } chacha20poly1305 = { version = "0.10.1", features = ["std"] }
argon2 = { version = "0.5.3", features = ["zeroize"] } argon2 = { version = "0.5.3", features = ["zeroize"] }
@@ -46,23 +42,22 @@ strum = { version = "0.28.0", features = ["derive"] }
pem = "3.0.6" pem = "3.0.6"
sha2.workspace = true sha2.workspace = true
hmac.workspace = true hmac.workspace = true
spki.workspace = true
alloy.workspace = true alloy.workspace = true
prost-types.workspace = true prost-types.workspace = true
prost.workspace = true
arbiter-tokens-registry.path = "../arbiter-tokens-registry" arbiter-tokens-registry.path = "../arbiter-tokens-registry"
anyhow = "1.0.102" anyhow = "1.0.102"
serde_with = "3.18.0"
mutants.workspace = true mutants.workspace = true
subtle = "2.6.1" subtle = "2.6.1"
ml-dsa.workspace = true
ed25519-dalek.workspace = true
x25519-dalek.workspace = true x25519-dalek.workspace = true
k256.workspace = true k256.workspace = true
kameo_actors.workspace = true kameo_actors.workspace = true
vsss-rs = "6.0.1"
[dev-dependencies] [dev-dependencies]
insta = "1.47.2"
proptest = "1.11.0" proptest = "1.11.0"
rstest.workspace = true rstest.workspace = true
test-log = { version = "0.2", default-features = false, features = ["trace"] } test-log = { version = "0.2", default-features = false, features = ["trace"] }
ml-dsa.workspace = true
[lib]
doctest = false

View File

@@ -37,19 +37,32 @@ create table if not exists tls_history (
create table if not exists arbiter_settings ( create table if not exists arbiter_settings (
id INTEGER not null PRIMARY KEY CHECK (id = 1), -- singleton row, id must be 1 id INTEGER not null PRIMARY KEY CHECK (id = 1), -- singleton row, id must be 1
root_key_id integer references root_key_history (id) on delete RESTRICT, -- if null, means wasn't bootstrapped yet root_key_id integer references root_key_history (id) on delete RESTRICT, -- if null, means wasn't bootstrapped yet
tls_id integer references tls_history (id) on delete RESTRICT tls_id integer references tls_history (id) on delete RESTRICT,
shamir_threshold integer
) STRICT; ) STRICT;
insert into arbiter_settings (id) values (1) on conflict do nothing; insert into arbiter_settings (id) values (1) on conflict do nothing;
-- ensure singleton row exists -- ensure singleton row exists
create table if not exists useragent_client ( create table if not exists operator_identity (
id integer not null primary key, id integer not null primary key,
public_key blob not null, public_key blob not null,
created_at integer not null default(unixepoch ('now')), created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now')) updated_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create unique index if not exists uniq_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 ( create table if not exists client_metadata (
id integer not null primary key, id integer not null primary key,

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ use crate::{
actors::flow_coordinator::ApprovalError, actors::flow_coordinator::ApprovalError,
peers::{ peers::{
client::ClientProfile, client::ClientProfile,
user_agent::{UserAgentSession, session::BeginNewClientApproval}, operator::{OperatorSession, session::BeginNewClientApproval},
}, },
}; };
@@ -11,16 +11,18 @@ use kameo::{
prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef}, prelude::{ActorId, ActorRef, ActorStopReason, Context, WeakActorRef},
reply::ReplySender, reply::ReplySender,
}; };
use std::ops::ControlFlow; use std::{ops::ControlFlow, time::Duration};
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(30);
pub struct Args { pub struct Args {
pub client: ClientProfile, pub client: ClientProfile,
pub user_agents: Vec<ActorRef<UserAgentSession>>, pub operators: Vec<ActorRef<OperatorSession>>,
pub reply: ReplySender<Result<bool, ApprovalError>>, pub reply: ReplySender<Result<bool, ApprovalError>>,
} }
pub struct ClientApprovalController { 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, pending: usize,
/// Number of approvals received so far. /// Number of approvals received so far.
approved: usize, approved: usize,
@@ -42,21 +44,21 @@ impl Actor for ClientApprovalController {
async fn on_start( async fn on_start(
Args { Args {
client, client,
user_agents, operators,
reply, reply,
}: Self::Args, }: Self::Args,
actor_ref: ActorRef<Self>, actor_ref: ActorRef<Self>,
) -> Result<Self, Self::Error> { ) -> Result<Self, Self::Error> {
let this = Self { let this = Self {
pending: user_agents.len(), pending: operators.len(),
approved: 0, approved: 0,
reply: Some(reply), reply: Some(reply),
}; };
for user_agent in user_agents { for operator in operators {
actor_ref.link(&user_agent).await; actor_ref.link(&operator).await;
let _ = user_agent let _ = operator
.tell(BeginNewClientApproval { .tell(BeginNewClientApproval {
client: client.clone(), client: client.clone(),
controller: actor_ref.clone(), controller: actor_ref.clone(),
@@ -64,6 +66,14 @@ impl Actor for ClientApprovalController {
.await; .await;
} }
let weak = actor_ref.downgrade();
tokio::spawn(async move {
tokio::time::sleep(APPROVAL_TIMEOUT).await;
if let Some(r) = weak.upgrade() {
let _ = r.tell(OnApprovalTimeout {}).await;
}
});
Ok(this) Ok(this)
} }
@@ -73,10 +83,10 @@ impl Actor for ClientApprovalController {
_: ActorId, _: ActorId,
_: ActorStopReason, _: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> { ) -> 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); self.pending = self.pending.saturating_sub(1);
if self.pending == 0 { 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)); self.send_reply(Ok(false));
return Ok(ControlFlow::Break(ActorStopReason::Normal)); return Ok(ControlFlow::Break(ActorStopReason::Normal));
} }
@@ -99,9 +109,19 @@ impl ClientApprovalController {
self.pending = self.pending.saturating_sub(1); self.pending = self.pending.saturating_sub(1);
if self.pending == 0 { if self.pending == 0 {
// Every connected UA approved. // Every connected operator approved.
self.send_reply(Ok(true)); self.send_reply(Ok(true));
ctx.stop(); ctx.stop();
} }
} }
/// Fired after `APPROVAL_TIMEOUT` elapses. Any operator that hasn't responded
/// by then is treated as a denial to prevent zombie sessions from blocking the flow.
#[message(ctx)]
pub fn on_approval_timeout(&mut self, ctx: &mut Context<Self, ()>) {
if self.pending > 0 {
self.send_reply(Ok(false));
ctx.stop();
}
}
} }

View File

@@ -1,7 +1,7 @@
use crate::{ use crate::{
actors::{ actors::{
flow_coordinator::client_connect_approval::ClientApprovalController, flow_coordinator::client_connect_approval::ClientApprovalController,
useragent_registry::{GetConnected, UserAgentRegistry}, operator_registry::{GetConnected, OperatorRegistry},
}, },
peers::client::{ClientProfile, session::ClientSession}, peers::client::{ClientProfile, session::ClientSession},
}; };
@@ -20,14 +20,17 @@ pub mod client_connect_approval;
pub struct FlowCoordinator { pub struct FlowCoordinator {
pub clients: HashMap<ActorId, ActorRef<ClientSession>>, pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
useragent_registry: ActorRef<UserAgentRegistry>, /// Maps DB `client_id` → `ActorId` for fast connected-client lookup.
client_ids: HashMap<i32, ActorId>,
operator_registry: ActorRef<OperatorRegistry>,
} }
impl FlowCoordinator { impl FlowCoordinator {
pub fn new(useragent_registry: ActorRef<UserAgentRegistry>) -> Self { pub fn new(operator_registry: ActorRef<OperatorRegistry>) -> Self {
Self { Self {
clients: HashMap::default(), clients: HashMap::default(),
useragent_registry, client_ids: HashMap::default(),
operator_registry,
} }
} }
} }
@@ -48,6 +51,7 @@ impl Actor for FlowCoordinator {
_: ActorStopReason, _: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> { ) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
if self.clients.remove(&id).is_some() { if self.clients.remove(&id).is_some() {
self.client_ids.retain(|_, actor_id| *actor_id != id);
info!( info!(
?id, ?id,
actor = "FlowCoordinator", actor = "FlowCoordinator",
@@ -66,8 +70,8 @@ impl Actor for FlowCoordinator {
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)] #[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)]
pub enum ApprovalError { pub enum ApprovalError {
#[error("No user agents connected")] #[error("No operators connected")]
NoUserAgentsConnected, NoOperatorsConnected,
} }
#[messages] #[messages]
@@ -75,14 +79,28 @@ impl FlowCoordinator {
#[message(ctx)] #[message(ctx)]
pub async fn register_client( pub async fn register_client(
&mut self, &mut self,
client_id: i32,
actor: ActorRef<ClientSession>, actor: ActorRef<ClientSession>,
ctx: &mut Context<Self, ()>, ctx: &mut Context<Self, ()>,
) { ) {
info!(id = %actor.id(), actor = "FlowCoordinator", event = "client.connected"); info!(id = %actor.id(), client_id, actor = "FlowCoordinator", event = "client.connected");
ctx.actor_ref().link(&actor).await; ctx.actor_ref().link(&actor).await;
self.client_ids.insert(client_id, actor.id());
self.clients.insert(actor.id(), actor); self.clients.insert(actor.id(), actor);
} }
#[message]
pub fn is_client_connected(&self, client_id: i32) -> bool {
self.client_ids.contains_key(&client_id)
}
/// Returns the DB `client_ids` of all currently connected SDK clients.
/// Used by operator sessions on startup to seed their approved-client set.
#[message]
pub fn get_connected_client_ids(&self) -> Vec<i32> {
self.client_ids.keys().copied().collect()
}
#[message(ctx)] #[message(ctx)]
pub async fn request_client_approval( pub async fn request_client_approval(
&mut self, &mut self,
@@ -93,19 +111,19 @@ impl FlowCoordinator {
unreachable!("Expected `request_client_approval` to have callback channel"); unreachable!("Expected `request_client_approval` to have callback channel");
}; };
let Ok(refs) = self.useragent_registry.ask(GetConnected).await else { let Ok(refs) = self.operator_registry.ask(GetConnected).await else {
reply_sender.send(Err(ApprovalError::NoUserAgentsConnected)); reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
return reply; return reply;
}; };
if refs.is_empty() { if refs.is_empty() {
reply_sender.send(Err(ApprovalError::NoUserAgentsConnected)); reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
return reply; return reply;
} }
ClientApprovalController::spawn(client_connect_approval::Args { ClientApprovalController::spawn(client_connect_approval::Args {
client, client,
user_agents: refs, operators: refs,
reply: reply_sender, reply: reply_sender,
}); });

View File

@@ -1,7 +1,7 @@
use crate::{ use crate::{
actors::{ actors::{
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator, bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
useragent_registry::UserAgentRegistry, vault::Vault, operator_registry::OperatorRegistry, vault::Vault, vault_coordinator::VaultCoordinator,
}, },
db, db,
}; };
@@ -13,25 +13,27 @@ use thiserror::Error;
pub mod bootstrap; pub mod bootstrap;
pub mod evm; pub mod evm;
pub mod flow_coordinator; pub mod flow_coordinator;
pub mod useragent_registry; pub mod operator_registry;
pub mod vault; pub mod vault;
pub mod vault_coordinator;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum SpawnError { pub enum SpawnError {
#[error("Failed to spawn Bootstrapper actor")] #[error("Failed to spawn Bootstrapper actor")]
Bootstrapper(#[from] bootstrap::Error), Bootstrapper(#[from] bootstrap::Error),
#[error("Failed to spawn Vault actor")] #[error("Failed to spawn Vault actor")]
Vault(#[from] vault::Error), Vault(#[from] vault::Error),
#[error("Failed to spawn VaultCoordinator actor")]
VaultCoordinator(#[from] vault_coordinator::Error),
} }
/// Long-lived actors that are shared across all connections and handle global state and operations
#[derive(Clone)] #[derive(Clone)]
pub struct GlobalActors { pub struct GlobalActors {
pub vault: ActorRef<Vault>, pub vault: ActorRef<Vault>,
pub vault_coordinator: ActorRef<VaultCoordinator>,
pub bootstrapper: ActorRef<Bootstrapper>, pub bootstrapper: ActorRef<Bootstrapper>,
pub flow_coordinator: ActorRef<FlowCoordinator>, pub flow_coordinator: ActorRef<FlowCoordinator>,
pub useragent_registry: ActorRef<UserAgentRegistry>, pub operator_registry: ActorRef<OperatorRegistry>,
pub evm: ActorRef<EvmActor>, pub evm: ActorRef<EvmActor>,
pub events: ActorRef<MessageBus>, pub events: ActorRef<MessageBus>,
} }
@@ -42,18 +44,22 @@ impl GlobalActors {
} }
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> { pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
let message_bus = Self::spawn_message_bus(); let events = Self::spawn_message_bus();
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?); let vault = Vault::spawn(Vault::new(db.clone(), events.clone()).await?);
let useragent_registry = UserAgentRegistry::spawn(UserAgentRegistry::default()); let bootstrapper = Bootstrapper::spawn(Bootstrapper::new(&db, events.clone()).await?);
let vault_coordinator =
VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault.clone()));
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
Ok(Self { Ok(Self {
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), bootstrapper,
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)), evm: EvmActor::spawn(EvmActor::new(vault.clone(), db.clone())),
vault: key_holder, vault,
vault_coordinator,
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new( flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
useragent_registry.clone(), operator_registry.clone(),
)), )),
useragent_registry, operator_registry,
events: message_bus, events,
}) })
} }
} }

View File

@@ -1,4 +1,4 @@
use crate::peers::user_agent::UserAgentSession; use crate::peers::operator::OperatorSession;
use kameo::{ use kameo::{
Actor, Actor,
@@ -11,17 +11,17 @@ use std::{collections::HashMap, ops::ControlFlow};
use tracing::info; use tracing::info;
#[derive(Default)] #[derive(Default)]
pub struct UserAgentRegistry { pub struct OperatorRegistry {
connected: HashMap<ActorId, ActorRef<UserAgentSession>>, connected: HashMap<ActorId, ActorRef<OperatorSession>>,
} }
impl Actor for UserAgentRegistry { impl Actor for OperatorRegistry {
type Args = Self; type Args = Self;
type Error = Infallible; type Error = Infallible;
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> { fn on_start(args: Self::Args, _: ActorRef<Self>) -> impl Future<Output = Result<Self, Self::Error>> {
Ok(args) std::future::ready(Ok(args))
} }
async fn on_link_died( async fn on_link_died(
@@ -33,8 +33,8 @@ impl Actor for UserAgentRegistry {
if self.connected.remove(&id).is_some() { if self.connected.remove(&id).is_some() {
info!( info!(
?id, ?id,
actor = "UserAgentRegistry", actor = "OperatorRegistry",
event = "useragent.disconnected" event = "operator.disconnected"
); );
} }
Ok(ControlFlow::Continue(())) Ok(ControlFlow::Continue(()))
@@ -42,20 +42,20 @@ impl Actor for UserAgentRegistry {
} }
#[messages] #[messages]
impl UserAgentRegistry { impl OperatorRegistry {
#[message(ctx)] #[message(ctx)]
pub async fn connect_useragent( pub async fn connect_operator(
&mut self, &mut self,
actor: ActorRef<UserAgentSession>, actor: ActorRef<OperatorSession>,
ctx: &mut Context<Self, ()>, 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; ctx.actor_ref().link(&actor).await;
self.connected.insert(actor.id(), actor); self.connected.insert(actor.id(), actor);
} }
#[message] #[message]
pub fn get_connected(&self) -> Vec<ActorRef<UserAgentSession>> { pub fn get_connected(&self) -> Vec<ActorRef<OperatorSession>> {
self.connected.values().cloned().collect() self.connected.values().cloned().collect()
} }
} }

View File

@@ -1,15 +1,17 @@
use crate::{ use crate::{
crypto::{ crypto::{
KeyCell, derive_key, KeyCell,
encryption::v1::{self, Nonce}, encryption::v1::{self, Nonce},
integrity::v1::HmacSha256, integrity::v1::HmacSha256,
}, },
db::{ db::{
self, self,
models::{self, RootKeyHistory}, custody::{self, CustodyRecord},
models::{self, RootKeyHistory, RootKeyHistoryId},
schema::{self}, schema::{self},
}, },
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use chrono::Utc; use chrono::Utc;
@@ -22,10 +24,9 @@ use hmac::{KeyInit as _, Mac as _};
use kameo::{Actor, Reply, actor::ActorRef, messages}; use kameo::{Actor, Reply, actor::ActorRef, messages};
use kameo_actors::message_bus::{MessageBus, Publish}; use kameo_actors::message_bus::{MessageBus, Publish};
use strum::{EnumDiscriminants, IntoDiscriminant}; use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info}; use tracing::{error, info, warn};
pub mod events { pub mod events {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct Bootstrapped; pub struct Bootstrapped;
@@ -46,6 +47,8 @@ pub enum Error {
Sealed, Sealed,
#[error("Invalid key provided")] #[error("Invalid key provided")]
InvalidKey, InvalidKey,
#[error("Vault locked: too many failed unseal attempts")]
LockedOut,
#[error("Requested aead entry not found")] #[error("Requested aead entry not found")]
NotFound, NotFound,
@@ -59,12 +62,18 @@ pub enum Error {
#[error("Database transaction error: {0}")] #[error("Database transaction error: {0}")]
DatabaseTransaction(#[from] diesel::result::Error), DatabaseTransaction(#[from] diesel::result::Error),
#[error("Custody storage error: {0}")]
Custody(#[from] custody::Error),
#[error("Broken database")] #[error("Broken database")]
BrokenDatabase, BrokenDatabase,
#[error("Integrity key version mismatch: envelope uses key {envelope:?}, current key is {current:?}")]
KeyVersionMismatch { envelope: RootKeyHistoryId, current: RootKeyHistoryId },
} }
struct Unsealed { struct Unsealed {
root_key_history_id: i32, root_key_history_id: RootKeyHistoryId,
root_key: KeyCell, root_key: KeyCell,
} }
@@ -73,12 +82,15 @@ struct Unsealed {
enum State { enum State {
#[default] #[default]
Unbootstrapped, Unbootstrapped,
Sealed { Sealed {
root_key_history_id: i32, root_key_history_id: RootKeyHistoryId,
}, },
Unsealed(Unsealed), Unsealed(Unsealed),
} }
const MAX_UNSEAL_ATTEMPTS: u32 = 5;
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed). /// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
/// ///
/// Provides API for encrypting and decrypting data using the vault root key. /// Provides API for encrypting and decrypting data using the vault root key.
@@ -88,6 +100,7 @@ pub struct Vault {
db: db::DatabasePool, db: db::DatabasePool,
state: State, state: State,
events: ActorRef<MessageBus>, events: ActorRef<MessageBus>,
unseal_failures: u32,
} }
#[messages] #[messages]
@@ -110,26 +123,33 @@ impl Vault {
} }
}; };
Ok(Self { db, state, events }) Ok(Self {
db,
state,
events,
unseal_failures: 0,
})
} }
// Exclusive transaction to avoid race condtions if multiple vaults write // Exclusive transaction to avoid race condtions if multiple vaults write
// additional layer of protection against nonce-reuse // additional layer of protection against nonce-reuse
async fn get_new_nonce(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 mut conn = pool.get().await?;
let nonce = conn let nonce = conn
.exclusive_transaction(|conn| { .exclusive_transaction(async |conn| {
Box::pin(async move {
let current_nonce: Vec<u8> = schema::root_key_history::table let current_nonce: Vec<u8> = schema::root_key_history::table
.filter(schema::root_key_history::id.eq(root_key_id)) .filter(schema::root_key_history::id.eq(root_key_id))
.select(schema::root_key_history::data_encryption_nonce) .select(schema::root_key_history::data_encryption_nonce)
.first(conn) .first(&mut *conn)
.await?; .await?;
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| { let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
error!( error!(
"Broken database: invalid nonce for root key history id={}", "Broken database: invalid nonce for root key history id={:#?}",
root_key_id root_key_id
); );
Error::BrokenDatabase Error::BrokenDatabase
@@ -139,12 +159,11 @@ impl Vault {
update(schema::root_key_history::table) update(schema::root_key_history::table)
.filter(schema::root_key_history::id.eq(root_key_id)) .filter(schema::root_key_history::id.eq(root_key_id))
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec())) .set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
.execute(conn) .execute(&mut *conn)
.await?; .await?;
Result::<_, Error>::Ok(nonce) Result::<_, Error>::Ok(nonce)
}) })
})
.await?; .await?;
Ok(nonce) Ok(nonce)
@@ -158,13 +177,16 @@ impl Vault {
} }
} }
/// Create the root key and take the vault into the unsealed state.
#[message] #[message]
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> { pub async fn bootstrap(
&mut self,
mut seal_key: KeyCell,
custody: Option<CustodyRecord>,
) -> Result<(), Error> {
if !matches!(self.state, State::Unbootstrapped) { if !matches!(self.state, State::Unbootstrapped) {
return Err(Error::AlreadyBootstrapped); return Err(Error::AlreadyBootstrapped);
} }
let salt = v1::generate_salt();
let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = KeyCell::new_secure_random(); let mut root_key = KeyCell::new_secure_random();
// Zero nonces are fine because they are one-time // Zero nonces are fine because they are one-time
@@ -185,28 +207,30 @@ impl Vault {
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec(); let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let root_key_history_id = conn let root_key_history_id = conn
.transaction(|conn| { .transaction(async |conn| {
Box::pin(async move { let root_key_history_id = insert_into(schema::root_key_history::table)
let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory { .values(&models::NewRootKeyHistory {
ciphertext: root_key_ciphertext, ciphertext: root_key_ciphertext.clone(),
tag: v1::ROOT_KEY_TAG.to_vec(), tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: root_key_nonce.to_vec(), root_key_encryption_nonce: root_key_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce_bytes, data_encryption_nonce: data_encryption_nonce_bytes.clone(),
schema_version: 1, schema_version: 1,
salt: salt.to_vec(), salt: v1::generate_salt().to_vec(),
}) })
.returning(schema::root_key_history::id) .returning(schema::root_key_history::id)
.get_result(conn) .get_result(&mut *conn)
.await?; .await?;
update(schema::arbiter_settings::table) update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id)) .set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
.execute(conn) .execute(&mut *conn)
.await?; .await?;
Result::<_, diesel::result::Error>::Ok(root_key_history_id) if let Some(record) = custody.as_ref() {
}) custody::write_record(&mut *conn, record).await?;
}
Result::<_, Error>::Ok(RootKeyHistoryId::from_raw(root_key_history_id))
}) })
.await?; .await?;
@@ -222,7 +246,11 @@ impl Vault {
} }
#[message] #[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> {
if self.unseal_failures >= MAX_UNSEAL_ATTEMPTS {
return Err(Error::LockedOut);
}
let State::Sealed { let State::Sealed {
root_key_history_id, root_key_history_id,
} = &self.state } = &self.state
@@ -240,13 +268,6 @@ impl Vault {
.await? .await?
}; };
let salt = &current_key.salt;
let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| {
error!("Broken database: invalid salt for root key");
Error::BrokenDatabase
})?;
let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = SafeCell::new(current_key.ciphertext.clone()); let mut root_key = SafeCell::new(current_key.ciphertext.clone());
let nonce = let nonce =
@@ -255,13 +276,27 @@ impl Vault {
Error::BrokenDatabase Error::BrokenDatabase
})?; })?;
seal_key if seal_key
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key) .decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
.map_err(|err| { .is_err()
error!(?err, "Failed to unseal root key: invalid seal key"); {
Error::InvalidKey self.unseal_failures += 1;
})?; if self.unseal_failures >= MAX_UNSEAL_ATTEMPTS {
error!(
attempts = self.unseal_failures,
"Vault locked: maximum failed unseal attempts reached"
);
} else {
warn!(
attempts = self.unseal_failures,
remaining = MAX_UNSEAL_ATTEMPTS - self.unseal_failures,
"Failed unseal attempt"
);
}
return Err(Error::InvalidKey);
}
self.unseal_failures = 0;
self.state = State::Unsealed(Unsealed { self.state = State::Unsealed(Unsealed {
root_key_history_id: current_key.id, root_key_history_id: current_key.id,
root_key: KeyCell::try_from(root_key).map_err(|err| { root_key: KeyCell::try_from(root_key).map_err(|err| {
@@ -276,8 +311,10 @@ impl Vault {
Ok(()) Ok(())
} }
/// Decrypts an AEAD entry. The `aad` must match the value used at encryption time;
/// a mismatch causes authentication failure, preventing cross-wallet key swaps.
#[message] #[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> { pub async fn decrypt(&mut self, aead_id: i32, aad: Vec<u8>) -> Result<SafeCell<Vec<u8>>, Error> {
let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?; let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?;
let row: models::AeadEncrypted = { let row: models::AeadEncrypted = {
@@ -299,13 +336,15 @@ impl Vault {
Error::BrokenDatabase Error::BrokenDatabase
})?; })?;
let mut output = SafeCell::new(row.ciphertext); let mut output = SafeCell::new(row.ciphertext);
root_key.decrypt_in_place(&nonce, v1::TAG, &mut output)?; root_key.decrypt_in_place(&nonce, &aad, &mut output)?;
Ok(output) Ok(output)
} }
/// Creates a new `aead_encrypted` entry and returns its ID.
/// The `aad` is bound into the ciphertext and must be reproduced exactly at decryption time.
// Creates new `aead_encrypted` entry in the database and returns it's ID // Creates new `aead_encrypted` entry in the database and returns it's ID
#[message] #[message]
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> { pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>, aad: Vec<u8>) -> Result<i32, Error> {
let Unsealed { let Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
@@ -317,7 +356,7 @@ impl Vault {
let mut ciphertext_buffer = plaintext.write(); let mut ciphertext_buffer = plaintext.write();
let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut(); let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut();
root_key.encrypt_in_place(&nonce, v1::TAG, &mut *ciphertext_buffer)?; root_key.encrypt_in_place(&nonce, &aad, &mut *ciphertext_buffer)?;
let ciphertext = std::mem::take(ciphertext_buffer); let ciphertext = std::mem::take(ciphertext_buffer);
@@ -344,19 +383,20 @@ impl Vault {
} }
#[message] #[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 { let Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?; } = Self::expect_unsealed(&mut self.state)?;
let mut hmac = root_key let mut hmac = root_key.0.read_inline(|k| {
.0 HmacSha256::new_from_slice(k)
.read_inline(|k| match HmacSha256::new_from_slice(k) { .unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
}); });
hmac.update(&root_key_history_id.to_be_bytes()); hmac.update(&root_key_history_id.to_raw().to_be_bytes());
hmac.update(&mac_input); hmac.update(&mac_input);
let mac = hmac.finalize().into_bytes().to_vec(); let mac = hmac.finalize().into_bytes().to_vec();
@@ -368,7 +408,7 @@ impl Vault {
&mut self, &mut self,
mac_input: Vec<u8>, mac_input: Vec<u8>,
expected_mac: Vec<u8>, expected_mac: Vec<u8>,
key_version: i32, key_version: RootKeyHistoryId,
) -> Result<bool, Error> { ) -> Result<bool, Error> {
let Unsealed { let Unsealed {
root_key, root_key,
@@ -376,16 +416,17 @@ impl Vault {
} = Self::expect_unsealed(&mut self.state)?; } = Self::expect_unsealed(&mut self.state)?;
if *root_key_history_id != key_version { if *root_key_history_id != key_version {
return Ok(false); return Err(Error::KeyVersionMismatch {
envelope: key_version,
current: *root_key_history_id,
});
} }
let mut hmac = root_key let mut hmac = root_key.0.read_inline(|k| {
.0 HmacSha256::new_from_slice(k)
.read_inline(|k| match HmacSha256::new_from_slice(k) { .unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
}); });
hmac.update(&key_version.to_be_bytes()); hmac.update(&key_version.to_raw().to_be_bytes());
hmac.update(&mac_input); hmac.update(&mac_input);
Ok(hmac.verify_slice(&expected_mac).is_ok()) Ok(hmac.verify_slice(&expected_mac).is_ok())
@@ -409,7 +450,6 @@ impl Vault {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::actors::GlobalActors; use crate::actors::GlobalActors;
use arbiter_crypto::safecell::SafeCellHandle as _;
use super::*; use super::*;
@@ -417,8 +457,8 @@ mod tests {
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await .await
.unwrap(); .unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec()); let seal_key = KeyCell::from([0u8; 32]);
actor.bootstrap(seal_key).await.unwrap(); actor.bootstrap(seal_key, None).await.unwrap();
actor actor
} }
@@ -427,12 +467,13 @@ mod tests {
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() { async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await; let mut actor = bootstrapped_actor(&db).await;
let root_key_history_id = match actor.state {
State::Unsealed(Unsealed { let State::Unsealed(Unsealed {
root_key_history_id, root_key_history_id,
.. ..
}) => root_key_history_id, }) = actor.state
_ => panic!("expected unsealed state"), else {
panic!("expected unsealed state")
}; };
let n1 = Vault::get_new_nonce(&db, root_key_history_id) let n1 = Vault::get_new_nonce(&db, root_key_history_id)
@@ -444,15 +485,15 @@ mod tests {
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase"); assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let root_row: models::RootKeyHistory = schema::root_key_history::table let root_row: RootKeyHistory = schema::root_key_history::table
.select(models::RootKeyHistory::as_select()) .select(RootKeyHistory::as_select())
.first(&mut conn) .first(&mut conn)
.await .await
.unwrap(); .unwrap();
assert_eq!(root_row.data_encryption_nonce, n2.to_vec()); assert_eq!(root_row.data_encryption_nonce, n2.to_vec());
let id = actor let id = actor
.create_new(SafeCell::new(b"post-interleave".to_vec())) .create_new(SafeCell::new(b"post-interleave".to_vec()), b"test-aad".to_vec())
.await .await
.unwrap(); .unwrap();
let row: models::AeadEncrypted = schema::aead_encrypted::table let row: models::AeadEncrypted = schema::aead_encrypted::table

View File

@@ -0,0 +1,378 @@
//! Coordinates the multi-operator ceremonies that create and open the vault.
//!
//! The coordinator collects one passphrase per committee member, then hands the
//! assembled material to [`Vault`] in a single message. It owns no Diesel code:
//! everything it reads or writes goes through [`db::custody`].
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use argon2::RECOMMENDED_SALT_LEN;
use kameo::{Actor, actor::ActorRef, error::SendError, messages};
use rand::rngs::SysRng;
use rand_core::{Rng as _, UnwrapErr};
use crate::{
actors::vault::{self, Bootstrap, TryUnseal, Vault},
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir},
db::{
self,
custody::{self, CustodyRecord, EncryptedShare},
models::OperatorId,
},
};
const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("An ordinary committee is already being coordinated")]
AlreadyBootstrapping,
#[error("An unseal is already being coordinated")]
AlreadyUnsealing,
#[error("Bootstrap is not in progress")]
NotBootstrapping,
#[error("The operator already contributed")]
DuplicateContribution,
#[error("The ordinary committee cannot be empty")]
EmptyCommittee,
#[error("Two-operator committees are unsupported")]
UnsupportedCommittee,
#[error(
"The ordinary committee cannot exceed {} members",
shamir::MAX_COMMITTEE_SIZE
)]
CommitteeTooLarge,
#[error("Invalid passphrase")]
InvalidPassphrase,
#[error("Broken database")]
BrokenDatabase,
#[error("Shamir error: {0}")]
Shamir(String),
#[error("Database connection error: {0}")]
DatabaseConnection(#[from] db::PoolError),
#[error("Custody storage error: {0}")]
Custody(#[from] custody::Error),
#[error("Encryption error")]
Encryption,
#[error("The vault is already bootstrapped")]
AlreadyBootstrapped,
#[error("Vault error")]
Vault,
}
/// Passphrases gathered so far, in contribution order.
///
/// A `Vec` rather than a map because [`SafeCell`] values are neither cloneable
/// nor hashable, and a committee holds at most
/// [`shamir::MAX_COMMITTEE_SIZE`] of them.
#[derive(Default)]
struct Contributions(Vec<(OperatorId, SafeCell<Vec<u8>>)>);
impl Contributions {
fn contains(&self, operator_id: OperatorId) -> bool {
self.0.iter().any(|(id, _)| *id == operator_id)
}
fn put(&mut self, operator_id: OperatorId, passphrase: SafeCell<Vec<u8>>) {
match self.0.iter_mut().find(|(id, _)| *id == operator_id) {
Some(slot) => slot.1 = passphrase,
None => self.0.push((operator_id, passphrase)),
}
}
const fn len(&self) -> usize {
self.0.len()
}
fn operators(&self) -> Vec<OperatorId> {
self.0.iter().map(|(id, _)| *id).collect()
}
}
enum CoordinatorState {
Idle,
Bootstrapping {
/// The operator that declared the committee. Only they may re-declare
/// it, which is the way out of a ceremony the others never finish.
declarer: OperatorId,
declared_count: usize,
contributions: Contributions,
retryable: bool,
},
Unsealing {
threshold: usize,
contributions: Contributions,
retryable: bool,
},
}
#[derive(Actor)]
pub struct VaultCoordinator {
db: db::DatabasePool,
vault: ActorRef<Vault>,
state: CoordinatorState,
}
impl VaultCoordinator {
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
Self {
db,
vault,
state: CoordinatorState::Idle,
}
}
}
/// Explain why a committee size was rejected.
const fn committee_error(declared_count: usize) -> Error {
match declared_count {
0 => Error::EmptyCommittee,
2 => Error::UnsupportedCommittee,
_ => Error::CommitteeTooLarge,
}
}
fn encrypt_share(
passphrase: &mut SafeCell<Vec<u8>>,
share: &[u8],
) -> Result<EncryptedShare, Error> {
let mut salt = [0u8; RECOMMENDED_SALT_LEN];
UnwrapErr(SysRng).fill_bytes(&mut salt);
let nonce = Nonce::default();
let ciphertext = derive_key(passphrase, &salt)
.encrypt(&nonce, SHARE_AAD, share)
.map_err(|_| Error::Encryption)?;
Ok(EncryptedShare {
ciphertext,
nonce: nonce.to_vec(),
salt: salt.to_vec(),
})
}
fn decrypt_share(
passphrase: &mut SafeCell<Vec<u8>>,
share: EncryptedShare,
) -> Result<SafeCell<Vec<u8>>, Error> {
let nonce = Nonce::try_from(share.nonce.as_slice()).map_err(|()| Error::BrokenDatabase)?;
let mut buffer = SafeCell::new(share.ciphertext);
derive_key(passphrase, &share.salt)
.decrypt_in_place(&nonce, SHARE_AAD, &mut buffer)
.map_err(|_| Error::InvalidPassphrase)?;
Ok(buffer)
}
const fn bootstrap_error(error: &SendError<Bootstrap, vault::Error>) -> Error {
match error {
SendError::HandlerError(vault::Error::AlreadyBootstrapped) => Error::AlreadyBootstrapped,
_ => Error::Vault,
}
}
/// Build the custody record and hand it to the vault, which stores it in the
/// same transaction as the root key.
async fn finalize_bootstrap(
vault: &ActorRef<Vault>,
contributions: &mut Contributions,
) -> Result<(), Error> {
let total = contributions.len();
let threshold = shamir::shamir_threshold(total).ok_or_else(|| committee_error(total))?;
let mut seal_key = KeyCell::new_secure_random();
let mut shares = shamir::split_key(threshold, total, &mut seal_key, UnwrapErr(SysRng))
.map_err(|error| Error::Shamir(error.to_string()))?;
if shares.len() < total {
return Err(Error::Shamir("missing share for operator".to_owned()));
}
let mut encrypted = Vec::with_capacity(total);
for ((operator_id, passphrase), share) in contributions.0.iter_mut().zip(shares.iter_mut()) {
let share = share.read_inline(|share| encrypt_share(passphrase, share))?;
encrypted.push((*operator_id, share));
}
vault
.ask(Bootstrap {
seal_key,
custody: Some(CustodyRecord {
threshold,
shares: encrypted,
}),
})
.await
.map_err(|error| bootstrap_error(&error))
}
/// Reconstruct the seal key from the contributed passphrases and unseal.
async fn finalize_unseal(
db: &db::DatabasePool,
vault: &ActorRef<Vault>,
threshold: usize,
contributions: &mut Contributions,
) -> Result<(), Error> {
let stored = {
let mut conn = db.get().await?;
custody::shares(&mut conn, &contributions.operators()).await?
};
let mut plaintext = Vec::with_capacity(stored.len());
for ((_, passphrase), share) in contributions.0.iter_mut().zip(stored) {
plaintext.push(decrypt_share(passphrase, share)?);
}
let seal_key = shamir::combine_shares(threshold, &mut plaintext)
.map_err(|error| Error::Shamir(error.to_string()))?;
vault
.ask(TryUnseal { seal_key })
.await
.map_err(|_| Error::Vault)
}
#[messages]
impl VaultCoordinator {
/// Announce how many operators will contribute to the bootstrap.
///
/// The declaring operator may re-declare to restart the ceremony; that is
/// the only way to release a committee whose members never all show up.
#[message]
pub fn start_bootstrap(
&mut self,
operator_id: OperatorId,
declared_count: usize,
) -> Result<(), Error> {
if shamir::shamir_threshold(declared_count).is_none() {
return Err(committee_error(declared_count));
}
match &self.state {
CoordinatorState::Unsealing { .. } => return Err(Error::AlreadyUnsealing),
CoordinatorState::Bootstrapping { declarer, .. } if *declarer != operator_id => {
return Err(Error::AlreadyBootstrapping);
}
CoordinatorState::Bootstrapping { .. } | CoordinatorState::Idle => {}
}
self.state = CoordinatorState::Bootstrapping {
declarer: operator_id,
declared_count,
contributions: Contributions::default(),
retryable: false,
};
Ok(())
}
#[message]
pub async fn contribute_bootstrap(
&mut self,
operator_id: OperatorId,
passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
let CoordinatorState::Bootstrapping {
declared_count,
contributions,
retryable,
..
} = &mut self.state
else {
return Err(Error::NotBootstrapping);
};
if contributions.contains(operator_id) && !*retryable {
return Err(Error::DuplicateContribution);
}
contributions.put(operator_id, passphrase);
*retryable = false;
if contributions.len() < *declared_count {
return Ok(false);
}
let state = std::mem::replace(&mut self.state, CoordinatorState::Idle);
let CoordinatorState::Bootstrapping {
declarer,
declared_count,
mut contributions,
..
} = state
else {
unreachable!("state was matched as Bootstrapping above")
};
match finalize_bootstrap(&self.vault, &mut contributions).await {
Ok(()) => Ok(true),
Err(error) => {
self.state = CoordinatorState::Bootstrapping {
declarer,
declared_count,
contributions,
retryable: true,
};
Err(error)
}
}
}
#[message]
pub async fn contribute_unseal(
&mut self,
operator_id: OperatorId,
passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
if matches!(self.state, CoordinatorState::Idle) {
let threshold = {
let mut conn = self.db.get().await?;
custody::threshold(&mut conn).await?
};
self.state = CoordinatorState::Unsealing {
threshold,
contributions: Contributions::default(),
retryable: false,
};
}
let CoordinatorState::Unsealing {
threshold,
contributions,
retryable,
} = &mut self.state
else {
return Err(Error::AlreadyBootstrapping);
};
if contributions.contains(operator_id) && !*retryable {
return Err(Error::DuplicateContribution);
}
contributions.put(operator_id, passphrase);
*retryable = false;
if contributions.len() < *threshold {
return Ok(false);
}
let state = std::mem::replace(&mut self.state, CoordinatorState::Idle);
let CoordinatorState::Unsealing {
threshold,
mut contributions,
..
} = state
else {
unreachable!("state was matched as Unsealing above")
};
match finalize_unseal(&self.db, &self.vault, threshold, &mut contributions).await {
Ok(()) => Ok(true),
Err(error) => {
self.state = CoordinatorState::Unsealing {
threshold,
contributions,
retryable: true,
};
Err(error)
}
}
}
}

View File

@@ -174,8 +174,7 @@ impl TlsManager {
{ {
let mut conn = db.get().await?; let mut conn = db.get().await?;
conn.transaction(|conn| { conn.transaction(async |conn| {
Box::pin(async {
let new_tls_history = NewTlsHistory { let new_tls_history = NewTlsHistory {
cert: new_cert.cert.pem(), cert: new_cert.cert.pem(),
cert_key: new_cert.cert_key.serialize_pem(), cert_key: new_cert.cert_key.serialize_pem(),
@@ -186,17 +185,16 @@ impl TlsManager {
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table) let inserted_tls_history: i32 = diesel::insert_into(tls_history::table)
.values(&new_tls_history) .values(&new_tls_history)
.returning(tls_history::id) .returning(tls_history::id)
.get_result(conn) .get_result(&mut *conn)
.await?; .await?;
diesel::update(arbiter_settings::table) diesel::update(arbiter_settings::table)
.set(arbiter_settings::tls_id.eq(inserted_tls_history)) .set(arbiter_settings::tls_id.eq(inserted_tls_history))
.execute(conn) .execute(&mut *conn)
.await?; .await?;
Result::<_, diesel::result::Error>::Ok(()) Result::<_, diesel::result::Error>::Ok(())
}) })
})
.await?; .await?;
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,221 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use rand_core::CryptoRng;
use vsss_rs::Gf256;
use crate::crypto::KeyCell;
/// GF(256) addresses shares by a non-zero byte, so no committee can exceed 255.
pub const MAX_COMMITTEE_SIZE: usize = 255;
/// Errors returned by Shamir split/combine operations.
#[derive(Debug, thiserror::Error)]
pub enum ShamirError {
#[error("failed to split key: {0}")]
Split(String),
#[error("failed to combine shares: {0}")]
Combine(String),
}
/// Return the required threshold for a Shamir share pool of `committee_size`.
///
/// A pool of two is rejected: a majority of two is two, which gives each holder
/// a veto over every unseal without giving either one recovery. That rejects no
/// supported committee, because a two-operator vault must carry at least one
/// recovery share and so never splits into a pool of two -- see
/// `docs/ARCHITECTURE.md` 3.9.
#[expect(
clippy::integer_division,
reason = "majority thresholds use integer arithmetic"
)]
#[must_use]
pub const fn shamir_threshold(committee_size: usize) -> Option<usize> {
match committee_size {
0 | 2 => None,
size if size > MAX_COMMITTEE_SIZE => None,
1 => Some(1),
size => Some(size / 2 + 1),
}
}
/// Split a seal key into `total` shares, `threshold` of which reconstruct it.
pub fn split_key(
threshold: usize,
total: usize,
key: &mut KeyCell,
rng: impl CryptoRng,
) -> Result<Vec<SafeCell<Vec<u8>>>, ShamirError> {
if total == 0 || threshold == 0 || threshold > total || total == 2 || total > MAX_COMMITTEE_SIZE
{
return Err(ShamirError::Split(
"unsupported committee parameters".to_owned(),
));
}
// Nothing to interpolate when one share suffices.
if threshold == 1 {
return Ok(key.0.read_inline(|key| {
std::iter::repeat_with(|| SafeCell::new(key.as_slice().to_vec()))
.take(total)
.collect()
}));
}
key.0.read_inline(|key| {
let key: &[u8; 32] = key
.as_slice()
.try_into()
.map_err(|_| ShamirError::Split("unexpected seal key length".to_owned()))?;
Gf256::split_array(threshold, total, key, rng)
.map(|shares| shares.into_iter().map(SafeCell::new).collect())
.map_err(|error| ShamirError::Split(format!("{error:?}")))
})
}
/// Combine shares back into the seal key.
///
/// `threshold` comes from storage rather than from the shapes of the shares:
/// a one-of-one committee stores the key verbatim, and telling that apart by
/// share length alone would misread any Shamir share that happened to be key
/// sized.
pub fn combine_shares(
threshold: usize,
shares: &mut [SafeCell<Vec<u8>>],
) -> Result<KeyCell, ShamirError> {
if threshold == 0 {
return Err(ShamirError::Combine("threshold is zero".to_owned()));
}
if shares.len() < threshold {
return Err(ShamirError::Combine(
"not enough shares supplied".to_owned(),
));
}
// Mirror of the one-of-one case in [`split_key`]: the share is the key.
if threshold == 1 {
let share = shares
.first_mut()
.ok_or_else(|| ShamirError::Combine("no shares supplied".to_owned()))?;
return reconstructed_key(share.read_inline(|share| SafeCell::new(share.clone())));
}
let mut gathered = SafeCell::new(Vec::with_capacity(shares.len()));
for share in shares.iter_mut() {
share.read_inline(|share| {
gathered.write_inline(|gathered| gathered.push(share.clone()));
});
}
let combined = gathered.read_inline(|gathered| {
Gf256::combine_array(gathered.as_slice())
.map(SafeCell::new)
.map_err(|error| ShamirError::Combine(format!("{error:?}")))
})?;
reconstructed_key(combined)
}
fn reconstructed_key(bytes: SafeCell<Vec<u8>>) -> Result<KeyCell, ShamirError> {
KeyCell::try_from(bytes)
.map_err(|()| ShamirError::Combine("unexpected reconstructed key length".to_owned()))
}
#[cfg(test)]
mod tests {
use super::{MAX_COMMITTEE_SIZE, combine_shares, shamir_threshold, split_key};
use crate::crypto::KeyCell;
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use rand::rngs::SysRng;
use rand_core::UnwrapErr;
use rstest::rstest;
fn key_bytes(mut key: KeyCell) -> [u8; 32] {
key.0.read_inline(|key| {
let mut bytes = [0u8; 32];
bytes.copy_from_slice(key.as_slice());
bytes
})
}
fn select(shares: &mut [SafeCell<Vec<u8>>], indexes: &[usize]) -> Vec<SafeCell<Vec<u8>>> {
indexes
.iter()
.filter_map(|index| {
shares
.get_mut(*index)
.map(|share| share.read_inline(|share| SafeCell::new(share.clone())))
})
.collect()
}
#[rstest]
#[case(&[0, 1])]
#[case(&[0, 2])]
#[case(&[1, 2])]
fn threshold_shares_reconstruct_fixed_key(#[case] indexes: &[usize]) {
let expected = [9_u8; 32];
let mut key = KeyCell::from(expected);
let rng = UnwrapErr(SysRng);
let mut shares = split_key(2, 3, &mut key, rng).expect("split should succeed");
let mut selected = select(&mut shares, indexes);
let combined = combine_shares(2, &mut selected).expect("combine should succeed");
assert_eq!(key_bytes(combined), expected);
}
#[test]
fn one_of_one_round_trips_a_fixed_size_key() {
let expected = [7_u8; 32];
let mut key = KeyCell::from(expected);
let rng = UnwrapErr(SysRng);
let mut shares = split_key(1, 1, &mut key, rng).expect("split should succeed");
let combined = combine_shares(1, &mut shares).expect("combine should succeed");
assert_eq!(key_bytes(combined), expected);
}
#[test]
fn fewer_shares_than_threshold_is_rejected() {
let mut key = KeyCell::from([3_u8; 32]);
let rng = UnwrapErr(SysRng);
let mut shares = split_key(3, 5, &mut key, rng).expect("split should succeed");
let mut selected = select(&mut shares, &[0, 1]);
assert!(
combine_shares(3, &mut selected).is_err(),
"two of three shares must not reconstruct the key"
);
}
#[rstest]
#[case(0, None)]
#[case(1, Some(1))]
#[case(2, None)]
#[case(3, Some(2))]
#[case(4, Some(3))]
#[case(MAX_COMMITTEE_SIZE, Some(128))]
#[case(MAX_COMMITTEE_SIZE + 1, None)]
fn committee_threshold_is_a_majority(
#[case] committee_size: usize,
#[case] expected: Option<usize>,
) {
assert_eq!(shamir_threshold(committee_size), expected);
}
#[test]
fn oversized_committee_is_rejected_by_split() {
let mut key = KeyCell::from([1_u8; 32]);
let rng = UnwrapErr(SysRng);
assert!(
split_key(129, MAX_COMMITTEE_SIZE + 1, &mut key, rng).is_err(),
"committees above the GF(256) share limit must be rejected"
);
}
#[test]
fn two_operator_committee_is_explicitly_unsupported() {
let mut key = KeyCell::from([7_u8; 32]);
let rng = UnwrapErr(SysRng);
assert!(
split_key(2, 2, &mut key, rng).is_err(),
"two-operator committees must be rejected"
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -176,8 +176,8 @@ impl Convert for auth::Error {
InvalidChallengeSolution => ProtoAuthResult::InvalidSignature, InvalidChallengeSolution => ProtoAuthResult::InvalidSignature,
ApproveError(auth::ApproveError::Denied) => ProtoAuthResult::ApprovalDenied, ApproveError(auth::ApproveError::Denied) => ProtoAuthResult::ApprovalDenied,
ApproveError(auth::ApproveError::Upstream( ApproveError(auth::ApproveError::Upstream(
crate::actors::flow_coordinator::ApprovalError::NoUserAgentsConnected, crate::actors::flow_coordinator::ApprovalError::NoOperatorsConnected,
)) => ProtoAuthResult::NoUserAgentsOnline, )) => ProtoAuthResult::NoOperatorsOnline,
ApproveError(auth::ApproveError::Internal) ApproveError(auth::ApproveError::Internal)
| DatabasePoolUnavailable | DatabasePoolUnavailable
| DatabaseOperationFailed | DatabaseOperationFailed
@@ -200,7 +200,7 @@ impl Convert for auth::Outbound {
.timestamp .timestamp
.timestamp_nanos_opt() .timestamp_nanos_opt()
.expect("timestamp within range") .expect("timestamp within range")
as u64, .cast_unsigned(),
random: challenge.nonce.to_vec(), random: challenge.nonce.to_vec(),
}) })
} }

View File

@@ -1,8 +1,8 @@
use crate::peers::{client::ClientConnection, user_agent::UserAgentConnection}; use crate::peers::{client::ClientConnection, operator::OperatorConnection};
use arbiter_proto::{ use arbiter_proto::{
proto::{ proto::{
client::{ClientRequest, ClientResponse}, client::{ClientRequest, ClientResponse},
user_agent::{UserAgentRequest, UserAgentResponse}, operator::{OperatorRequest, OperatorResponse},
}, },
transport::grpc::GrpcBi, transport::grpc::GrpcBi,
}; };
@@ -14,7 +14,7 @@ use tracing::info;
mod request_tracker; mod request_tracker;
pub mod client; pub mod client;
pub mod user_agent; pub mod operator;
mod common; mod common;
@@ -33,7 +33,7 @@ pub trait TryConvert {
#[async_trait] #[async_trait]
impl arbiter_proto::proto::arbiter_service_server::ArbiterService for super::Server { impl arbiter_proto::proto::arbiter_service_server::ArbiterService for super::Server {
type UserAgentStream = ReceiverStream<Result<UserAgentResponse, Status>>; type OperatorStream = ReceiverStream<Result<OperatorResponse, Status>>;
type ClientStream = ReceiverStream<Result<ClientResponse, Status>>; type ClientStream = ReceiverStream<Result<ClientResponse, Status>>;
#[tracing::instrument(level = "debug", skip(self))] #[tracing::instrument(level = "debug", skip(self))]
@@ -52,23 +52,23 @@ impl arbiter_proto::proto::arbiter_service_server::ArbiterService for super::Ser
} }
#[tracing::instrument(level = "debug", skip(self))] #[tracing::instrument(level = "debug", skip(self))]
async fn user_agent( async fn operator(
&self, &self,
request: Request<tonic::Streaming<UserAgentRequest>>, request: Request<tonic::Streaming<OperatorRequest>>,
) -> Result<Response<Self::UserAgentStream>, Status> { ) -> Result<Response<Self::OperatorStream>, Status> {
let req_stream = request.into_inner(); let req_stream = request.into_inner();
let (bi, rx) = GrpcBi::from_bi_stream(req_stream); let (bi, rx) = GrpcBi::from_bi_stream(req_stream);
tokio::spawn(user_agent::start( tokio::spawn(operator::start(
UserAgentConnection { OperatorConnection {
db: self.context.db.clone(), db: self.context.db.clone(),
actors: self.context.actors.clone(), actors: self.context.actors.clone(),
}, },
bi, bi,
)); ));
info!(event = "connection established", "grpc.user_agent"); info!(event = "connection established", "grpc.operator");
Ok(Response::new(rx)) Ok(Response::new(rx))
} }

View File

@@ -1,12 +1,11 @@
use crate::{ use crate::{
grpc::request_tracker::RequestTracker, grpc::request_tracker::RequestTracker,
peers::user_agent::{OutOfBand, UserAgentConnection, UserAgentSession}, peers::operator::{OperatorConnection, OperatorSession, OutOfBand},
}; };
use arbiter_proto::{ use arbiter_proto::{
proto::user_agent::{ proto::operator::{
UserAgentRequest, UserAgentResponse, OperatorRequest, OperatorResponse, operator_request::Payload as OperatorRequestPayload,
user_agent_request::Payload as UserAgentRequestPayload, operator_response::Payload as OperatorResponsePayload,
user_agent_response::Payload as UserAgentResponsePayload,
}, },
transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi}, transport::{Error as TransportError, Receiver, Sender, grpc::GrpcBi},
}; };
@@ -38,8 +37,8 @@ impl Sender<OutOfBand> for OutOfBandAdapter {
} }
async fn dispatch_loop( async fn dispatch_loop(
mut bi: GrpcBi<UserAgentRequest, UserAgentResponse>, mut bi: GrpcBi<OperatorRequest, OperatorResponse>,
actor: ActorRef<UserAgentSession>, actor: ActorRef<OperatorSession>,
mut receiver: mpsc::Receiver<OutOfBand>, mut receiver: mpsc::Receiver<OutOfBand>,
mut request_tracker: RequestTracker, mut request_tracker: RequestTracker,
) { ) {
@@ -53,7 +52,7 @@ async fn dispatch_loop(
let payload = sdk_client::out_of_band_payload(oob); let payload = sdk_client::out_of_band_payload(oob);
if bi.send(Ok(UserAgentResponse { id: None, payload: Some(payload) })).await.is_err() { if bi.send(Ok(OperatorResponse { id: None, payload: Some(payload) })).await.is_err() {
return; return;
} }
} }
@@ -64,7 +63,7 @@ async fn dispatch_loop(
let conn = match message { let conn = match message {
Ok(conn) => conn, Ok(conn) => conn,
Err(err) => { Err(err) => {
warn!(error = ?err, "Failed to receive user agent request"); warn!(error = ?err, "Failed to receive operator request");
return; return;
} }
}; };
@@ -78,13 +77,13 @@ async fn dispatch_loop(
}; };
let Some(payload) = conn.payload else { let Some(payload) = conn.payload else {
let _ = bi.send(Err(Status::invalid_argument("Missing user-agent request payload"))).await; let _ = bi.send(Err(Status::invalid_argument("Missing operator request payload"))).await;
return; return;
}; };
match dispatch_inner(&actor, payload).await { match dispatch_inner(&actor, payload).await {
Ok(Some(response)) => { Ok(Some(response)) => {
if bi.send(Ok(UserAgentResponse { if bi.send(Ok(OperatorResponse {
id: Some(request_id), id: Some(request_id),
payload: Some(response), payload: Some(response),
})).await.is_err() { })).await.is_err() {
@@ -93,7 +92,7 @@ async fn dispatch_loop(
} }
Ok(None) => {} Ok(None) => {}
Err(status) => { Err(status) => {
error!(?status, "Failed to process user agent request"); error!(?status, "Failed to process operator request");
let _ = bi.send(Err(status)).await; let _ = bi.send(Err(status)).await;
return; return;
} }
@@ -104,23 +103,26 @@ async fn dispatch_loop(
} }
async fn dispatch_inner( async fn dispatch_inner(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
payload: UserAgentRequestPayload, payload: OperatorRequestPayload,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
match payload { match payload {
UserAgentRequestPayload::Vault(req) => vault::dispatch(actor, req).await, OperatorRequestPayload::Vault(req) => vault::dispatch(actor, req).await,
UserAgentRequestPayload::Evm(req) => evm::dispatch(actor, req).await, OperatorRequestPayload::Evm(req) => evm::dispatch(actor, req).await,
UserAgentRequestPayload::SdkClient(req) => sdk_client::dispatch(actor, req).await, OperatorRequestPayload::SdkClient(req) => sdk_client::dispatch(actor, req).await,
UserAgentRequestPayload::Auth(..) => { OperatorRequestPayload::Governance(_) => {
warn!("Unsupported post-auth user agent auth request"); Err(Status::permission_denied(stringify!(Governance)))
Err(Status::invalid_argument("Unsupported user-agent request")) }
OperatorRequestPayload::Auth(..) => {
warn!("Unsupported post-auth operator auth request");
Err(Status::invalid_argument("Unsupported operator request"))
} }
} }
} }
pub async fn start( pub async fn start(
mut conn: UserAgentConnection, mut conn: OperatorConnection,
mut bi: GrpcBi<UserAgentRequest, UserAgentResponse>, mut bi: GrpcBi<OperatorRequest, OperatorResponse>,
) { ) {
let mut request_tracker = RequestTracker::default(); let mut request_tracker = RequestTracker::default();
@@ -129,16 +131,16 @@ pub async fn start(
let actor = { let actor = {
let transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker); let transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker);
match crate::peers::user_agent::start(&mut conn, transport, Box::new(oob_adapter)).await { match crate::peers::operator::start(&mut conn, transport, Box::new(oob_adapter)).await {
Ok(actor) => actor, Ok(actor) => actor,
Err(e) => { Err(e) => {
warn!(error = ?e, "User agent connection failed"); warn!(error = ?e, "Operator connection failed");
return; return;
} }
} }
}; };
info!("User agent session established"); info!("Operator session established");
dispatch_loop(bi, actor.clone(), oob_receiver, request_tracker).await; dispatch_loop(bi, actor.clone(), oob_receiver, request_tracker).await;
actor.kill(); actor.kill();

View File

@@ -1,16 +1,16 @@
use crate::{grpc::request_tracker::RequestTracker, peers::user_agent::auth}; use crate::{grpc::request_tracker::RequestTracker, peers::operator::auth};
use arbiter_crypto::authn; use arbiter_crypto::authn;
use arbiter_proto::{ use arbiter_proto::{
proto::user_agent::{ proto::operator::{
UserAgentRequest, UserAgentResponse, OperatorRequest, OperatorResponse,
auth::{ auth::{
self as proto_auth, AuthChallenge as ProtoAuthChallenge, self as proto_auth, AuthChallenge as ProtoAuthChallenge,
AuthChallengeRequest as ProtoAuthChallengeRequest, AuthChallengeRequest as ProtoAuthChallengeRequest,
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult, AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
request::Payload as AuthRequestPayload, response::Payload as AuthResponsePayload, request::Payload as AuthRequestPayload, response::Payload as AuthResponsePayload,
}, },
user_agent_request::Payload as UserAgentRequestPayload, operator_request::Payload as OperatorRequestPayload,
user_agent_response::Payload as UserAgentResponsePayload, operator_response::Payload as OperatorResponsePayload,
}, },
transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi}, transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi},
}; };
@@ -20,13 +20,13 @@ use tonic::Status;
use tracing::warn; use tracing::warn;
pub(super) struct AuthTransportAdapter<'a> { pub(super) struct AuthTransportAdapter<'a> {
pub(super) bi: &'a mut GrpcBi<UserAgentRequest, UserAgentResponse>, pub(super) bi: &'a mut GrpcBi<OperatorRequest, OperatorResponse>,
pub(super) request_tracker: &'a mut RequestTracker, pub(super) request_tracker: &'a mut RequestTracker,
} }
impl<'a> AuthTransportAdapter<'a> { impl<'a> AuthTransportAdapter<'a> {
pub(super) const fn new( pub(super) const fn new(
bi: &'a mut GrpcBi<UserAgentRequest, UserAgentResponse>, bi: &'a mut GrpcBi<OperatorRequest, OperatorResponse>,
request_tracker: &'a mut RequestTracker, request_tracker: &'a mut RequestTracker,
) -> Self { ) -> Self {
Self { Self {
@@ -35,7 +35,7 @@ impl<'a> AuthTransportAdapter<'a> {
} }
} }
pub(super) const fn bi_mut(&mut self) -> &mut GrpcBi<UserAgentRequest, UserAgentResponse> { pub(super) const fn bi_mut(&mut self) -> &mut GrpcBi<OperatorRequest, OperatorResponse> {
self.bi self.bi
} }
@@ -45,21 +45,21 @@ impl<'a> AuthTransportAdapter<'a> {
pub(super) async fn send_response_payload( pub(super) async fn send_response_payload(
&mut self, &mut self,
payload: UserAgentResponsePayload, payload: OperatorResponsePayload,
) -> Result<(), TransportError> { ) -> Result<(), TransportError> {
self.bi self.bi
.send(Ok(UserAgentResponse { .send(Ok(OperatorResponse {
id: Some(self.request_tracker.current_request_id()), id: Some(self.request_tracker.current_request_id()),
payload: Some(payload), payload: Some(payload),
})) }))
.await .await
} }
async fn send_user_agent_response( async fn send_operator_response(
&mut self, &mut self,
payload: AuthResponsePayload, payload: AuthResponsePayload,
) -> Result<(), TransportError> { ) -> Result<(), TransportError> {
self.send_response_payload(UserAgentResponsePayload::Auth(proto_auth::Response { self.send_response_payload(OperatorResponsePayload::Auth(proto_auth::Response {
payload: Some(payload), payload: Some(payload),
})) }))
.await .await
@@ -80,7 +80,7 @@ impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {
.timestamp .timestamp
.timestamp_nanos_opt() .timestamp_nanos_opt()
.expect("timestamp within range") .expect("timestamp within range")
as u64, .cast_unsigned(),
random: challenge.nonce.to_vec(), random: challenge.nonce.to_vec(),
}) })
} }
@@ -107,7 +107,7 @@ impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {
} }
}; };
self.send_user_agent_response(payload).await self.send_operator_response(payload).await
} }
} }
@@ -117,7 +117,7 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
let request = match self.bi.recv().await? { let request = match self.bi.recv().await? {
Ok(request) => request, Ok(request) => request,
Err(error) => { Err(error) => {
warn!(error = ?error, "Failed to receive user agent auth request"); warn!(error = ?error, "Failed to receive operator auth request");
return None; return None;
} }
}; };
@@ -133,16 +133,16 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
let Some(payload) = request.payload else { let Some(payload) = request.payload else {
warn!( warn!(
event = "received request with empty payload", event = "received request with empty payload",
"grpc.useragent.auth_adapter" "grpc.operator.auth_adapter"
); );
return None; return None;
}; };
let UserAgentRequestPayload::Auth(auth_request) = payload else { let OperatorRequestPayload::Auth(auth_request) = payload else {
let _ = self let _ = self
.bi .bi
.send(Err(Status::invalid_argument( .send(Err(Status::invalid_argument(
"Unsupported user-agent auth request", "Unsupported operator auth request",
))) )))
.await; .await;
return None; return None;
@@ -151,7 +151,7 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
let Some(payload) = auth_request.payload else { let Some(payload) = auth_request.payload else {
warn!( warn!(
event = "received auth request with empty payload", event = "received auth request with empty payload",
"grpc.useragent.auth_adapter" "grpc.operator.auth_adapter"
); );
return None; return None;
}; };
@@ -164,14 +164,14 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
let Ok(pubkey) = authn::PublicKey::try_from(pubkey.as_slice()) else { let Ok(pubkey) = authn::PublicKey::try_from(pubkey.as_slice()) else {
warn!( warn!(
event = "received request with invalid public key", event = "received request with invalid public key",
"grpc.useragent.auth_adapter" "grpc.operator.auth_adapter"
); );
return None; return None;
}; };
Some(auth::Inbound::AuthChallengeRequest { Some(auth::Inbound::AuthChallengeRequest {
pubkey, pubkey,
bootstrap_token, bootstrap_token: bootstrap_token.map(String::into_bytes),
}) })
} }
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => { AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => {

View File

@@ -3,8 +3,8 @@ use crate::{
Convert, TryConvert, Convert, TryConvert,
common::inbound::{RawEvmAddress, RawEvmTransaction}, common::inbound::{RawEvmAddress, RawEvmTransaction},
}, },
peers::user_agent::{ peers::operator::{
UserAgentSession, OperatorSession,
session::handlers::{ session::handlers::{
GrantMutationError, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, GrantMutationError, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate,
HandleGrantDelete, HandleGrantList, HandleSignTransaction, HandleGrantDelete, HandleGrantList, HandleSignTransaction,
@@ -24,12 +24,12 @@ use arbiter_proto::proto::{
wallet_create_response::Result as WalletCreateResult, wallet_create_response::Result as WalletCreateResult,
wallet_list_response::Result as WalletListResult, wallet_list_response::Result as WalletListResult,
}, },
user_agent::{ operator::{
evm::{ evm::{
self as proto_evm, SignTransactionRequest as ProtoSignTransactionRequest, self as proto_evm, SignTransactionRequest as ProtoSignTransactionRequest,
request::Payload as EvmRequestPayload, response::Payload as EvmResponsePayload, request::Payload as EvmRequestPayload, response::Payload as EvmResponsePayload,
}, },
user_agent_response::Payload as UserAgentResponsePayload, operator_response::Payload as OperatorResponsePayload,
}, },
}; };
@@ -37,16 +37,16 @@ use kameo::actor::ActorRef;
use tonic::Status; use tonic::Status;
use tracing::warn; use tracing::warn;
const fn wrap_evm_response(payload: EvmResponsePayload) -> UserAgentResponsePayload { const fn wrap_evm_response(payload: EvmResponsePayload) -> OperatorResponsePayload {
UserAgentResponsePayload::Evm(proto_evm::Response { OperatorResponsePayload::Evm(proto_evm::Response {
payload: Some(payload), payload: Some(payload),
}) })
} }
pub(super) async fn dispatch( pub(super) async fn dispatch(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
req: proto_evm::Request, req: proto_evm::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
let Some(payload) = req.payload else { let Some(payload) = req.payload else {
return Err(Status::invalid_argument("Missing EVM request payload")); return Err(Status::invalid_argument("Missing EVM request payload"));
}; };
@@ -62,8 +62,8 @@ pub(super) async fn dispatch(
} }
async fn handle_wallet_create( async fn handle_wallet_create(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
let result = match actor.ask(HandleEvmWalletCreate {}).await { let result = match actor.ask(HandleEvmWalletCreate {}).await {
Ok((wallet_id, address)) => WalletCreateResult::Wallet(WalletEntry { Ok((wallet_id, address)) => WalletCreateResult::Wallet(WalletEntry {
id: wallet_id, id: wallet_id,
@@ -82,15 +82,15 @@ async fn handle_wallet_create(
} }
async fn handle_wallet_list( async fn handle_wallet_list(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
let result = match actor.ask(HandleEvmWalletList {}).await { let result = match actor.ask(HandleEvmWalletList {}).await {
Ok(wallets) => WalletListResult::Wallets(WalletList { Ok(wallets) => WalletListResult::Wallets(WalletList {
wallets: wallets wallets: wallets
.into_iter() .into_iter()
.map(|(id, address)| WalletEntry { .map(|(id, address)| WalletEntry {
address: address.to_vec(), address: address.to_vec(),
id, id: id.to_raw(),
}) })
.collect(), .collect(),
}), }),
@@ -107,8 +107,8 @@ async fn handle_wallet_list(
} }
async fn handle_grant_list( async fn handle_grant_list(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
let result = match actor.ask(HandleGrantList {}).await { let result = match actor.ask(HandleGrantList {}).await {
Ok(grants) => EvmGrantListResult::Grants(EvmGrantList { Ok(grants) => EvmGrantListResult::Grants(EvmGrantList {
grants: grants grants: grants
@@ -134,9 +134,9 @@ async fn handle_grant_list(
} }
async fn handle_grant_create( async fn handle_grant_create(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
req: EvmGrantCreateRequest, req: EvmGrantCreateRequest,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
let basic = req let basic = req
.shared .shared
.ok_or_else(|| Status::invalid_argument("Missing shared grant settings"))? .ok_or_else(|| Status::invalid_argument("Missing shared grant settings"))?
@@ -164,9 +164,9 @@ async fn handle_grant_create(
} }
async fn handle_grant_delete( async fn handle_grant_delete(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
req: EvmGrantDeleteRequest, req: EvmGrantDeleteRequest,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
let result = match actor let result = match actor
.ask(HandleGrantDelete { .ask(HandleGrantDelete {
grant_id: req.grant_id, grant_id: req.grant_id,
@@ -190,9 +190,9 @@ async fn handle_grant_delete(
} }
async fn handle_sign_transaction( async fn handle_sign_transaction(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
req: ProtoSignTransactionRequest, req: ProtoSignTransactionRequest,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
let request = req let request = req
.request .request
.ok_or_else(|| Status::invalid_argument("Missing sign transaction request"))?; .ok_or_else(|| Status::invalid_argument("Missing sign transaction request"))?;
@@ -217,6 +217,11 @@ async fn handle_sign_transaction(
result: Some(vet_error.convert()), result: Some(vet_error.convert()),
} }
} }
Err(kameo::error::SendError::HandlerError(
SessionSignTransactionError::ClientNotConnected,
)) => {
return Err(Status::permission_denied("client not connected"));
}
Err(kameo::error::SendError::HandlerError(SessionSignTransactionError::Internal)) => { Err(kameo::error::SendError::HandlerError(SessionSignTransactionError::Internal)) => {
EvmSignTransactionResponse { EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error( result: Some(EvmSignTransactionResult::Error(

View File

@@ -1,11 +1,10 @@
use crate::{ use crate::{
db::models::{CoreEvmWalletAccess, NewEvmWalletAccess}, db::models::{CoreEvmWalletAccess, EvmWalletId, NewEvmWalletAccess},
evm::policies::{ evm::policies::{
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer, SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer,
token_transfers, token_transfers,
}, },
grpc::Convert, grpc::{Convert, TryConvert},
grpc::TryConvert,
}; };
use arbiter_proto::{ use arbiter_proto::{
proto::evm::{ proto::evm::{
@@ -14,7 +13,7 @@ use arbiter_proto::{
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit, TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
specific_grant::Grant as ProtoSpecificGrantType, specific_grant::Grant as ProtoSpecificGrantType,
}, },
proto::user_agent::sdk_client::{WalletAccess, WalletAccessEntry as SdkClientWalletAccess}, proto::operator::sdk_client::{WalletAccess, WalletAccessEntry as SdkClientWalletAccess},
}; };
use alloy::primitives::{Address, U256}; use alloy::primitives::{Address, U256};
@@ -87,6 +86,7 @@ impl TryConvert for ProtoSharedSettings {
.valid_until .valid_until
.map(ProtoTimestamp::try_convert) .map(ProtoTimestamp::try_convert)
.transpose()?, .transpose()?,
revoked_at: None,
max_gas_fee_per_gas: self max_gas_fee_per_gas: self
.max_gas_fee_per_gas .max_gas_fee_per_gas
.as_deref() .as_deref()
@@ -150,7 +150,7 @@ impl Convert for WalletAccess {
fn convert(self) -> Self::Output { fn convert(self) -> Self::Output {
NewEvmWalletAccess { NewEvmWalletAccess {
wallet_id: self.wallet_id, wallet_id: EvmWalletId::from_raw(self.wallet_id),
client_id: self.sdk_client_id, client_id: self.sdk_client_id,
} }
} }
@@ -165,7 +165,7 @@ impl TryConvert for SdkClientWalletAccess {
return Err(Status::invalid_argument("Missing wallet access entry")); return Err(Status::invalid_argument("Missing wallet access entry"));
}; };
Ok(CoreEvmWalletAccess { Ok(CoreEvmWalletAccess {
wallet_id: access.wallet_id, wallet_id: EvmWalletId::from_raw(access.wallet_id),
client_id: access.sdk_client_id, client_id: access.sdk_client_id,
id: self.id, id: self.id,
}) })

View File

@@ -10,7 +10,7 @@ use arbiter_proto::proto::{
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit, TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
specific_grant::Grant as ProtoSpecificGrantType, specific_grant::Grant as ProtoSpecificGrantType,
}, },
user_agent::sdk_client::{WalletAccess, WalletAccessEntry as ProtoSdkClientWalletAccess}, operator::sdk_client::{WalletAccess, WalletAccessEntry as ProtoSdkClientWalletAccess},
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
@@ -103,7 +103,7 @@ impl Convert for EvmWalletAccess {
Self::Output { Self::Output {
id: self.id, id: self.id,
access: Some(WalletAccess { access: Some(WalletAccess {
wallet_id: self.wallet_id, wallet_id: self.wallet_id.to_raw(),
sdk_client_id: self.client_id, sdk_client_id: self.client_id,
}), }),
} }

View File

@@ -1,8 +1,8 @@
use crate::{ use crate::{
db::models::NewEvmWalletAccess, db::models::NewEvmWalletAccess,
grpc::Convert, grpc::Convert,
peers::user_agent::{ peers::operator::{
OutOfBand, UserAgentSession, OperatorSession, OutOfBand,
session::handlers::{ session::handlers::{
HandleGrantEvmWalletAccess, HandleListWalletAccess, HandleNewClientApprove, HandleGrantEvmWalletAccess, HandleListWalletAccess, HandleNewClientApprove,
HandleRevokeEvmWalletAccess, HandleSdkClientList, HandleRevokeEvmWalletAccess, HandleSdkClientList,
@@ -11,8 +11,8 @@ use crate::{
}; };
use arbiter_crypto::authn; use arbiter_crypto::authn;
use arbiter_proto::proto::{ use arbiter_proto::proto::{
shared::ClientInfo as ProtoClientMetadata, operator::{
user_agent::{ operator_response::Payload as OperatorResponsePayload,
sdk_client::{ sdk_client::{
self as proto_sdk_client, ConnectionCancel as ProtoSdkClientConnectionCancel, self as proto_sdk_client, ConnectionCancel as ProtoSdkClientConnectionCancel,
ConnectionRequest as ProtoSdkClientConnectionRequest, ConnectionRequest as ProtoSdkClientConnectionRequest,
@@ -24,21 +24,21 @@ use arbiter_proto::proto::{
request::Payload as SdkClientRequestPayload, request::Payload as SdkClientRequestPayload,
response::Payload as SdkClientResponsePayload, response::Payload as SdkClientResponsePayload,
}, },
user_agent_response::Payload as UserAgentResponsePayload,
}, },
shared::ClientInfo as ProtoClientMetadata,
}; };
use kameo::actor::ActorRef; use kameo::actor::ActorRef;
use tonic::Status; use tonic::Status;
use tracing::{info, warn}; use tracing::{info, warn};
const fn wrap_sdk_client_response(payload: SdkClientResponsePayload) -> UserAgentResponsePayload { const fn wrap_sdk_client_response(payload: SdkClientResponsePayload) -> OperatorResponsePayload {
UserAgentResponsePayload::SdkClient(proto_sdk_client::Response { OperatorResponsePayload::SdkClient(proto_sdk_client::Response {
payload: Some(payload), payload: Some(payload),
}) })
} }
pub(super) fn out_of_band_payload(oob: OutOfBand) -> UserAgentResponsePayload { pub(super) fn out_of_band_payload(oob: OutOfBand) -> OperatorResponsePayload {
match oob { match oob {
OutOfBand::ClientConnectionRequest { profile } => wrap_sdk_client_response( OutOfBand::ClientConnectionRequest { profile } => wrap_sdk_client_response(
SdkClientResponsePayload::ConnectionRequest(ProtoSdkClientConnectionRequest { SdkClientResponsePayload::ConnectionRequest(ProtoSdkClientConnectionRequest {
@@ -59,9 +59,9 @@ pub(super) fn out_of_band_payload(oob: OutOfBand) -> UserAgentResponsePayload {
} }
pub(super) async fn dispatch( pub(super) async fn dispatch(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
req: proto_sdk_client::Request, req: proto_sdk_client::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
let Some(payload) = req.payload else { let Some(payload) = req.payload else {
return Err(Status::invalid_argument( return Err(Status::invalid_argument(
"Missing SDK client request payload", "Missing SDK client request payload",
@@ -87,9 +87,9 @@ pub(super) async fn dispatch(
} }
async fn handle_connection_response( async fn handle_connection_response(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
resp: ProtoSdkClientConnectionResponse, resp: ProtoSdkClientConnectionResponse,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
let pubkey = authn::PublicKey::try_from(resp.pubkey.as_slice()) let pubkey = authn::PublicKey::try_from(resp.pubkey.as_slice())
.map_err(|()| Status::invalid_argument("Invalid ML-DSA public key"))?; .map_err(|()| Status::invalid_argument("Invalid ML-DSA public key"))?;
@@ -108,14 +108,14 @@ async fn handle_connection_response(
} }
async fn handle_list( async fn handle_list(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
let result = match actor.ask(HandleSdkClientList {}).await { let result = match actor.ask(HandleSdkClientList {}).await {
Ok(clients) => ProtoSdkClientListResult::Clients(ProtoSdkClientList { Ok(clients) => ProtoSdkClientListResult::Clients(ProtoSdkClientList {
clients: clients clients: clients
.into_iter() .into_iter()
.map(|(client, metadata)| ProtoSdkClientEntry { .map(|(client, metadata)| ProtoSdkClientEntry {
id: client.id, id: client.id.to_raw(),
pubkey: client.public_key.clone(), pubkey: client.public_key.clone(),
info: Some(ProtoClientMetadata { info: Some(ProtoClientMetadata {
name: metadata.name, name: metadata.name,
@@ -144,9 +144,9 @@ async fn handle_list(
} }
async fn handle_grant_wallet_access( async fn handle_grant_wallet_access(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
req: ProtoSdkClientGrantWalletAccess, req: ProtoSdkClientGrantWalletAccess,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
let entries: Vec<NewEvmWalletAccess> = req.accesses.into_iter().map(Convert::convert).collect(); let entries: Vec<NewEvmWalletAccess> = req.accesses.into_iter().map(Convert::convert).collect();
match actor.ask(HandleGrantEvmWalletAccess { entries }).await { match actor.ask(HandleGrantEvmWalletAccess { entries }).await {
Ok(()) => { Ok(()) => {
@@ -161,9 +161,9 @@ async fn handle_grant_wallet_access(
} }
async fn handle_revoke_wallet_access( async fn handle_revoke_wallet_access(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
req: ProtoSdkClientRevokeWalletAccess, req: ProtoSdkClientRevokeWalletAccess,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
match actor match actor
.ask(HandleRevokeEvmWalletAccess { .ask(HandleRevokeEvmWalletAccess {
entries: req.accesses, entries: req.accesses,
@@ -182,8 +182,8 @@ async fn handle_revoke_wallet_access(
} }
async fn handle_list_wallet_access( async fn handle_list_wallet_access(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
match actor.ask(HandleListWalletAccess {}).await { match actor.ask(HandleListWalletAccess {}).await {
Ok(accesses) => Ok(Some(wrap_sdk_client_response( Ok(accesses) => Ok(Some(wrap_sdk_client_response(
SdkClientResponsePayload::ListWalletAccess(ListWalletAccessResponse { SdkClientResponsePayload::ListWalletAccess(ListWalletAccessResponse {

View File

@@ -1,49 +1,49 @@
use crate::{ use crate::{
actors::vault::VaultState, actors::vault::VaultState,
peers::user_agent::{UserAgentSession, session::handlers::HandleQueryVaultState}, peers::operator::{OperatorSession, session::handlers::HandleQueryVaultState},
}; };
use arbiter_proto::{ use arbiter_proto::{
proto::shared::VaultState as ProtoVaultState, proto::operator::{
proto::user_agent::{ operator_response::Payload as OperatorResponsePayload,
user_agent_response::Payload as UserAgentResponsePayload,
vault::{ vault::{
self as proto_vault, request::Payload as VaultRequestPayload, self as proto_vault, request::Payload as VaultRequestPayload,
response::Payload as VaultResponsePayload, response::Payload as VaultResponsePayload,
}, },
}, },
proto::shared::VaultState as ProtoVaultState,
}; };
use kameo::actor::ActorRef; use kameo::actor::ActorRef;
use tonic::Status; use tonic::Status;
use tracing::warn; use tracing::warn;
const fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload { const fn wrap_vault_response(payload: VaultResponsePayload) -> OperatorResponsePayload {
UserAgentResponsePayload::Vault(proto_vault::Response { OperatorResponsePayload::Vault(proto_vault::Response {
payload: Some(payload), payload: Some(payload),
}) })
} }
pub(super) async fn dispatch( pub(super) async fn dispatch(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
req: proto_vault::Request, req: proto_vault::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
let Some(payload) = req.payload else { let Some(payload) = req.payload else {
return Err(Status::invalid_argument("Missing vault request payload")); return Err(Status::invalid_argument("Missing vault request payload"));
}; };
match payload { match payload {
VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await, VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await,
VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => { VaultRequestPayload::Unseal(_)
Err(Status::permission_denied( | VaultRequestPayload::Bootstrap(_)
| VaultRequestPayload::Rekey(_) => Err(Status::permission_denied(
"Vault is already unsealed; unseal/bootstrap not permitted in session", "Vault is already unsealed; unseal/bootstrap not permitted in session",
)) )),
}
} }
} }
async fn handle_query_vault_state( async fn handle_query_vault_state(
actor: &ActorRef<UserAgentSession>, actor: &ActorRef<OperatorSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> { ) -> Result<Option<OperatorResponsePayload>, Status> {
let state = match actor.ask(HandleQueryVaultState {}).await { let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped, Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(VaultState::Sealed) => ProtoVaultState::Sealed, Ok(VaultState::Sealed) => ProtoVaultState::Sealed,

View File

@@ -1,7 +1,7 @@
use super::auth::AuthTransportAdapter; use super::auth::AuthTransportAdapter;
use crate::{ use crate::{
grpc::TryConvert, grpc::TryConvert,
peers::user_agent::vault_gate::{self as vault_gate}, peers::operator::vault_gate::{self as vault_gate},
}; };
use arbiter_proto::transport::{Bi, Error as TransportError, Receiver, Sender}; use arbiter_proto::transport::{Bi, Error as TransportError, Receiver, Sender};
@@ -20,7 +20,7 @@ impl Receiver<vault_gate::Inbound> for AuthTransportAdapter<'_> {
Err(error) => { Err(error) => {
warn!( warn!(
?error, ?error,
"Failed to receive user agent request during vault gate" "Failed to receive operator request during vault gate"
); );
return None; return None;
} }

View File

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

View File

@@ -0,0 +1,185 @@
use crate::{
actors::{vault::VaultState, vault_coordinator},
grpc::{Convert, TryConvert},
peers::operator::vault_gate::{self as vault_gate},
};
use arbiter_proto::proto::{
operator::{
operator_response::Payload as OperatorResponsePayload,
vault::{
self as proto_vault,
bootstrap::{self as proto_bootstrap, BootstrapResult as ProtoBootstrapResult},
response::Payload as VaultResponsePayload,
unseal::{
self as proto_unseal, UnsealResult as ProtoUnsealResult,
response::Payload as UnsealResponsePayload,
},
},
},
shared::VaultState as ProtoVaultState,
};
use tonic::Status;
use tracing::warn;
const fn wrap_vault_response(payload: VaultResponsePayload) -> OperatorResponsePayload {
OperatorResponsePayload::Vault(proto_vault::Response {
payload: Some(payload),
})
}
const fn wrap_unseal_response(payload: UnsealResponsePayload) -> OperatorResponsePayload {
wrap_vault_response(VaultResponsePayload::Unseal(proto_unseal::Response {
payload: Some(payload),
}))
}
/// Ceremony errors are the operator's own doing far more often than ours, so
/// they travel back as a specific status instead of a blanket internal error.
fn ceremony_status(error: &vault_coordinator::Error) -> Status {
match error {
vault_coordinator::Error::AlreadyBootstrapping
| vault_coordinator::Error::AlreadyUnsealing
| vault_coordinator::Error::NotBootstrapping
| vault_coordinator::Error::DuplicateContribution => {
Status::failed_precondition(error.to_string())
}
vault_coordinator::Error::EmptyCommittee
| vault_coordinator::Error::UnsupportedCommittee
| vault_coordinator::Error::CommitteeTooLarge => {
Status::invalid_argument(error.to_string())
}
vault_coordinator::Error::InvalidPassphrase => Status::unauthenticated(error.to_string()),
_ => Status::internal("Vault ceremony failed"),
}
}
fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> OperatorResponsePayload {
wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response {
result: result.into(),
}))
}
impl Convert for VaultState {
type Output = OperatorResponsePayload;
fn convert(self) -> OperatorResponsePayload {
let proto_state = match self {
Self::Unbootstrapped => ProtoVaultState::Unbootstrapped,
Self::Sealed => ProtoVaultState::Sealed,
Self::Unsealed => ProtoVaultState::Unsealed,
};
wrap_vault_response(VaultResponsePayload::State(proto_state.into()))
}
}
impl Convert for vault_gate::HandshakeResponse {
type Output = OperatorResponsePayload;
fn convert(self) -> OperatorResponsePayload {
wrap_unseal_response(UnsealResponsePayload::Start(
proto_unseal::UnsealStartResponse {
server_pubkey: self.server_pubkey.as_bytes().to_vec(),
},
))
}
}
impl TryConvert for vault_gate::Outbound {
type Output = OperatorResponsePayload;
type Error = Status;
fn try_convert(self) -> Result<OperatorResponsePayload, Status> {
match self {
Self::HandleVaultState(result) => result
.map_err(|err| {
warn!(?err, "vault state query failed");
Status::internal("Failed to query vault state")
})
.map(VaultState::convert),
Self::HandleHandshake(result) => result
.map_err(|err| {
warn!(?err, "handshake failed");
Status::internal("Failed to start unseal flow")
})
.map(vault_gate::HandshakeResponse::convert),
Self::HandleUnsealEncryptedKey(result) => {
let proto_result = match result {
Ok(()) => ProtoUnsealResult::Success,
Err(vault_gate::Error::InvalidKey) => ProtoUnsealResult::InvalidKey,
Err(err) => {
warn!(?err, "unseal failed");
return Err(Status::internal("Failed to unseal vault"));
}
};
Ok(wrap_unseal_response(UnsealResponsePayload::Result(
proto_result.into(),
)))
}
Self::HandleBootstrapEncryptedKey(result) => {
let proto_result = match result {
Ok(()) => ProtoBootstrapResult::Success,
Err(vault_gate::Error::InvalidKey) => ProtoBootstrapResult::InvalidKey,
Err(vault_gate::Error::AlreadyBootstrapped) => {
ProtoBootstrapResult::AlreadyBootstrapped
}
Err(err) => {
warn!(?err, "bootstrap failed");
return Err(Status::internal("Failed to bootstrap vault"));
}
};
Ok(wrap_bootstrap_response(proto_result))
}
Self::HandleDeclareCommittee(result) => {
let proto_result = match result {
Ok(()) => ProtoBootstrapResult::AwaitingContributions,
Err(vault_gate::Error::Ceremony(
vault_coordinator::Error::AlreadyBootstrapped,
)) => ProtoBootstrapResult::AlreadyBootstrapped,
Err(vault_gate::Error::Ceremony(err)) => {
warn!(?err, "declare committee failed");
return Err(ceremony_status(&err));
}
Err(err) => {
warn!(?err, "declare committee failed");
return Err(Status::internal("Failed to declare committee"));
}
};
Ok(wrap_bootstrap_response(proto_result))
}
Self::HandleContributeBootstrapPassphrase(result) => {
let proto_result = match result {
Ok(true) => ProtoBootstrapResult::Success,
Ok(false) => ProtoBootstrapResult::AwaitingContributions,
Err(vault_gate::Error::Ceremony(
vault_coordinator::Error::AlreadyBootstrapped,
)) => ProtoBootstrapResult::AlreadyBootstrapped,
Err(vault_gate::Error::Ceremony(err)) => {
warn!(?err, "contribute bootstrap passphrase failed");
return Err(ceremony_status(&err));
}
Err(err) => {
warn!(?err, "contribute bootstrap passphrase failed");
return Err(Status::internal(
"Failed to contribute bootstrap passphrase",
));
}
};
Ok(wrap_bootstrap_response(proto_result))
}
Self::HandleContributeUnsealPassphrase(result) => {
let proto_result = match result {
Ok(true) => ProtoUnsealResult::Success,
Ok(false) => ProtoUnsealResult::AwaitingContributions,
Err(err) => {
warn!(?err, "contribute unseal passphrase failed");
return Err(Status::internal("Failed to contribute unseal passphrase"));
}
};
Ok(wrap_unseal_response(UnsealResponsePayload::Result(
proto_result.into(),
)))
}
}
}
}

View File

@@ -1,115 +0,0 @@
use crate::{
actors::vault::VaultState,
grpc::{Convert, TryConvert},
peers::user_agent::vault_gate::{self as vault_gate},
};
use arbiter_proto::proto::{
shared::VaultState as ProtoVaultState,
user_agent::{
user_agent_response::Payload as UserAgentResponsePayload,
vault::{
self as proto_vault,
bootstrap::{self as proto_bootstrap, BootstrapResult as ProtoBootstrapResult},
response::Payload as VaultResponsePayload,
unseal::{
self as proto_unseal, UnsealResult as ProtoUnsealResult,
response::Payload as UnsealResponsePayload,
},
},
},
};
use tonic::Status;
use tracing::warn;
const fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload {
UserAgentResponsePayload::Vault(proto_vault::Response {
payload: Some(payload),
})
}
const fn wrap_unseal_response(payload: UnsealResponsePayload) -> UserAgentResponsePayload {
wrap_vault_response(VaultResponsePayload::Unseal(proto_unseal::Response {
payload: Some(payload),
}))
}
fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> UserAgentResponsePayload {
wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response {
result: result.into(),
}))
}
impl Convert for VaultState {
type Output = UserAgentResponsePayload;
fn convert(self) -> UserAgentResponsePayload {
let proto_state = match self {
Self::Unbootstrapped => ProtoVaultState::Unbootstrapped,
Self::Sealed => ProtoVaultState::Sealed,
Self::Unsealed => ProtoVaultState::Unsealed,
};
wrap_vault_response(VaultResponsePayload::State(proto_state.into()))
}
}
impl Convert for vault_gate::HandshakeResponse {
type Output = UserAgentResponsePayload;
fn convert(self) -> UserAgentResponsePayload {
wrap_unseal_response(UnsealResponsePayload::Start(
proto_unseal::UnsealStartResponse {
server_pubkey: self.server_pubkey.as_bytes().to_vec(),
},
))
}
}
impl TryConvert for vault_gate::Outbound {
type Output = UserAgentResponsePayload;
type Error = Status;
fn try_convert(self) -> Result<UserAgentResponsePayload, Status> {
match self {
Self::HandleVaultState(result) => result
.map_err(|err| {
warn!(?err, "vault state query failed");
Status::internal("Failed to query vault state")
})
.map(VaultState::convert),
Self::HandleHandshake(result) => result
.map_err(|err| {
warn!(?err, "handshake failed");
Status::internal("Failed to start unseal flow")
})
.map(vault_gate::HandshakeResponse::convert),
Self::HandleUnsealEncryptedKey(result) => {
let proto_result = match result {
Ok(()) => ProtoUnsealResult::Success,
Err(vault_gate::Error::InvalidKey) => ProtoUnsealResult::InvalidKey,
Err(err) => {
warn!(?err, "unseal failed");
return Err(Status::internal("Failed to unseal vault"));
}
};
Ok(wrap_unseal_response(UnsealResponsePayload::Result(
proto_result.into(),
)))
}
Self::HandleBootstrapEncryptedKey(result) => {
let proto_result = match result {
Ok(()) => ProtoBootstrapResult::Success,
Err(vault_gate::Error::InvalidKey) => ProtoBootstrapResult::InvalidKey,
Err(vault_gate::Error::AlreadyBootstrapped) => {
ProtoBootstrapResult::AlreadyBootstrapped
}
Err(err) => {
warn!(?err, "bootstrap failed");
return Err(Status::internal("Failed to bootstrap vault"));
}
};
Ok(wrap_bootstrap_response(proto_result))
}
}
}
}

View File

@@ -8,7 +8,7 @@ use crate::{
crypto::integrity::{self, AttestationStatus}, crypto::integrity::{self, AttestationStatus},
db::{ db::{
self, self,
models::{ProgramClientMetadata, SqliteTimestamp}, models::ProgramClientMetadata,
schema::program_client, schema::program_client,
}, },
}; };
@@ -18,14 +18,13 @@ use arbiter_proto::{
transport::{Bi, expect_message}, transport::{Bi, expect_message},
}; };
use chrono::Utc;
use diesel::{ use diesel::{
ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, SelectableHelper as _, ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, SelectableHelper as _,
dsl::insert_into, update, dsl::insert_into,
}; };
use diesel_async::RunQueryDsl as _; use diesel_async::RunQueryDsl as _;
use kameo::{actor::ActorRef, error::SendError}; use kameo::{actor::ActorRef, error::SendError};
use tracing::error; use tracing::{error, warn};
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum Error { pub enum Error {
@@ -54,7 +53,7 @@ impl From<diesel::result::Error> for Error {
pub enum ApproveError { pub enum ApproveError {
#[error("Internal error")] #[error("Internal error")]
Internal, Internal,
#[error("Client connection denied by user agents")] #[error("Client connection denied by operators")]
Denied, Denied,
#[error("Upstream error: {0}")] #[error("Upstream error: {0}")]
Upstream(flow_coordinator::ApprovalError), Upstream(flow_coordinator::ApprovalError),
@@ -171,10 +170,7 @@ async fn insert_client(
Error::DatabasePoolUnavailable Error::DatabasePoolUnavailable
})?; })?;
conn.exclusive_transaction(|conn| { conn.exclusive_transaction(async |conn| {
let vault = vault.clone();
let pubkey = pubkey.clone();
Box::pin(async move {
let metadata_id = insert_into(client_metadata::table) let metadata_id = insert_into(client_metadata::table)
.values(( .values((
client_metadata::name.eq(&metadata.name), client_metadata::name.eq(&metadata.name),
@@ -182,7 +178,7 @@ async fn insert_client(
client_metadata::version.eq(&metadata.version), client_metadata::version.eq(&metadata.version),
)) ))
.returning(client_metadata::id) .returning(client_metadata::id)
.get_result::<i32>(conn) .get_result::<i32>(&mut *conn)
.await?; .await?;
let client_id = insert_into(program_client::table) let client_id = insert_into(program_client::table)
@@ -192,12 +188,12 @@ async fn insert_client(
)) ))
.on_conflict_do_nothing() .on_conflict_do_nothing()
.returning(program_client::id) .returning(program_client::id)
.get_result::<i32>(conn) .get_result::<i32>(&mut *conn)
.await?; .await?;
integrity::sign_entity( integrity::sign_entity(
conn, &mut *conn,
&vault, vault,
&ClientCredentials { &ClientCredentials {
pubkey: pubkey.clone(), pubkey: pubkey.clone(),
}, },
@@ -211,79 +207,50 @@ async fn insert_client(
Ok(client_id) Ok(client_id)
}) })
})
.await .await
} }
async fn sync_client_metadata( /// Compares stored metadata against what a reconnecting client presents.
/// Metadata is frozen after initial operator approval and must not be silently
/// overwritten. Doing so would let an approved client forge its displayed
/// identity in later approval prompts. Drift is logged and ignored.
async fn check_metadata_drift(
db: &db::DatabasePool, db: &db::DatabasePool,
client_id: i32, client_id: i32,
metadata: &ClientMetadata, presented: &ClientMetadata,
) -> Result<(), Error> { ) -> Result<(), Error> {
use crate::db::schema::{client_metadata, client_metadata_history}; use crate::db::schema::client_metadata;
let now = SqliteTimestamp(Utc::now());
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable Error::DatabasePoolUnavailable
})?; })?;
conn.exclusive_transaction(|conn| { let current: ProgramClientMetadata = program_client::table
let metadata = metadata.clone();
Box::pin(async move {
let (current_metadata_id, current): (i32, ProgramClientMetadata) =
program_client::table
.find(client_id) .find(client_id)
.inner_join(client_metadata::table) .inner_join(client_metadata::table)
.select(( .select(ProgramClientMetadata::as_select())
program_client::metadata_id, .first(&mut conn)
ProgramClientMetadata::as_select(),
))
.first(conn)
.await?;
let unchanged = current.name == metadata.name
&& current.description == metadata.description
&& current.version == metadata.version;
if unchanged {
return Ok(());
}
insert_into(client_metadata_history::table)
.values((
client_metadata_history::metadata_id.eq(current_metadata_id),
client_metadata_history::client_id.eq(client_id),
))
.execute(conn)
.await?;
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(conn)
.await?;
update(program_client::table.find(client_id))
.set((
program_client::metadata_id.eq(metadata_id),
program_client::updated_at.eq(now),
))
.execute(conn)
.await?;
Ok::<(), diesel::result::Error>(())
})
})
.await .await
.map_err(|e| { .map_err(|e| {
error!(error = ?e, "Database error"); error!(error = ?e, "Database error");
Error::DatabaseOperationFailed Error::DatabaseOperationFailed
}) })?;
let changed = current.name != presented.name
|| current.description != presented.description
|| current.version != presented.version;
if changed {
warn!(
client_id,
stored_name = %current.name,
presented_name = %presented.name,
"reconnecting client presented different metadata; ignoring - metadata is frozen after operator approval"
);
}
Ok(())
} }
async fn challenge_client<T>( async fn challenge_client<T>(
@@ -306,7 +273,7 @@ where
let signature = expect_message(transport, |req: Inbound| match req { let signature = expect_message(transport, |req: Inbound| match req {
Inbound::AuthChallengeSolution { signature } => Some(signature), Inbound::AuthChallengeSolution { signature } => Some(signature),
_ => None, Inbound::AuthChallengeRequest { .. } => None,
}) })
.await .await
.map_err(|e| { .map_err(|e| {
@@ -332,6 +299,7 @@ where
let client_id = if let Some(id) = get_client_id(&props.db, &pubkey).await? { let client_id = if let Some(id) = get_client_id(&props.db, &pubkey).await? {
verify_integrity(&props.db, &props.actors.vault, &pubkey).await?; verify_integrity(&props.db, &props.actors.vault, &pubkey).await?;
check_metadata_drift(&props.db, id, &metadata).await?;
id id
} else { } else {
approve_new_client( approve_new_client(
@@ -345,8 +313,6 @@ where
insert_client(&props.db, &props.actors.vault, &pubkey, &metadata).await? insert_client(&props.db, &props.actors.vault, &pubkey, &metadata).await?
}; };
sync_client_metadata(&props.db, client_id, &metadata).await?;
let challenge = AuthChallenge::generate(&mut rand::rng()); let challenge = AuthChallenge::generate(&mut rand::rng());
challenge_client(transport, pubkey, challenge).await?; challenge_client(transport, pubkey, challenge).await?;

View File

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

View File

@@ -1,2 +1,2 @@
pub mod client; pub mod client;
pub mod user_agent; pub mod operator;

View File

@@ -1,4 +1,4 @@
use super::{Credentials, UserAgentConnection}; use super::{Credentials, OperatorConnection};
use arbiter_crypto::authn::{self, AuthChallenge}; use arbiter_crypto::authn::{self, AuthChallenge};
use arbiter_proto::transport::Bi; use arbiter_proto::transport::Bi;
@@ -14,7 +14,7 @@ mod state;
pub enum Inbound { pub enum Inbound {
AuthChallengeRequest { AuthChallengeRequest {
pubkey: authn::PublicKey, pubkey: authn::PublicKey,
bootstrap_token: Option<String>, bootstrap_token: Option<Vec<u8>>,
}, },
AuthChallengeSolution { AuthChallengeSolution {
signature: Vec<u8>, signature: Vec<u8>,
@@ -69,7 +69,7 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents {
} }
pub async fn authenticate<T>( pub async fn authenticate<T>(
props: &mut UserAgentConnection, props: &mut OperatorConnection,
transport: &mut T, transport: &mut T,
) -> Result<Credentials, Error> ) -> Result<Credentials, Error>
where where

View File

@@ -1,13 +1,13 @@
use super::{ use super::{
super::{Credentials, UserAgentConnection}, super::{Credentials, OperatorConnection},
Error, Error,
}; };
use crate::{ use crate::{
actors::bootstrap::ConsumeToken, actors::bootstrap::VerifyToken,
db::{DatabasePool, schema::useragent_client}, db::{DatabasePool, models::OperatorId, schema::operator_identity},
peers::user_agent::auth::Outbound, peers::operator::auth::Outbound,
}; };
use arbiter_crypto::authn::{self, AuthChallenge, USERAGENT_CONTEXT}; use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT};
use arbiter_proto::transport::Bi; use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl}; use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl};
@@ -16,13 +16,12 @@ use tracing::error;
pub(super) struct ChallengeRequest { pub(super) struct ChallengeRequest {
pub(super) pubkey: authn::PublicKey, pub(super) pubkey: authn::PublicKey,
pub(super) bootstrap_token: Option<String>, pub(super) bootstrap_token: Option<Vec<u8>>,
} }
pub(super) struct ChallengeContext { pub struct ChallengeContext {
pub(super) challenge: AuthChallenge, pub(super) challenge: AuthChallenge,
pub(super) pubkey: authn::PublicKey, pub(super) pubkey: authn::PublicKey,
pub(super) bootstrap_token: Option<String>,
} }
pub(super) struct ChallengeSolution { pub(super) struct ChallengeSolution {
@@ -38,16 +37,19 @@ smlang::statemachine!(
} }
); );
async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<Option<i32>, Error> { async fn get_client_id(
db: &DatabasePool,
pubkey: &authn::PublicKey,
) -> Result<Option<OperatorId>, Error> {
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::internal("Database unavailable") Error::internal("Database unavailable")
})?; })?;
useragent_client::table operator_identity::table
.filter(useragent_client::public_key.eq(pubkey.to_bytes())) .filter(operator_identity::public_key.eq(pubkey.to_bytes()))
.select(useragent_client::id) .select(operator_identity::id)
.first::<i32>(&mut conn) .first::<OperatorId>(&mut conn)
.await .await
.optional() .optional()
.map_err(|e| { .map_err(|e| {
@@ -56,16 +58,16 @@ async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<O
}) })
} }
async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i32, Error> { async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<OperatorId, Error> {
let pubkey_bytes = pubkey.to_bytes(); let pubkey_bytes = pubkey.to_bytes();
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::internal("Database unavailable") Error::internal("Database unavailable")
})?; })?;
let id: i32 = diesel::insert_into(useragent_client::table) let id: OperatorId = diesel::insert_into(operator_identity::table)
.values((useragent_client::public_key.eq(pubkey_bytes),)) .values((operator_identity::public_key.eq(pubkey_bytes),))
.returning(useragent_client::id) .returning(operator_identity::id)
.get_result(&mut conn) .get_result(&mut conn)
.await .await
.map_err(|e| { .map_err(|e| {
@@ -77,13 +79,18 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i3
} }
pub(super) struct AuthContext<'a, T: ?Sized> { pub(super) struct AuthContext<'a, T: ?Sized> {
pub(super) conn: &'a mut UserAgentConnection, pub(super) conn: &'a mut OperatorConnection,
pub(super) transport: &'a mut T, pub(super) transport: &'a mut T,
bootstrap_token: Option<Vec<u8>>,
} }
impl<'a, T: ?Sized> AuthContext<'a, T> { impl<'a, T: ?Sized> AuthContext<'a, T> {
pub(super) const fn new(conn: &'a mut UserAgentConnection, transport: &'a mut T) -> Self { pub(super) const fn new(conn: &'a mut OperatorConnection, transport: &'a mut T) -> Self {
Self { conn, transport } Self {
conn,
transport,
bootstrap_token: None,
}
} }
} }
@@ -108,6 +115,8 @@ where
} }
} }
self.bootstrap_token = bootstrap_token;
let challenge = AuthChallenge::generate(&mut rand::rng()); let challenge = AuthChallenge::generate(&mut rand::rng());
self.transport self.transport
@@ -120,22 +129,12 @@ where
Error::Transport Error::Transport
})?; })?;
Ok(ChallengeContext { Ok(ChallengeContext { challenge, pubkey })
challenge,
pubkey,
bootstrap_token,
})
} }
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
async fn verify_solution( async fn verify_solution(
&mut self, &mut self,
ChallengeContext { ChallengeContext { challenge, pubkey }: &ChallengeContext,
challenge,
pubkey,
bootstrap_token,
}: &ChallengeContext,
ChallengeSolution { solution }: ChallengeSolution, ChallengeSolution { solution }: ChallengeSolution,
) -> Result<Credentials, Self::Error> { ) -> Result<Credentials, Self::Error> {
let signature = authn::Signature::try_from(solution.as_slice()).map_err(|()| { let signature = authn::Signature::try_from(solution.as_slice()).map_err(|()| {
@@ -143,7 +142,7 @@ where
Error::InvalidChallengeSolution Error::InvalidChallengeSolution
})?; })?;
let valid = pubkey.verify(challenge, USERAGENT_CONTEXT, &signature); let valid = pubkey.verify(challenge, OPERATOR_CONTEXT, &signature);
if !valid { if !valid {
self.transport self.transport
@@ -154,15 +153,13 @@ where
} }
// Resolve client id: bootstrap (consume token + register) or lookup // Resolve client id: bootstrap (consume token + register) or lookup
let id = match bootstrap_token { let id = match self.bootstrap_token.take() {
Some(token) => { Some(token) => {
let token_ok: bool = self let token_ok: bool = self
.conn .conn
.actors .actors
.bootstrapper .bootstrapper
.ask(ConsumeToken { .ask(VerifyToken { token })
token: token.clone(),
})
.await .await
.map_err(|e| { .map_err(|e| {
error!(?e, "Failed to consume bootstrap token"); error!(?e, "Failed to consume bootstrap token");

View File

@@ -4,7 +4,7 @@ use crate::{
vault::{GetState, Vault}, vault::{GetState, Vault},
}, },
crypto::integrity::{self, AttestationStatus, Integrable}, crypto::integrity::{self, AttestationStatus, Integrable},
db::{DatabaseError, DatabasePool}, db::{DatabaseError, DatabasePool, models::OperatorId},
peers::client::ClientProfile, peers::client::ClientProfile,
}; };
use arbiter_crypto::authn; use arbiter_crypto::authn;
@@ -17,7 +17,7 @@ use tokio::sync::oneshot;
use tracing::{error, warn}; use tracing::{error, warn};
pub use auth::authenticate; pub use auth::authenticate;
pub use session::UserAgentSession; pub use session::OperatorSession;
pub mod auth; pub mod auth;
pub mod session; pub mod session;
@@ -25,15 +25,15 @@ pub mod vault_gate;
#[derive(Debug, Clone, Hashable)] #[derive(Debug, Clone, Hashable)]
pub struct Credentials { pub struct Credentials {
pub id: i32, pub id: OperatorId,
pub pubkey: authn::PublicKey, pub pubkey: authn::PublicKey,
} }
impl Integrable for Credentials { impl Integrable for Credentials {
const KIND: &'static str = "useragent_credentials"; const KIND: &'static str = "operator_credentials";
} }
// Messages, sent by user agent to connection client without having a request // Messages, sent by operator to connection client without having a request
#[derive(Debug)] #[derive(Debug)]
pub enum OutOfBand { pub enum OutOfBand {
ClientConnectionRequest { profile: ClientProfile }, ClientConnectionRequest { profile: ClientProfile },
@@ -41,12 +41,12 @@ pub enum OutOfBand {
} }
#[derive(Clone)] #[derive(Clone)]
pub struct UserAgentConnection { pub struct OperatorConnection {
pub(crate) db: DatabasePool, pub(crate) db: DatabasePool,
pub(crate) actors: GlobalActors, pub(crate) actors: GlobalActors,
} }
impl UserAgentConnection { impl OperatorConnection {
pub const fn new(db: DatabasePool, actors: GlobalActors) -> Self { pub const fn new(db: DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors } Self { db, actors }
} }
@@ -106,7 +106,7 @@ async fn should_run_gate(vault: &ActorRef<Vault>) -> Result<bool, Error> {
} }
async fn run_vault_gate<T>( async fn run_vault_gate<T>(
props: &UserAgentConnection, props: &OperatorConnection,
transport: &mut T, transport: &mut T,
auth_creds: Credentials, auth_creds: Credentials,
) -> Result<(), Error> ) -> Result<(), Error>
@@ -160,10 +160,10 @@ where
} }
pub async fn start<T>( pub async fn start<T>(
props: &mut UserAgentConnection, props: &mut OperatorConnection,
mut transport: T, mut transport: T,
oob_sender: Box<dyn Sender<OutOfBand>>, oob_sender: Box<dyn Sender<OutOfBand>>,
) -> Result<ActorRef<UserAgentSession>, Error> ) -> Result<ActorRef<OperatorSession>, Error>
where where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send, T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send,
T: Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>> + Send, T: Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>> + Send,
@@ -178,7 +178,7 @@ where
// checking the integrity // checking the integrity
verify_integrity(&props.db, &props.actors.vault, &creds).await?; verify_integrity(&props.db, &props.actors.vault, &creds).await?;
Ok(UserAgentSession::spawn(UserAgentSession::new( Ok(OperatorSession::spawn(OperatorSession::new(
props.clone(), props.clone(),
oob_sender, oob_sender,
))) )))

View File

@@ -0,0 +1,472 @@
use super::{Error, OperatorSession};
use crate::{
actors::{
evm::{
ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorListGrants,
SignTransactionError as EvmSignError,
},
flow_coordinator::{IsClientConnected, client_connect_approval::ClientApprovalAnswer},
vault::VaultState,
},
db::{
models::{
EvmWalletAccess, EvmWalletId, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
},
schema::program_client,
},
evm::policies::{Grant, SpecificGrant},
};
use arbiter_crypto::authn;
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{error::SendError, messages, prelude::Context};
use tracing::{error, info, warn};
#[derive(Debug, Error)]
pub enum SignTransactionError {
#[error("Policy evaluation failed")]
Vet(#[from] crate::evm::VetError),
#[error("Client not connected")]
ClientNotConnected,
#[error("Internal signing error")]
Internal,
}
#[derive(Debug, Error)]
pub enum GrantMutationError {
#[error("Vault is sealed")]
VaultSealed,
#[error("Internal grant mutation error")]
Internal,
}
#[messages]
impl OperatorSession {
#[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<VaultState, Error> {
use crate::actors::vault::GetState;
let vault_state = match self.props.actors.vault.ask(GetState {}).await {
Ok(state) => state,
Err(err) => {
error!(?err, actor = "operator", "vault.query.failed");
return Err(Error::internal("Vault is in broken state"));
}
};
Ok(vault_state)
}
}
#[messages]
impl OperatorSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
match self.props.actors.evm.ask(Generate {}).await {
Ok(address) => Ok(address),
Err(SendError::HandlerError(err)) => Err(Error::internal(format!(
"EVM wallet generation failed: {err}"
))),
Err(err) => {
error!(?err, "EVM actor unreachable during wallet create");
Err(Error::internal("EVM actor unreachable"))
}
}
}
#[message]
pub(crate) async fn handle_evm_wallet_list(
&mut self,
) -> Result<Vec<(EvmWalletId, Address)>, Error> {
match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => Ok(wallets),
Err(err) => {
error!(?err, "EVM wallet list failed");
Err(Error::internal("Failed to list EVM wallets"))
}
}
}
}
#[messages]
impl OperatorSession {
#[message]
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.props.actors.evm.ask(OperatorListGrants {}).await {
Ok(grants) => Ok(grants),
Err(err) => {
error!(?err, "EVM grant list failed");
Err(Error::internal("Failed to list EVM grants"))
}
}
}
#[message]
pub(crate) async fn handle_grant_create(
&mut self,
basic: crate::evm::policies::SharedGrantSettings,
grant: SpecificGrant,
) -> Result<i32, GrantMutationError> {
match self
.props
.actors
.evm
.ask(OperatorCreateGrant { basic, grant })
.await
{
Ok(grant_id) => Ok(grant_id),
Err(err) => {
error!(?err, "EVM grant create failed");
Err(GrantMutationError::Internal)
}
}
}
#[message]
pub(crate) fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), GrantMutationError> {
// match self
// .props
// .actors
// .evm
// .ask(OperatorDeleteGrant { grant_id })
// .await
// {
// Ok(()) => Ok(()),
// Err(err) => {
// error!(?err, "EVM grant delete failed");
// Err(GrantMutationError::Internal)
// }
// }
let _ = grant_id;
todo!()
}
#[message]
pub(crate) async fn handle_sign_transaction(
&mut self,
client_id: i32,
wallet_address: Address,
transaction: TxEip1559,
) -> Result<Signature, SignTransactionError> {
if !self.approved_client_ids.contains(&client_id) {
warn!(
client_id,
"operator attempted to sign for client not in its approved set"
);
return Err(SignTransactionError::ClientNotConnected);
}
let connected = self
.props
.actors
.flow_coordinator
.ask(IsClientConnected { client_id })
.await
.unwrap_or(false);
if !connected {
self.approved_client_ids.remove(&client_id);
warn!(client_id, "operator attempted to sign for disconnected client");
return Err(SignTransactionError::ClientNotConnected);
}
info!(client_id, event = "sign_transaction", "operator.sign_transaction");
match self
.props
.actors
.evm
.ask(ClientSignTransaction {
client_id,
wallet_address,
transaction,
})
.await
{
Ok(signature) => Ok(signature),
Err(SendError::HandlerError(EvmSignError::Vet(vet_error))) => {
Err(SignTransactionError::Vet(vet_error))
}
Err(err) => {
error!(?err, "EVM sign transaction failed in operator session");
Err(SignTransactionError::Internal)
}
}
}
#[message]
pub(crate) async fn handle_grant_evm_wallet_access(
&mut self,
entries: Vec<NewEvmWalletAccess>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(async |conn| {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::insert_into(evm_wallet_access::table)
.values(&entry)
.on_conflict_do_nothing()
.execute(&mut *conn)
.await?;
}
Result::<_, Error>::Ok(())
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_revoke_evm_wallet_access(
&mut self,
entries: Vec<i32>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(async |conn| {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::id.eq(entry))
.execute(&mut *conn)
.await?;
}
Result::<_, Error>::Ok(())
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_list_wallet_access(
&mut self,
) -> Result<Vec<EvmWalletAccess>, Error> {
let mut conn = self.props.db.get().await?;
let access_entries = crate::db::schema::evm_wallet_access::table
.select(EvmWalletAccess::as_select())
.load::<_>(&mut conn)
.await?;
Ok(access_entries)
}
}
#[messages]
impl OperatorSession {
#[message(ctx)]
pub(crate) async fn handle_new_client_approve(
&mut self,
approved: bool,
pubkey: authn::PublicKey,
ctx: &mut Context<Self, Result<(), Error>>,
) -> Result<(), Error> {
let Some(pending_approval) = self.pending_client_approvals.remove(&pubkey.to_bytes())
else {
error!("Received client connection response for unknown client");
return Err(Error::internal("Unknown client in connection response"));
};
pending_approval
.controller
.tell(ClientApprovalAnswer { approved })
.await
.map_err(|err| {
error!(
?err,
"Failed to send client approval response to controller"
);
Error::internal("Failed to send client approval response to controller")
})?;
ctx.actor_ref().unlink(&pending_approval.controller).await;
if approved {
let pubkey_bytes = pending_approval.pubkey.to_bytes();
match self.props.db.get().await {
Ok(mut conn) => {
match program_client::table
.filter(program_client::public_key.eq(pubkey_bytes.as_slice()))
.select(program_client::id)
.first::<i32>(&mut conn)
.await
{
Ok(client_id) => {
self.approved_client_ids.insert(client_id);
}
Err(err) => {
error!(?err, "Failed to look up client_id for approved pubkey");
}
}
}
Err(err) => {
error!(?err, "DB pool error after client approval");
}
}
}
Ok(())
}
#[message]
pub(crate) async fn handle_sdk_client_list(
&mut self,
) -> Result<Vec<(ProgramClient, ProgramClientMetadata)>, Error> {
use crate::db::schema::{client_metadata, program_client};
let mut conn = self.props.db.get().await?;
let clients = program_client::table
.inner_join(client_metadata::table)
.select((
ProgramClient::as_select(),
ProgramClientMetadata::as_select(),
))
.load::<(ProgramClient, ProgramClientMetadata)>(&mut conn)
.await?;
Ok(clients)
}
}
#[cfg(test)]
mod tests {
use crate::db::{self, models::{EvmWalletId, NewEvmWalletAccess}, schema::evm_wallet_access};
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
/// Regression test: revocation must delete by access-entry `id`, not by `wallet_id`.
///
/// Before the fix, revoking `entry_id=1` would delete all rows where `wallet_id=1`,
/// wiping out every client's access to wallet #1.
#[tokio::test]
async fn revoke_deletes_by_entry_id_not_wallet_id() {
use crate::db::models::EvmWalletAccess;
let pool = db::create_test_pool().await;
let mut conn = pool.get().await.expect("pool connection");
// Insert two access entries for the same wallet but different clients.
// entry A: id will be 1, wallet_id=1, client_id=10
// entry B: id will be 2, wallet_id=1, client_id=20
let entry_a = diesel::insert_into(evm_wallet_access::table)
.values(NewEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(1),
client_id: 10,
})
.returning(EvmWalletAccess::as_select())
.get_result(&mut *conn)
.await
.expect("insert entry A");
let entry_b = diesel::insert_into(evm_wallet_access::table)
.values(NewEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(1),
client_id: 20,
})
.returning(EvmWalletAccess::as_select())
.get_result(&mut *conn)
.await
.expect("insert entry B");
// Revoke only entry A by its primary key id.
conn.transaction(async |conn| {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::id.eq(entry_a.id))
.execute(&mut *conn)
.await
})
.await
.expect("revoke entry A");
// Entry A must be gone.
let gone = evm_wallet_access::table
.filter(evm_wallet_access::id.eq(entry_a.id))
.count()
.get_result::<i64>(&mut *conn)
.await
.expect("count entry A");
assert_eq!(gone, 0, "revoked entry must be deleted");
// Entry B (same wallet, different client) must still exist.
let still_there = evm_wallet_access::table
.filter(evm_wallet_access::id.eq(entry_b.id))
.count()
.get_result::<i64>(&mut *conn)
.await
.expect("count entry B");
assert_eq!(still_there, 1, "unrelated entry must not be deleted");
}
/// Regression test: when `entry_id` and `wallet_id` differ, only the correct row is removed.
///
/// This specifically catches the case where `entry.id=5` and `wallet_id=1` are different values;
/// the old bug would delete by `wallet_id`, potentially matching a completely different entry.
#[tokio::test]
async fn revoke_with_mismatched_wallet_and_entry_ids() {
use crate::db::models::EvmWalletAccess;
let pool = db::create_test_pool().await;
let mut conn = pool.get().await.expect("pool connection");
// Insert entries to force auto-increment IDs to diverge from wallet_ids.
// We'll insert 5 placeholder entries first so that the real entry gets id=6.
for i in 1_i32..=5 {
diesel::insert_into(evm_wallet_access::table)
.values(NewEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(99),
client_id: i,
})
.execute(&mut *conn)
.await
.expect("insert placeholder");
}
// Real target: wallet_id=1, will get id=6.
let target = diesel::insert_into(evm_wallet_access::table)
.values(NewEvmWalletAccess {
wallet_id: EvmWalletId::from_raw(1),
client_id: 1,
})
.returning(EvmWalletAccess::as_select())
.get_result(&mut *conn)
.await
.expect("insert target");
// Sanity: target.id != target.wallet_id
assert_ne!(
target.id, target.wallet_id.to_raw(),
"test prerequisite: id and wallet_id must differ"
);
// Revoke by entry id.
conn.transaction(async |conn| {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::id.eq(target.id))
.execute(&mut *conn)
.await
})
.await
.expect("revoke target");
let remaining = evm_wallet_access::table
.filter(evm_wallet_access::id.eq(target.id))
.count()
.get_result::<i64>(&mut *conn)
.await
.expect("count target");
assert_eq!(remaining, 0, "target must be deleted by its entry id");
// Placeholders for wallet_id=99 must be untouched.
let placeholders = evm_wallet_access::table
.filter(evm_wallet_access::wallet_id.eq(99))
.count()
.get_result::<i64>(&mut *conn)
.await
.expect("count placeholders");
assert_eq!(placeholders, 5, "unrelated entries must survive");
}
}

View File

@@ -1,16 +1,14 @@
use super::{OutOfBand, UserAgentConnection}; use super::{OutOfBand, OperatorConnection};
use crate::{ use crate::{
actors::{ actors::{
flow_coordinator::client_connect_approval::ClientApprovalController, flow_coordinator::{GetConnectedClientIds, client_connect_approval::{ClientApprovalAnswer, ClientApprovalController}}, operator_registry::ConnectOperator,
useragent_registry::ConnectUseragent, }, peers::client::ClientProfile,
},
peers::client::ClientProfile,
}; };
use arbiter_crypto::authn; use arbiter_crypto::authn;
use arbiter_proto::transport::Sender; use arbiter_proto::transport::Sender;
use kameo::{Actor, actor::ActorRef, messages}; use kameo::{Actor, actor::ActorRef, messages};
use std::{borrow::Cow, collections::HashMap}; use std::{borrow::Cow, collections::{HashMap, HashSet}};
use thiserror::Error; use thiserror::Error;
use tracing::error; use tracing::error;
@@ -49,27 +47,32 @@ pub struct PendingClientApproval {
controller: ActorRef<ClientApprovalController>, controller: ActorRef<ClientApprovalController>,
} }
pub struct UserAgentSession { pub struct OperatorSession {
props: UserAgentConnection, props: OperatorConnection,
sender: Box<dyn Sender<OutOfBand>>, sender: Box<dyn Sender<OutOfBand>>,
pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>, pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>,
/// DB `client_ids` this operator session is allowed to sign for.
/// Seeded from currently-connected clients on start, then updated as
/// approvals are granted or denied during the session lifetime.
approved_client_ids: HashSet<i32>,
} }
pub mod handlers; pub mod handlers;
impl UserAgentSession { impl OperatorSession {
pub(crate) fn new(props: UserAgentConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self { pub(crate) fn new(props: OperatorConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self {
Self { Self {
props, props,
sender, sender,
pending_client_approvals: Default::default(), pending_client_approvals: HashMap::default(),
approved_client_ids: HashSet::default(),
} }
} }
} }
#[messages] #[messages]
impl UserAgentSession { impl OperatorSession {
#[message] #[message]
pub async fn begin_new_client_approval( pub async fn begin_new_client_approval(
&mut self, &mut self,
@@ -85,9 +88,10 @@ impl UserAgentSession {
{ {
error!( error!(
?e, ?e,
actor = "user_agent", actor = "operator",
event = "failed to announce new client connection" event = "failed to announce new client connection"
); );
let _ = controller.tell(ClientApprovalAnswer { approved: false }).await;
return; return;
} }
@@ -101,26 +105,36 @@ impl UserAgentSession {
} }
} }
impl Actor for UserAgentSession { impl Actor for OperatorSession {
type Args = Self; type Args = Self;
type Error = Error; type Error = Error;
async fn on_start(args: Self::Args, this: ActorRef<Self>) -> Result<Self, Self::Error> { async fn on_start(mut args: Self::Args, this: ActorRef<Self>) -> Result<Self, Self::Error> {
args.props args.props
.actors .actors
.useragent_registry .operator_registry
.ask(ConnectUseragent { .ask(ConnectOperator {
actor: this.clone(), actor: this.clone(),
}) })
.await .await
.map_err(|err| { .map_err(|err| {
error!( error!(
?err, ?err,
"Failed to register user agent connection with user agent registry" "Failed to register operator connection with operator registry"
); );
Error::internal("Failed to register user agent connection with user agent registry") Error::internal("Failed to register operator connection with operator registry")
})?; })?;
// Seed approved set with clients already connected when this session starts.
// New clients will be added via handle_new_client_approve as they are approved.
match args.props.actors.flow_coordinator.ask(GetConnectedClientIds {}).await {
Ok(ids) => args.approved_client_ids.extend(ids),
Err(err) => {
error!(?err, "Failed to fetch connected client IDs on operator session start");
}
}
Ok(args) Ok(args)
} }
@@ -149,7 +163,7 @@ impl Actor for UserAgentSession {
{ {
error!( error!(
?e, ?e,
actor = "user_agent", actor = "operator",
event = "failed to announce client connection cancellation" event = "failed to announce client connection cancellation"
); );
} }

View File

@@ -3,8 +3,9 @@ use crate::{
actors::{ actors::{
GlobalActors, GlobalActors,
vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events}, vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events},
vault_coordinator::{self, ContributeBootstrap, ContributeUnseal, StartBootstrap},
}, },
crypto::integrity::{self}, crypto::{KeyCell, integrity},
db::DatabasePool, db::DatabasePool,
}; };
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
@@ -25,17 +26,29 @@ pub enum Error {
AlreadyBootstrapped, AlreadyBootstrapped,
#[error("Invalid key provided")] #[error("Invalid key provided")]
InvalidKey, InvalidKey,
#[error("Vault locked: too many failed unseal attempts")]
LockedOut,
#[error("State transition failed")] #[error("State transition failed")]
State, State,
#[error("Vault ceremony failed: {0}")]
Ceremony(#[from] vault_coordinator::Error),
#[error("Internal error: {0}")] #[error("Internal error: {0}")]
Internal(String), Internal(String),
} }
impl Error { impl Error {
fn internal(message: impl Into<String>) -> Self { fn internal(message: impl Into<String>) -> Self {
Self::Internal(message.into()) Self::Internal(message.into())
} }
/// Preserve the coordinator's own error so the operator learns why a
/// ceremony was refused instead of reading "internal error".
fn ceremony<M>(error: SendError<M, vault_coordinator::Error>) -> Self {
match error {
SendError::HandlerError(inner) => Self::Ceremony(inner),
_ => Self::internal("VaultCoordinator unavailable"),
}
}
} }
pub struct HandshakeResponse { pub struct HandshakeResponse {
@@ -69,7 +82,6 @@ impl VaultGate {
impl Actor for VaultGate { impl Actor for VaultGate {
type Args = Self; type Args = Self;
type Error = (); type Error = ();
async fn on_start( async fn on_start(
@@ -100,11 +112,8 @@ impl VaultGate {
associated_data: &[u8], associated_data: &[u8],
) -> Result<SafeCell<Vec<u8>>, ()> { ) -> Result<SafeCell<Vec<u8>>, ()> {
let nonce = XNonce::from_slice(nonce); let nonce = XNonce::from_slice(nonce);
let cipher = XChaCha20Poly1305::new(secret.as_bytes().into()); let cipher = XChaCha20Poly1305::new(secret.as_bytes().into());
let mut key_buffer = SafeCell::new(ciphertext.to_vec()); let mut key_buffer = SafeCell::new(ciphertext.to_vec());
let decryption_result = key_buffer.write_inline(|write_handle| { let decryption_result = key_buffer.write_inline(|write_handle| {
cipher.decrypt_in_place(nonce, associated_data, write_handle) cipher.decrypt_in_place(nonce, associated_data, write_handle)
}); });
@@ -117,9 +126,13 @@ impl VaultGate {
} }
} }
} }
fn key_cell(buffer: SafeCell<Vec<u8>>) -> Result<KeyCell, Error> {
KeyCell::try_from(buffer).map_err(|()| Error::InvalidKey)
}
} }
#[messages(messages = Inbound, replies = Outbound)] #[messages]
impl VaultGate { impl VaultGate {
#[message] #[message]
pub fn handle_handshake( pub fn handle_handshake(
@@ -128,14 +141,11 @@ impl VaultGate {
) -> Result<HandshakeResponse, Error> { ) -> Result<HandshakeResponse, Error> {
let ephemeral_secret = EphemeralSecret::random(); let ephemeral_secret = EphemeralSecret::random();
let public_key = PublicKey::from(&ephemeral_secret); let public_key = PublicKey::from(&ephemeral_secret);
let secret = ephemeral_secret.diffie_hellman(&client_pubkey); let secret = ephemeral_secret.diffie_hellman(&client_pubkey);
self.state = State::ReadyForExchange { self.state = State::ReadyForExchange {
server_key: public_key, server_key: public_key,
secret, secret,
}; };
Ok(HandshakeResponse { Ok(HandshakeResponse {
server_pubkey: public_key, server_pubkey: public_key,
}) })
@@ -151,25 +161,17 @@ impl VaultGate {
let State::ReadyForExchange { secret, .. } = &self.state else { let State::ReadyForExchange { secret, .. } = &self.state else {
return Err(Error::State); return Err(Error::State);
}; };
let seal_key = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
.map_err(|()| Error::InvalidKey)
.and_then(Self::key_cell)?;
let Ok(seal_key_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) match self.actors.vault.ask(TryUnseal { seal_key }).await {
else {
return Err(Error::InvalidKey);
};
match self
.actors
.vault
.ask(TryUnseal {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(()) => { Ok(()) => {
info!("Successfully unsealed key with client-provided key"); info!("Successfully unsealed key with client-provided key");
Ok(()) Ok(())
} }
Err(SendError::HandlerError(vault::Error::InvalidKey)) => Err(Error::InvalidKey), Err(SendError::HandlerError(vault::Error::InvalidKey)) => Err(Error::InvalidKey),
Err(SendError::HandlerError(vault::Error::LockedOut)) => Err(Error::LockedOut),
Err(SendError::HandlerError(err)) => { Err(SendError::HandlerError(err)) => {
error!(?err, "Vault failed to unseal key"); error!(?err, "Vault failed to unseal key");
Err(Error::InvalidKey) Err(Error::InvalidKey)
@@ -191,17 +193,16 @@ impl VaultGate {
let State::ReadyForExchange { secret, .. } = &self.state else { let State::ReadyForExchange { secret, .. } = &self.state else {
return Err(Error::State); return Err(Error::State);
}; };
let seal_key = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
let Ok(seal_key_buffer) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) .map_err(|()| Error::InvalidKey)
else { .and_then(Self::key_cell)?;
return Err(Error::InvalidKey);
};
match self match self
.actors .actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key_raw: seal_key_buffer, seal_key,
custody: None,
}) })
.await .await
{ {
@@ -225,14 +226,53 @@ impl VaultGate {
#[message] #[message]
pub async fn handle_vault_state(&mut self) -> Result<VaultState, Error> { pub async fn handle_vault_state(&mut self) -> Result<VaultState, Error> {
let answer = self self.actors
.actors
.vault .vault
.ask(GetState {}) .ask(GetState {})
.await .await
.map_err(|_| Error::internal("failed to query vault"))?; .map_err(|_| Error::internal("failed to query vault"))
}
Ok(answer) #[message]
pub async fn handle_declare_committee(&mut self, count: usize) -> Result<(), Error> {
self.actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: self.auth_creds.id,
declared_count: count,
})
.await
.map_err(Error::ceremony)
}
#[message]
pub async fn handle_contribute_bootstrap_passphrase(
&mut self,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
self.actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: self.auth_creds.id,
passphrase: SafeCell::new(passphrase),
})
.await
.map_err(Error::ceremony)
}
#[message]
pub async fn handle_contribute_unseal_passphrase(
&mut self,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
self.actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: self.auth_creds.id,
passphrase: SafeCell::new(passphrase),
})
.await
.map_err(Error::ceremony)
} }
} }
@@ -286,3 +326,75 @@ impl Message<events::Unsealed> for VaultGate {
ctx.stop(); ctx.stop();
} }
} }
pub enum Inbound {
HandleHandshake(HandleHandshake),
HandleUnsealEncryptedKey(HandleUnsealEncryptedKey),
HandleBootstrapEncryptedKey(HandleBootstrapEncryptedKey),
HandleVaultState,
HandleDeclareCommittee(HandleDeclareCommittee),
HandleContributeBootstrapPassphrase(HandleContributeBootstrapPassphrase),
HandleContributeUnsealPassphrase(HandleContributeUnsealPassphrase),
}
pub enum Outbound {
HandleHandshake(Result<HandshakeResponse, Error>),
HandleUnsealEncryptedKey(Result<(), Error>),
HandleBootstrapEncryptedKey(Result<(), Error>),
HandleVaultState(Result<VaultState, Error>),
HandleDeclareCommittee(Result<(), Error>),
HandleContributeBootstrapPassphrase(Result<bool, Error>),
HandleContributeUnsealPassphrase(Result<bool, Error>),
}
impl Message<Inbound> for VaultGate {
type Reply = Result<Outbound, Error>;
async fn handle(
&mut self,
msg: Inbound,
_ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
) -> Self::Reply {
match msg {
Inbound::HandleHandshake(message) => Ok(Outbound::HandleHandshake(
self.handle_handshake(message.client_pubkey),
)),
Inbound::HandleUnsealEncryptedKey(message) => Ok(Outbound::HandleUnsealEncryptedKey(
self.handle_unseal_encrypted_key(
message.nonce,
message.ciphertext,
message.associated_data,
)
.await,
)),
Inbound::HandleBootstrapEncryptedKey(message) => {
Ok(Outbound::HandleBootstrapEncryptedKey(
self.handle_bootstrap_encrypted_key(
message.nonce,
message.ciphertext,
message.associated_data,
)
.await,
))
}
Inbound::HandleVaultState => {
Ok(Outbound::HandleVaultState(self.handle_vault_state().await))
}
Inbound::HandleDeclareCommittee(message) => Ok(Outbound::HandleDeclareCommittee(
self.handle_declare_committee(message.count).await,
)),
Inbound::HandleContributeBootstrapPassphrase(message) => {
Ok(Outbound::HandleContributeBootstrapPassphrase(
self.handle_contribute_bootstrap_passphrase(message.passphrase)
.await,
))
}
Inbound::HandleContributeUnsealPassphrase(message) => {
Ok(Outbound::HandleContributeUnsealPassphrase(
self.handle_contribute_unseal_passphrase(message.passphrase)
.await,
))
}
}
}
}

View File

@@ -1,278 +0,0 @@
use super::{Error, UserAgentSession};
use crate::{
actors::evm::{
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
UseragentCreateGrant, UseragentListGrants,
},
actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer,
actors::vault::VaultState,
db::models::{EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata},
evm::policies::{Grant, SpecificGrant},
};
use arbiter_crypto::authn;
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{error::SendError, messages, prelude::Context};
use tracing::error;
#[derive(Debug, Error)]
pub enum SignTransactionError {
#[error("Policy evaluation failed")]
Vet(#[from] crate::evm::VetError),
#[error("Internal signing error")]
Internal,
}
#[derive(Debug, Error)]
pub enum GrantMutationError {
#[error("Vault is sealed")]
VaultSealed,
#[error("Internal grant mutation error")]
Internal,
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<VaultState, Error> {
use crate::actors::vault::GetState;
let vault_state = match self.props.actors.vault.ask(GetState {}).await {
Ok(state) => state,
Err(err) => {
error!(?err, actor = "useragent", "vault.query.failed");
return Err(Error::internal("Vault is in broken state"));
}
};
Ok(vault_state)
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
match self.props.actors.evm.ask(Generate {}).await {
Ok(address) => Ok(address),
Err(SendError::HandlerError(err)) => Err(Error::internal(format!(
"EVM wallet generation failed: {err}"
))),
Err(err) => {
error!(?err, "EVM actor unreachable during wallet create");
Err(Error::internal("EVM actor unreachable"))
}
}
}
#[message]
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<(i32, Address)>, Error> {
match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => Ok(wallets),
Err(err) => {
error!(?err, "EVM wallet list failed");
Err(Error::internal("Failed to list EVM wallets"))
}
}
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.props.actors.evm.ask(UseragentListGrants {}).await {
Ok(grants) => Ok(grants),
Err(err) => {
error!(?err, "EVM grant list failed");
Err(Error::internal("Failed to list EVM grants"))
}
}
}
#[message]
pub(crate) async fn handle_grant_create(
&mut self,
basic: crate::evm::policies::SharedGrantSettings,
grant: SpecificGrant,
) -> Result<i32, GrantMutationError> {
match self
.props
.actors
.evm
.ask(UseragentCreateGrant { basic, grant })
.await
{
Ok(grant_id) => Ok(grant_id),
Err(err) => {
error!(?err, "EVM grant create failed");
Err(GrantMutationError::Internal)
}
}
}
#[message]
pub(crate) fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), GrantMutationError> {
// match self
// .props
// .actors
// .evm
// .ask(UseragentDeleteGrant { grant_id })
// .await
// {
// Ok(()) => Ok(()),
// Err(err) => {
// error!(?err, "EVM grant delete failed");
// Err(GrantMutationError::Internal)
// }
// }
let _ = grant_id;
todo!()
}
#[message]
pub(crate) async fn handle_sign_transaction(
&mut self,
client_id: i32,
wallet_address: Address,
transaction: TxEip1559,
) -> Result<Signature, SignTransactionError> {
match self
.props
.actors
.evm
.ask(ClientSignTransaction {
client_id,
wallet_address,
transaction,
})
.await
{
Ok(signature) => Ok(signature),
Err(SendError::HandlerError(EvmSignError::Vet(vet_error))) => {
Err(SignTransactionError::Vet(vet_error))
}
Err(err) => {
error!(?err, "EVM sign transaction failed in user-agent session");
Err(SignTransactionError::Internal)
}
}
}
#[message]
pub(crate) async fn handle_grant_evm_wallet_access(
&mut self,
entries: Vec<NewEvmWalletAccess>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::insert_into(evm_wallet_access::table)
.values(&entry)
.on_conflict_do_nothing()
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_revoke_evm_wallet_access(
&mut self,
entries: Vec<i32>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::wallet_id.eq(entry))
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_list_wallet_access(
&mut self,
) -> Result<Vec<EvmWalletAccess>, Error> {
let mut conn = self.props.db.get().await?;
use crate::db::schema::evm_wallet_access;
let access_entries = evm_wallet_access::table
.select(EvmWalletAccess::as_select())
.load::<_>(&mut conn)
.await?;
Ok(access_entries)
}
}
#[messages]
impl UserAgentSession {
#[message(ctx)]
pub(crate) async fn handle_new_client_approve(
&mut self,
approved: bool,
pubkey: authn::PublicKey,
ctx: &mut Context<Self, Result<(), Error>>,
) -> Result<(), Error> {
let Some(pending_approval) = self.pending_client_approvals.remove(&pubkey.to_bytes())
else {
error!("Received client connection response for unknown client");
return Err(Error::internal("Unknown client in connection response"));
};
pending_approval
.controller
.tell(ClientApprovalAnswer { approved })
.await
.map_err(|err| {
error!(
?err,
"Failed to send client approval response to controller"
);
Error::internal("Failed to send client approval response to controller")
})?;
ctx.actor_ref().unlink(&pending_approval.controller).await;
Ok(())
}
#[message]
pub(crate) async fn handle_sdk_client_list(
&mut self,
) -> Result<Vec<(ProgramClient, ProgramClientMetadata)>, Error> {
use crate::db::schema::{client_metadata, program_client};
let mut conn = self.props.db.get().await?;
let clients = program_client::table
.inner_join(client_metadata::table)
.select((
ProgramClient::as_select(),
ProgramClientMetadata::as_select(),
))
.load::<(ProgramClient, ProgramClientMetadata)>(&mut conn)
.await?;
Ok(clients)
}
}

View File

@@ -1,15 +1,12 @@
use super::common::ChannelTransport; use super::common::ChannelTransport;
use arbiter_crypto::{ use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT};
authn::{self, AuthChallenge, CLIENT_CONTEXT},
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, ClientMetadata,
transport::{Receiver, Sender}, transport::{Receiver, Sender},
}; };
use arbiter_server::{ use arbiter_server::{
actors::{GlobalActors, vault::Bootstrap}, actors::{GlobalActors, vault::Bootstrap},
crypto::integrity, crypto::{KeyCell, integrity},
db::{self, schema}, db::{self, schema},
peers::client::{ClientConnection, ClientCredentials, auth, connect_client}, peers::client::{ClientConnection, ClientCredentials, auth, connect_client},
}; };
@@ -79,28 +76,29 @@ fn sign_client_challenge(key: &SigningKey<MlDsa87>, challenge: &AuthChallenge) -
.into() .into()
} }
async fn insert_bootstrap_sentinel_useragent(db: &db::DatabasePool) { async fn insert_bootstrap_sentinel_operator(db: &db::DatabasePool) {
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let sentinel_key = verifying_key(&MlDsa87::key_gen(&mut rand::rng())) let sentinel_key = verifying_key(&MlDsa87::key_gen(&mut rand::rng()))
.encode() .encode()
.0 .0
.to_vec(); .to_vec();
insert_into(schema::useragent_client::table) insert_into(schema::operator_identity::table)
.values((schema::useragent_client::public_key.eq(sentinel_key),)) .values((schema::operator_identity::public_key.eq(sentinel_key),))
.execute(&mut conn) .execute(&mut conn)
.await .await
.unwrap(); .unwrap();
} }
async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors { async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors {
insert_bootstrap_sentinel_useragent(db).await; insert_bootstrap_sentinel_operator(db).await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap(); let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), seal_key: KeyCell::from([0u8; 32]),
custody: None,
}) })
.await .await
.unwrap(); .unwrap();
@@ -266,7 +264,7 @@ pub async fn metadata_unchanged_does_not_append_history() {
#[tokio::test] #[tokio::test]
#[test_log::test] #[test_log::test]
pub async fn metadata_change_appends_history_and_repoints_binding() { pub async fn metadata_frozen_after_approval_ignores_reconnect_changes() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await; let actors = spawn_test_actors(&db).await;
let new_key = MlDsa87::key_gen(&mut rand::rng()); let new_key = MlDsa87::key_gen(&mut rand::rng());
@@ -287,6 +285,7 @@ pub async fn metadata_change_appends_history_and_repoints_binding() {
connect_client(props, &mut server_transport).await; connect_client(props, &mut server_transport).await;
}); });
// Reconnect presenting different metadata — must be silently ignored.
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(), pubkey: verifying_key(&new_key).into(),
@@ -313,6 +312,7 @@ pub async fn metadata_change_appends_history_and_repoints_binding() {
client_metadata, client_metadata_history, program_client, client_metadata, client_metadata_history, program_client,
}; };
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
// Metadata is frozen: no new row, no history entry.
let metadata_count: i64 = client_metadata::table let metadata_count: i64 = client_metadata::table
.count() .count()
.get_result(&mut conn) .get_result(&mut conn)
@@ -338,15 +338,19 @@ pub async fn metadata_change_appends_history_and_repoints_binding() {
.first::<(String, Option<String>, Option<String>)>(&mut conn) .first::<(String, Option<String>, Option<String>)>(&mut conn)
.await .await
.unwrap(); .unwrap();
assert_eq!(metadata_count, 2); assert_eq!(
assert_eq!(history_count, 1); metadata_count, 1,
"frozen: no new metadata row on reconnect"
);
assert_eq!(history_count, 0, "frozen: no history entry on reconnect");
assert_eq!( assert_eq!(
current, current,
( (
"client".to_owned(), "client".to_owned(),
Some("new".to_owned()), Some("old".to_owned()),
Some("2.0.0".to_owned()) Some("1.0.0".to_owned())
) ),
"frozen: original metadata must be preserved"
); );
} }
} }

View File

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

View File

@@ -0,0 +1,6 @@
mod common;
#[path = "operator/auth.rs"]
mod auth;
#[path = "operator/unseal.rs"]
mod unseal;

View File

@@ -1,14 +1,11 @@
use super::common::ChannelTransport; use super::common::ChannelTransport;
use arbiter_crypto::{ use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT};
authn::{self, AuthChallenge, USERAGENT_CONTEXT},
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_proto::transport::{Error as TransportError, Receiver, Sender}; use arbiter_proto::transport::{Error as TransportError, Receiver, Sender};
use arbiter_server::{ use arbiter_server::{
actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap}, actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap},
crypto::integrity, crypto::{KeyCell, integrity},
db::{self, schema}, db::{self, models::OperatorId, schema},
peers::user_agent::{self, Credentials, UserAgentConnection, auth, vault_gate}, peers::operator::{self, Credentials, OperatorConnection, auth, vault_gate},
}; };
use async_trait::async_trait; use async_trait::async_trait;
@@ -21,13 +18,13 @@ fn verifying_key(key: &SigningKey<MlDsa87>) -> VerifyingKey<MlDsa87> {
<SigningKey<MlDsa87> as Keypair>::verifying_key(key) <SigningKey<MlDsa87> as Keypair>::verifying_key(key)
} }
fn sign_useragent_challenge( fn sign_operator_challenge(
key: &SigningKey<MlDsa87>, key: &SigningKey<MlDsa87>,
challenge: &AuthChallenge, challenge: &AuthChallenge,
) -> authn::Signature { ) -> authn::Signature {
let challenge = challenge.format(); let challenge = challenge.format();
key.signing_key() key.signing_key()
.sign_deterministic(&challenge, USERAGENT_CONTEXT) .sign_deterministic(&challenge, OPERATOR_CONTEXT)
.unwrap() .unwrap()
.into() .into()
} }
@@ -41,8 +38,8 @@ fn tamper_challenge(challenge: &AuthChallenge) -> AuthChallenge {
struct NullOobSender; struct NullOobSender;
#[async_trait] #[async_trait]
impl Sender<user_agent::OutOfBand> for NullOobSender { impl Sender<operator::OutOfBand> for NullOobSender {
async fn send(&mut self, _item: user_agent::OutOfBand) -> Result<(), TransportError> { async fn send(&mut self, _item: operator::OutOfBand) -> Result<(), TransportError> {
Ok(()) Ok(())
} }
} }
@@ -154,19 +151,12 @@ impl Sender<auth::Inbound> for StartTestTransport {
pub async fn bootstrap_token_auth() { pub async fn bootstrap_token_auth() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap(); let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.vault
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap(); let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
let (mut server_transport, mut test_transport) = ChannelTransport::new(); let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone(); let db_for_task = db.clone();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors); let mut props = OperatorConnection::new(db_for_task, actors);
auth::authenticate(&mut props, &mut server_transport).await auth::authenticate(&mut props, &mut server_transport).await
}); });
@@ -174,7 +164,7 @@ pub async fn bootstrap_token_auth() {
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(), pubkey: verifying_key(&new_key).into(),
bootstrap_token: Some(token), bootstrap_token: Some(token.into_bytes()),
}) })
.await .await
.unwrap(); .unwrap();
@@ -188,7 +178,7 @@ pub async fn bootstrap_token_auth() {
other => panic!("Expected AuthChallenge, got {other:?}"), other => panic!("Expected AuthChallenge, got {other:?}"),
}; };
let signature = sign_useragent_challenge(&new_key, &challenge); let signature = sign_operator_challenge(&new_key, &challenge);
test_transport test_transport
.send(auth::Inbound::AuthChallengeSolution { .send(auth::Inbound::AuthChallengeSolution {
@@ -206,8 +196,8 @@ pub async fn bootstrap_token_auth() {
task.await.unwrap().unwrap(); task.await.unwrap().unwrap();
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let stored_pubkey: Vec<u8> = schema::useragent_client::table let stored_pubkey: Vec<u8> = schema::operator_identity::table
.select(schema::useragent_client::public_key) .select(schema::operator_identity::public_key)
.first::<Vec<u8>>(&mut conn) .first::<Vec<u8>>(&mut conn)
.await .await
.unwrap(); .unwrap();
@@ -223,7 +213,7 @@ pub async fn bootstrap_invalid_token_auth() {
let (mut server_transport, mut test_transport) = ChannelTransport::new(); let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone(); let db_for_task = db.clone();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors); let mut props = OperatorConnection::new(db_for_task, actors);
auth::authenticate(&mut props, &mut server_transport).await auth::authenticate(&mut props, &mut server_transport).await
}); });
@@ -231,7 +221,7 @@ pub async fn bootstrap_invalid_token_auth() {
test_transport test_transport
.send(auth::Inbound::AuthChallengeRequest { .send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(), pubkey: verifying_key(&new_key).into(),
bootstrap_token: Some("invalid_token".to_owned()), bootstrap_token: Some(b"invalid_token".to_vec()),
}) })
.await .await
.unwrap(); .unwrap();
@@ -245,7 +235,7 @@ pub async fn bootstrap_invalid_token_auth() {
other => panic!("Expected AuthChallenge, got {other:?}"), other => panic!("Expected AuthChallenge, got {other:?}"),
}; };
let signature = sign_useragent_challenge(&new_key, &challenge); let signature = sign_operator_challenge(&new_key, &challenge);
test_transport test_transport
.send(auth::Inbound::AuthChallengeSolution { .send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes(), signature: signature.to_bytes(),
@@ -259,7 +249,7 @@ pub async fn bootstrap_invalid_token_auth() {
)); ));
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let count: i64 = schema::useragent_client::table let count: i64 = schema::operator_identity::table
.count() .count()
.get_result::<i64>(&mut conn) .get_result::<i64>(&mut conn)
.await .await
@@ -275,7 +265,8 @@ pub async fn challenge_auth() {
actors actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), seal_key: KeyCell::from([0u8; 32]),
custody: None,
}) })
.await .await
.unwrap(); .unwrap();
@@ -285,10 +276,10 @@ pub async fn challenge_auth() {
{ {
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let id: i32 = insert_into(schema::useragent_client::table) let id: OperatorId = insert_into(schema::operator_identity::table)
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),)) .values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::useragent_client::id) .returning(schema::operator_identity::id)
.get_result(&mut conn) .get_result::<OperatorId>(&mut conn)
.await .await
.unwrap(); .unwrap();
integrity::sign_entity( integrity::sign_entity(
@@ -307,7 +298,7 @@ pub async fn challenge_auth() {
let (mut server_transport, mut test_transport) = ChannelTransport::new(); let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone(); let db_for_task = db.clone();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors); let mut props = OperatorConnection::new(db_for_task, actors);
auth::authenticate(&mut props, &mut server_transport).await auth::authenticate(&mut props, &mut server_transport).await
}); });
@@ -331,7 +322,7 @@ pub async fn challenge_auth() {
Err(err) => panic!("Expected Ok response, got Err({err:?})"), Err(err) => panic!("Expected Ok response, got Err({err:?})"),
}; };
let signature = sign_useragent_challenge(&new_key, &challenge); let signature = sign_operator_challenge(&new_key, &challenge);
test_transport test_transport
.send(auth::Inbound::AuthChallengeSolution { .send(auth::Inbound::AuthChallengeSolution {
@@ -361,7 +352,8 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
actors actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), seal_key: KeyCell::from([0u8; 32]),
custody: None,
}) })
.await .await
.unwrap(); .unwrap();
@@ -371,8 +363,8 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
{ {
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table) insert_into(schema::operator_identity::table)
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),)) .values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
.execute(&mut conn) .execute(&mut conn)
.await .await
.unwrap(); .unwrap();
@@ -381,8 +373,8 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
let (server_transport, mut test_transport) = start_transport_pair(); let (server_transport, mut test_transport) = start_transport_pair();
let db_for_task = db.clone(); let db_for_task = db.clone();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors); let mut props = OperatorConnection::new(db_for_task, actors);
user_agent::start(&mut props, server_transport, Box::new(NullOobSender)).await operator::start(&mut props, server_transport, Box::new(NullOobSender)).await
}); });
test_transport test_transport
@@ -400,12 +392,12 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
let challenge = match response { let challenge = match response {
Ok(resp) => match resp { Ok(resp) => match resp {
auth::Outbound::AuthChallenge { challenge } => challenge, auth::Outbound::AuthChallenge { challenge } => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"), other @ auth::Outbound::AuthSuccess => panic!("Expected AuthChallenge, got {other:?}"),
}, },
Err(err) => panic!("Expected Ok response, got Err({err:?})"), Err(err) => panic!("Expected Ok response, got Err({err:?})"),
}; };
let signature = sign_useragent_challenge(&new_key, &challenge); let signature = sign_operator_challenge(&new_key, &challenge);
test_transport test_transport
.send(auth::Inbound::AuthChallengeSolution { .send(auth::Inbound::AuthChallengeSolution {
@@ -422,7 +414,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
assert!(matches!( assert!(matches!(
task.await.unwrap(), task.await.unwrap(),
Err(user_agent::Error::Internal(_)) Err(operator::Error::Internal(_))
)); ));
} }
@@ -434,7 +426,8 @@ pub async fn challenge_auth_rejects_invalid_signature() {
actors actors
.vault .vault
.ask(Bootstrap { .ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()), seal_key: KeyCell::from([0u8; 32]),
custody: None,
}) })
.await .await
.unwrap(); .unwrap();
@@ -444,10 +437,10 @@ pub async fn challenge_auth_rejects_invalid_signature() {
{ {
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
let id: i32 = insert_into(schema::useragent_client::table) let id: OperatorId = insert_into(schema::operator_identity::table)
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),)) .values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::useragent_client::id) .returning(schema::operator_identity::id)
.get_result(&mut conn) .get_result::<OperatorId>(&mut conn)
.await .await
.unwrap(); .unwrap();
integrity::sign_entity( integrity::sign_entity(
@@ -466,7 +459,7 @@ pub async fn challenge_auth_rejects_invalid_signature() {
let (mut server_transport, mut test_transport) = ChannelTransport::new(); let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone(); let db_for_task = db.clone();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors); let mut props = OperatorConnection::new(db_for_task, actors);
auth::authenticate(&mut props, &mut server_transport).await auth::authenticate(&mut props, &mut server_transport).await
}); });
@@ -490,7 +483,7 @@ pub async fn challenge_auth_rejects_invalid_signature() {
Err(err) => panic!("Expected Ok response, got Err({err:?})"), Err(err) => panic!("Expected Ok response, got Err({err:?})"),
}; };
let signature = sign_useragent_challenge(&new_key, &tamper_challenge(&challenge)); let signature = sign_operator_challenge(&new_key, &tamper_challenge(&challenge));
test_transport test_transport
.send(auth::Inbound::AuthChallengeSolution { .send(auth::Inbound::AuthChallengeSolution {
@@ -506,3 +499,92 @@ pub async fn challenge_auth_rejects_invalid_signature() {
Err(auth::Error::InvalidChallengeSolution) Err(auth::Error::InvalidChallengeSolution)
)); ));
} }
/// The bootstrap token authorises registering committee members *before* the
/// vault exists. Once any bootstrap path succeeds it must stop working, or its
/// holder could keep minting operator identities until the next restart.
#[tokio::test]
#[test_log::test]
pub async fn bootstrap_token_rejected_after_bootstrap() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
actors
.vault
.ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]),
custody: None,
})
.await
.unwrap();
// `Bootstrapped` travels through the message bus, so the token disappears
// a couple of actor turns after the bootstrap call returns.
let mut retired = false;
for _ in 0..100 {
if actors.bootstrapper.ask(GetToken).await.unwrap().is_none() {
retired = true;
break;
}
tokio::task::yield_now().await;
}
assert!(
retired,
"the bootstrap token must be retired once the vault is bootstrapped"
);
let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = OperatorConnection::new(db_for_task, actors);
auth::authenticate(&mut props, &mut server_transport).await
});
let new_key = MlDsa87::key_gen(&mut rand::rng());
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(),
bootstrap_token: Some(token.into_bytes()),
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive challenge");
let challenge = match response {
Ok(auth::Outbound::AuthChallenge { challenge }) => challenge,
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = sign_operator_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes(),
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive auth result");
assert!(
matches!(response, Err(auth::Error::InvalidBootstrapToken)),
"a spent bootstrap token must not authorise a new identity, got {response:?}"
);
assert!(task.await.unwrap().is_err(), "authentication must fail");
let mut conn = db.get().await.unwrap();
let registered: i64 = schema::operator_identity::table
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(
registered, 0,
"no identity may be registered after the bootstrap"
);
}

View File

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

View File

@@ -1,6 +0,0 @@
mod common;
#[path = "user_agent/auth.rs"]
mod auth;
#[path = "user_agent/unseal.rs"]
mod unseal;

View File

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

View File

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

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