Compare commits

..

35 Commits

Author SHA1 Message Date
CleverWild
a501283b0c refactor(crypto): replace byte-slice signing contexts with SigningContext enum 2026-08-26 14:08:32 +02:00
CleverWild
f881102f0a refactor(proposal): drop the expired status, enforce expiry on every vote 2026-08-26 13:01:44 +02:00
CleverWild
f13c1cf9d1 fix(db): require share_salt to be supplied by the daemon 2026-08-26 13:01:44 +02:00
CleverWild
f03997ea56 chore(lints): fix regression caused by rust 1.98.0 2026-08-26 13:01:44 +02:00
CleverWild
957c5096df chore(deps): bump kameo version 2026-08-26 13:01:44 +02:00
CleverWild
36249129d1 feat(vault)!: implement full Shamir re-key flow and governance execution (§3.3–§3.5)
- Add `rekey.proto` with `ContributePassphrase` / `ContributeRecoveryPassphrase` / `RekeyResult`
- Wire `rekey` as a 4th vault stream payload in `vault.proto` and gRPC dispatch
- Add `RekeyRootKey` message to `Vault` actor: generates new random seal key, re-encrypts root key, writes new `root_key_history` row
- Add `StartRekey`, `ContributeRekey`, `ContributeRecoveryRekey` messages to `VaultCoordinator`; `finalize_rekey` uses threshold-1 fast path identical to bootstrap
- `execute_replace_operator` now UPDATEs `operator_identity.public_key` in-place (avoids FK constraint violation), deletes stale `operator` share row, then triggers `StartRekey`
- `execute_update_shamir_parameters` triggers `StartRekey` instead of warning stub
- `ProposalKind::ReplaceOperator` carries `old_operator_id`; encode/decode updated accordingly
- `GlobalActors::spawn` extracts `vault_coordinator` before `Ok(Self { … })` so it can be cloned into `ProposalManager::new`
- Add `handle_rekey` in session handlers forwarding passphrase contributions to `VaultCoordinator`
- Fix test: rename `replace_operator_inserts_identity_row` → `replace_operator_updates_pubkey_and_starts_rekey`, assert count stays 1 and pubkey is updated
2026-08-26 13:01:44 +02:00
CleverWild
3817a080c9 refactor(proposal): replace string kind dispatch with ProposalKindTag enum (strum) 2026-08-26 13:01:44 +02:00
CleverWild
e1d060dd06 feat(vault): add recovery passphrase handling for bootstrap and unseal processes 2026-08-26 13:01:44 +02:00
CleverWild
e121708d28 fix(crypto): handle 1-of-N Shamir split when ordinary_count=1 2026-08-26 13:01:44 +02:00
CleverWild
6e3fa736e0 feat(server): recovery operators with sleeping/wakeup mechanism (§3.5/§3.6) 2026-08-26 13:01:44 +02:00
CleverWild
19a62e7195 feat(server): key-rotation proposals require full quorum (§3.3) 2026-08-26 13:01:44 +02:00
CleverWild
57200cbc50 feat(server): two-operator vault requires at least one recovery share 2026-08-26 13:01:44 +02:00
CleverWild
291ef2e831 refactor(server): typed pubkey len via u32::try_from in ReplaceOperator 2026-08-26 13:01:44 +02:00
CleverWild
d12109c2c9 feat(server): ProposalKind::ApproveOneOffTransaction 2026-08-26 13:01:44 +02:00
CleverWild
277fb3c92d feat(server): ProposalKind::ApprovePersistentGrant 2026-08-26 13:01:44 +02:00
CleverWild
9e42097683 feat(server): ProposalKind::UpdateShamirParameters 2026-08-26 13:01:44 +02:00
CleverWild
25b86b14e9 feat(server): ProposalKind::ReplaceOperator 2026-08-26 13:01:44 +02:00
CleverWild
ba8748a17b feat(server): ProposalKind ::GrantWalletAccess and ::ApproveServerUpdate 2026-08-26 13:01:44 +02:00
CleverWild
3e61d807b4 test(server): governance integration tests 2026-08-26 13:01:44 +02:00
CleverWild
e6459aade8 feat(server::grpc): wire governance RPCs through operator session 2026-08-26 13:01:44 +02:00
CleverWild
f16d0a26e2 feat(server): introduce ProposalManager actor with quorum voting logic 2026-08-26 13:01:44 +02:00
CleverWild
074e6501ec feat(crypto): expose governance signing context and make shamir_threshold pub const 2026-08-26 13:01:44 +02:00
CleverWild
b2632661f8 feat(db): add proposal and proposal_vote tables 2026-08-26 13:01:44 +02:00
CleverWild
966b4c8828 feat(proto): add governance proposal/vote RPC definitions 2026-08-26 13:01:44 +02:00
CleverWild
b6c91c56eb housekepping: add fixme for start_bootstrap's operator_id 2026-08-26 13:01:44 +02:00
CleverWild
240fd3eb63 refactor(server::crypto): use fixed-size [u8; 32] and KeyCell throughout seal key API 2026-08-26 13:01:44 +02:00
CleverWild
80ba30d430 fix(server::tests): tighten unseal test seal_key params to &[u8; 32] 2026-08-26 13:01:44 +02:00
CleverWild
59cb65f3e1 feat(server::grpc): wire Shamir committee bootstrap and unseal proto messages
Adds DeclareCommittee and ContributePassphrase variants to bootstrap.proto,
ContributePassphrase to unseal.proto, and AwaitingContributions result codes
to both. Implements corresponding inbound converters and outbound reply
mappings. VaultGate handlers delegate to VaultCoordinator.
2026-08-26 13:01:44 +02:00
CleverWild
83075e9df7 feat(server): introduce VaultCoordinator for multi-operator Shamir bootstrap/unseal
VaultCoordinator collects operator passphrases, splits the seal key into
Shamir shares on bootstrap (encrypting each share with the operator's
passphrase via Argon2 + XChaCha20-Poly1305), and reconstructs the seal
key from threshold shares on unseal. Adds vsss-rs 5.4.0 and rand_core 0.6
dependencies.
2026-08-26 13:01:44 +02:00
CleverWild
fc7f2b1a03 refactor(server::actors::vault): clean up Bootstrap/TryUnseal, remove Bootstrapping state
Bootstrap and TryUnseal now accept a SafeCell<Vec<u8>> seal key directly.
The Bootstrapping intermediate state is removed — multi-operator coordination
is the responsibility of VaultCoordinator, which calls Bootstrap atomically
once all shares are collected.
2026-08-26 13:01:44 +02:00
CleverWild
0695ec96a8 feat(server::crypto): add Shamir secret sharing utilities
Wraps vsss_rs Gf256::split_array / combine_array into thin split_key /
combine_shares helpers. Also widens derive_key salt parameter from &[u8;16]
to &[u8] to accommodate the 32-byte share salts.
2026-08-26 13:01:44 +02:00
CleverWild
8159902027 feat(server::db): add share_salt column to operator table
Each operator row now stores a 32-byte random salt used to derive the
per-operator share encryption key from their passphrase (Argon2 KDF).
2026-08-26 13:01:44 +02:00
CleverWild
928799fa07 feat(server::actors::evm): implement operator_delete_grant
Sets revoked_at on the evm_basic_grant row; returns NotFound if the grant
does not exist. Wires the handler in OperatorSession replacing the todo!().
2026-08-26 13:01:44 +02:00
CleverWild
3d3a4be806 fix(server::peers::operator::auth): make ChallengeContext pub for smlang state machine
smlang generates a public state enum whose variants contain ChallengeContext,
requiring the type itself to be fully public. Also tightens the wildcard arm
in client auth to an exhaustive match.
2026-08-26 13:01:44 +02:00
Skipper
28b7276e11 WIP: some things 2026-08-26 13:01:44 +02:00
62 changed files with 4376 additions and 2798 deletions

109
AGENTS.md
View File

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

View File

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

View File

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

312
server/Cargo.lock generated
View File

@@ -504,7 +504,7 @@ dependencies = [
"async-trait",
"auto_impl",
"either",
"elliptic-curve 0.13.8",
"elliptic-curve",
"k256",
"thiserror",
]
@@ -707,6 +707,7 @@ dependencies = [
"memsafe",
"ml-dsa",
"rand 0.10.1",
"strum 0.28.0",
"thiserror",
"x-wing",
]
@@ -769,9 +770,10 @@ dependencies = [
"mutants",
"pem",
"proptest",
"prost",
"prost-types",
"rand 0.10.1",
"rand_core 0.10.1",
"rand_core 0.6.4",
"rcgen",
"restructed",
"rstest",
@@ -822,7 +824,7 @@ dependencies = [
"ark-serialize 0.3.0",
"ark-std 0.3.0",
"derivative",
"num-bigint 0.4.6",
"num-bigint",
"num-traits",
"paste",
"rustc_version 0.3.3",
@@ -842,7 +844,7 @@ dependencies = [
"derivative",
"digest 0.10.7",
"itertools 0.10.5",
"num-bigint 0.4.6",
"num-bigint",
"num-traits",
"paste",
"rustc_version 0.4.1",
@@ -863,7 +865,7 @@ dependencies = [
"digest 0.10.7",
"educe",
"itertools 0.13.0",
"num-bigint 0.4.6",
"num-bigint",
"num-traits",
"paste",
"zeroize",
@@ -905,7 +907,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20"
dependencies = [
"num-bigint 0.4.6",
"num-bigint",
"num-traits",
"quote",
"syn 1.0.109",
@@ -917,7 +919,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565"
dependencies = [
"num-bigint 0.4.6",
"num-bigint",
"num-traits",
"proc-macro2",
"quote",
@@ -930,7 +932,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3"
dependencies = [
"num-bigint 0.4.6",
"num-bigint",
"num-traits",
"proc-macro2",
"quote",
@@ -955,7 +957,7 @@ checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5"
dependencies = [
"ark-std 0.4.0",
"digest 0.10.7",
"num-bigint 0.4.6",
"num-bigint",
]
[[package]]
@@ -967,7 +969,7 @@ dependencies = [
"ark-std 0.5.0",
"arrayvec",
"digest 0.10.7",
"num-bigint 0.4.6",
"num-bigint",
]
[[package]]
@@ -1197,12 +1199,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base16ct"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
[[package]]
name = "base64"
version = "0.22.1"
@@ -1560,12 +1556,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1628,22 +1618,20 @@ checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array 0.14.7",
"rand_core 0.6.4",
"serdect 0.2.0",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-bigint"
version = "0.7.5"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
checksum = "96272c2ff28b807e09250b180ad1fb7889a3258f7455759b5c3c58b719467130"
dependencies = [
"cpubits",
"ctutils",
"hybrid-array",
"num-traits",
"rand_core 0.10.1",
"serdect 0.4.3",
"rand_core 0.6.4",
"serdect 0.3.0",
"subtle",
"zeroize",
]
@@ -1676,7 +1664,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
"subtle",
]
[[package]]
@@ -1839,7 +1826,7 @@ dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint 0.4.6",
"num-bigint",
"num-traits",
"rusticata-macros",
]
@@ -2036,7 +2023,7 @@ checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der 0.7.10",
"digest 0.10.7",
"elliptic-curve 0.13.8",
"elliptic-curve",
"rfc6979",
"serdect 0.2.0",
"signature 2.2.0",
@@ -2070,50 +2057,34 @@ version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct 0.2.0",
"base16ct",
"crypto-bigint 0.5.5",
"digest 0.10.7",
"ff 0.13.1",
"ff",
"generic-array 0.14.7",
"group 0.13.0",
"group",
"hkdf",
"pkcs8 0.10.2",
"rand_core 0.6.4",
"sec1 0.7.3",
"sec1",
"serdect 0.2.0",
"subtle",
"zeroize",
]
[[package]]
name = "elliptic-curve"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65"
dependencies = [
"base16ct 1.0.0",
"crypto-bigint 0.7.5",
"crypto-common 0.2.1",
"ff 0.14.0",
"group 0.14.0",
"hybrid-array",
"pkcs8 0.11.0",
"rand_core 0.10.1",
"sec1 0.8.1",
"serdect 0.4.3",
"subtle",
"tap",
"zeroize",
]
[[package]]
name = "elliptic-curve-tools"
version = "0.3.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0d5e534f103b079a71ef1d66c6e62c89413f1b62709ca11f744429b4afe5b4"
checksum = "1de2b6fae800f08032a6ea32995b52925b1d451bff9d445c8ab2932323277faf"
dependencies = [
"elliptic-curve 0.14.1",
"elliptic-curve",
"heapless",
"hex",
"multiexp",
"serde",
"serdect 0.4.3",
"zeroize",
]
[[package]]
@@ -2186,20 +2157,11 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"bitvec",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "ff"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f"
dependencies = [
"rand_core 0.10.1",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
@@ -2398,9 +2360,9 @@ dependencies = [
[[package]]
name = "generic-array"
version = "1.4.2"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb130435a959a8d525e6bca66ff6c40981a300ee96d70e3ef56f046556d614a3"
checksum = "dab9e9188e97a93276e1fe7b56401b851e2b45a46d045ca658100c1303ada649"
dependencies = [
"rustversion",
"serde_core",
@@ -2466,22 +2428,11 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff 0.13.1",
"ff",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "group"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4"
dependencies = [
"ff 0.14.0",
"rand_core 0.10.1",
"subtle",
]
[[package]]
name = "h2"
version = "0.4.13"
@@ -2552,9 +2503,9 @@ checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "heapless"
version = "0.9.3"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4"
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
dependencies = [
"hash32",
"stable_deref_trait",
@@ -2587,6 +2538,15 @@ dependencies = [
"arrayvec",
]
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac 0.12.1",
]
[[package]]
name = "hmac"
version = "0.12.1"
@@ -2652,13 +2612,12 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.15"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
dependencies = [
"ctutils",
"serde",
"subtle",
"typenum",
"zeroize",
]
@@ -3061,7 +3020,7 @@ checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
dependencies = [
"cfg-if",
"ecdsa",
"elliptic-curve 0.13.8",
"elliptic-curve",
"once_cell",
"serdect 0.2.0",
"sha2 0.10.9",
@@ -3071,7 +3030,7 @@ dependencies = [
[[package]]
name = "kameo"
version = "0.22.2"
source = "git+https://github.com/hdbg/kameo.git?rev=3bbebac#3bbebac9f2a943be75588d80826e6bea45079d5f"
source = "git+https://github.com/hdbg/kameo.git?rev=17af90e3#17af90e3ae95fc6f89fa31a2f1b9506ac127f0b7"
dependencies = [
"downcast-rs",
"dyn-clone",
@@ -3085,7 +3044,7 @@ dependencies = [
[[package]]
name = "kameo_actors"
version = "0.8.1"
source = "git+https://github.com/hdbg/kameo.git?rev=3bbebac#3bbebac9f2a943be75588d80826e6bea45079d5f"
source = "git+https://github.com/hdbg/kameo.git?rev=17af90e3#17af90e3ae95fc6f89fa31a2f1b9506ac127f0b7"
dependencies = [
"futures",
"glob",
@@ -3097,12 +3056,12 @@ dependencies = [
[[package]]
name = "kameo_macros"
version = "0.21.1"
source = "git+https://github.com/hdbg/kameo.git?rev=3bbebac#3bbebac9f2a943be75588d80826e6bea45079d5f"
source = "git+https://github.com/hdbg/kameo.git?rev=17af90e3#17af90e3ae95fc6f89fa31a2f1b9506ac127f0b7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.4",
]
[[package]]
@@ -3405,6 +3364,20 @@ dependencies = [
"zeroize",
]
[[package]]
name = "multiexp"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ec2ce93a6f06ac6cae04c1da3f2a6a24fcfc1f0eb0b4e0f3d302f0df45326cb"
dependencies = [
"ff",
"group",
"rand_core 0.6.4",
"rustversion",
"std-shims",
"zeroize",
]
[[package]]
name = "multimap"
version = "0.10.1"
@@ -3436,6 +3409,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -3444,16 +3431,18 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
"rand 0.8.6",
"serde",
]
[[package]]
name = "num-bigint"
version = "0.5.1"
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-integer",
"num-traits",
"rand 0.8.6",
"serde",
]
@@ -3472,6 +3461,29 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
"serde",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -4351,7 +4363,7 @@ dependencies = [
"bytes",
"fastrlp 0.3.1",
"fastrlp 0.4.0",
"num-bigint 0.4.6",
"num-bigint",
"num-integer",
"num-traits",
"parity-scale-codec",
@@ -4578,7 +4590,7 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct 0.2.0",
"base16ct",
"der 0.7.10",
"generic-array 0.14.7",
"pkcs8 0.10.2",
@@ -4587,21 +4599,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "sec1"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
dependencies = [
"base16ct 1.0.0",
"ctutils",
"der 0.8.0",
"hybrid-array",
"serdect 0.4.3",
"subtle",
"zeroize",
]
[[package]]
name = "secp256k1"
version = "0.30.0"
@@ -4759,17 +4756,17 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177"
dependencies = [
"base16ct 0.2.0",
"base16ct",
"serde",
]
[[package]]
name = "serdect"
version = "0.4.3"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53"
dependencies = [
"base16ct 1.0.0",
"base16ct",
"serde",
]
@@ -4815,17 +4812,6 @@ dependencies = [
"keccak 0.2.0",
]
[[package]]
name = "sha3"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759"
dependencies = [
"digest 0.11.2",
"keccak 0.2.0",
"sponge-cursor",
]
[[package]]
name = "sha3-asm"
version = "0.1.6"
@@ -4836,17 +4822,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "shake"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a"
dependencies = [
"digest 0.11.2",
"keccak 0.2.0",
"sponge-cursor",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -4960,6 +4935,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "spin"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
[[package]]
name = "spki"
version = "0.7.3"
@@ -4980,12 +4961,6 @@ dependencies = [
"der 0.8.0",
]
[[package]]
name = "sponge-cursor"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.3"
@@ -5010,6 +4985,17 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "std-shims"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "227c4f8561598188d0df96dbe749824576174bba278b5b6bb2eacff1066067d0"
dependencies = [
"hashbrown 0.16.1",
"rustversion",
"spin",
]
[[package]]
name = "string_morph"
version = "0.1.0"
@@ -5115,9 +5101,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.5"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
@@ -5766,23 +5752,21 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsss-rs"
version = "6.0.1"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bfc736cfd88115aedb95ba84bc2d428fe351e92a56f69fce090af301402d91"
checksum = "6ec751bdcc8bda099e269b24cc6b4ad14f9ce8b0490c1599174070e792ecd70c"
dependencies = [
"crypto-bigint 0.7.5",
"elliptic-curve 0.14.1",
"crypto-bigint 0.5.5",
"crypto-bigint 0.6.1",
"elliptic-curve",
"elliptic-curve-tools",
"ff 0.14.0",
"generic-array 1.4.2",
"generic-array 1.4.1",
"hex",
"hybrid-array",
"num-bigint 0.5.1",
"num-traits",
"rand_core 0.10.1",
"num",
"rand_core 0.6.4",
"serde",
"sha3 0.12.0",
"shake",
"sha3 0.10.9",
"subtle",
"zeroize",
]
@@ -6474,18 +6458,18 @@ dependencies = [
[[package]]
name = "zeroize"
version = "1.9.0"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",

View File

@@ -12,8 +12,8 @@ base64 = "0.22.1"
chrono = { version = "0.4.44", features = ["serde"] }
futures = "0.3.32"
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "3bbebac"}
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "3bbebac"}
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "17af90e3"}
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "17af90e3"}
hmac = "0.13.0"
miette = { version = "7.6.0", features = ["fancy", "serde"] }
ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] }
@@ -21,13 +21,13 @@ mutants = "0.0.4"
prost = "0.14.3"
prost-types = { version = "0.14.3", features = ["chrono"] }
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 }
rstest = "0.26.1"
rustls = { version = "0.23.40", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
rustls-pki-types = "1.14.1"
sha2 = "0.11"
smlang = "0.8.0"
strum = { version = "0.28.0", features = ["derive"] }
thiserror = "2.0.18"
tokio = { version = "1.52.1", features = ["full"] }
tokio-stream = { version = "0.1.18", features = ["full"] }
@@ -77,8 +77,6 @@ needless_pass_by_ref_mut = "allow"
pub_underscore_fields = "allow"
redundant_pub_crate = "allow"
uninhabited_references = "allow" # safe with unsafe_code = "forbid" and standard uninhabited pattern (match *self {})
too-many-lines = "allow" # this is a very common pattern in server code, and it's not always possible to break it down into smaller modules without hurting readability
unused_async_trait_impl = "allow" # too pedantic
# restriction lints
alloc_instead_of_core = "warn"
@@ -109,7 +107,6 @@ indexing_slicing = "warn"
infinite_loop = "warn"
inline_asm_x86_att_syntax = "warn"
inline_asm_x86_intel_syntax = "warn"
integer_division = "warn"
large_include_file = "warn"
lossy_float_literal = "warn"
map_with_unused_argument_over_ranges = "warn"
@@ -171,3 +168,4 @@ nursery = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
type_repetition_in_bounds = "allow" # sometimes, it's better for readability this way
unused_async_trait_impl = "allow"

View File

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

View File

@@ -2,7 +2,7 @@ use crate::{
storage::StorageError,
transport::{ClientTransport, next_request_id},
};
use arbiter_crypto::authn::{self, CLIENT_CONTEXT, SigningKey};
use arbiter_crypto::authn::{self, SigningContext, SigningKey};
use arbiter_proto::{
ClientMetadata,
proto::{
@@ -100,7 +100,7 @@ async fn send_auth_challenge_solution(
key: &SigningKey,
challenge: AuthChallenge,
) -> Result<(), AuthError> {
let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos.cast_signed());
let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos as i64);
let challenge = authn::AuthChallenge {
nonce: *challenge
.random
@@ -110,7 +110,7 @@ async fn send_auth_challenge_solution(
};
let challenge_payload: Vec<u8> = challenge.format();
let signature = key
.sign_message(&challenge_payload, CLIENT_CONTEXT)
.sign_message(&challenge_payload, SigningContext::Client)
.map_err(|_| AuthError::UnexpectedAuthResponse)?
.to_bytes();

View File

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

View File

@@ -7,6 +7,7 @@ edition = "2024"
ml-dsa = {workspace = true, optional = true }
rand = {workspace = true, optional = true}
memsafe = {version = "0.4.0", optional = true}
strum = { workspace = true, optional = true }
hmac.workspace = true
alloy.workspace = true
x-wing = { version = "0.1.0-rc.0", features = ["zeroize"] }
@@ -18,7 +19,7 @@ workspace = true
[features]
default = ["authn", "safecell"]
authn = ["dep:ml-dsa", "dep:rand"]
authn = ["dep:ml-dsa", "dep:rand", "dep:strum"]
safecell = ["dep:memsafe"]
[lib]

View File

@@ -5,9 +5,25 @@ use ml_dsa::{
SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, signature::Keypair as _,
};
use rand::RngExt;
use strum::IntoStaticStr;
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator";
/// Domain separation tag mixed into every ML-DSA signature.
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
pub enum SigningContext {
#[strum(serialize = "arbiter_client")]
Client,
#[strum(serialize = "arbiter_operator")]
Operator,
#[strum(serialize = "arbiter_governance_vote")]
GovernanceVote,
}
impl SigningContext {
#[must_use]
pub fn as_bytes(self) -> &'static [u8] {
<&'static str>::from(self).as_bytes()
}
}
const NONCE_SIZE: usize = 32;
@@ -85,10 +101,26 @@ impl PublicKey {
}
#[must_use]
pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool {
pub fn verify(
&self,
challenge: &AuthChallenge,
context: SigningContext,
signature: &Signature,
) -> bool {
let challenge = challenge.format();
self.0
.verify_with_context(&challenge, context, &signature.0)
.verify_with_context(&challenge, context.as_bytes(), &signature.0)
}
#[must_use]
pub fn verify_message(
&self,
message: &[u8],
context: SigningContext,
signature: &Signature,
) -> bool {
self.0
.verify_with_context(message, context.as_bytes(), &signature.0)
}
}
@@ -115,17 +147,21 @@ impl SigningKey {
self.0.verifying_key().into()
}
pub fn sign_message(&self, message: &[u8], context: &[u8]) -> Result<Signature, Error> {
pub fn sign_message(
&self,
message: &[u8],
context: SigningContext,
) -> Result<Signature, Error> {
self.0
.signing_key()
.sign_deterministic(message, context)
.sign_deterministic(message, context.as_bytes())
.map(Into::into)
}
pub fn sign_challenge(
&self,
challenge: &AuthChallenge,
context: &[u8],
context: SigningContext,
) -> Result<Signature, Error> {
let challenge = challenge.format();
@@ -192,7 +228,7 @@ mod tests {
use crate::authn::AuthChallenge;
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, OPERATOR_CONTEXT};
use super::{PublicKey, Signature, SigningContext, SigningKey};
#[test]
fn public_key_round_trip_decodes() {
@@ -208,7 +244,7 @@ mod tests {
fn signature_round_trip_decodes() {
let key = SigningKey::generate();
let signature = key
.sign_message(b"challenge", CLIENT_CONTEXT)
.sign_message(b"challenge", SigningContext::Client)
.expect("signature should be created");
let decoded =
@@ -223,11 +259,11 @@ mod tests {
let public_key = key.public_key();
let challenge = AuthChallenge::generate(&mut rand::rng());
let signature = key
.sign_challenge(&challenge, CLIENT_CONTEXT)
.sign_challenge(&challenge, SigningContext::Client)
.expect("signature should be created");
assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature));
assert!(!public_key.verify(&challenge, OPERATOR_CONTEXT, &signature));
assert!(public_key.verify(&challenge, SigningContext::Client, &signature));
assert!(!public_key.verify(&challenge, SigningContext::Operator, &signature));
}
#[test]
@@ -240,13 +276,13 @@ mod tests {
let challenge = AuthChallenge::generate(&mut rand::rng());
let signature = restored
.sign_challenge(&challenge, CLIENT_CONTEXT)
.sign_challenge(&challenge, SigningContext::Client)
.expect("signature should be created");
assert!(
restored
.public_key()
.verify(&challenge, CLIENT_CONTEXT, &signature)
.verify(&challenge, SigningContext::Client, &signature)
);
}
}

View File

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

View File

@@ -31,18 +31,18 @@ diesel_migrations = { version = "2.3.2", features = ["sqlite"] }
async-trait.workspace = true
tokio-stream.workspace = true
rand.workspace = true
rand_core.workspace = true
rcgen.workspace = true
chrono.workspace = true
kameo.workspace = true
chacha20poly1305 = { version = "0.10.1", features = ["std"] }
argon2 = { version = "0.5.3", features = ["zeroize"] }
restructed = "0.2.2"
strum = { version = "0.28.0", features = ["derive"] }
strum.workspace = true
pem = "3.0.6"
sha2.workspace = true
hmac.workspace = true
alloy.workspace = true
prost.workspace = true
prost-types.workspace = true
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
anyhow = "1.0.102"
@@ -51,7 +51,8 @@ subtle = "2.6.1"
x25519-dalek.workspace = true
k256.workspace = true
kameo_actors.workspace = true
vsss-rs = "6.0.1"
vsss-rs = "5.4.0"
rand_core = "0.6"
[dev-dependencies]
proptest = "1.11.0"

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
use crate::{
actors::vault::{CreateNew, Decrypt, Vault},
crypto::integrity::{self, Integrable},
crypto::integrity,
db::{
DatabaseError, DatabasePool,
models::{self, EvmWalletId},
@@ -25,35 +25,14 @@ use diesel::{
use diesel_async::RunQueryDsl;
use kameo::{Actor, actor::ActorRef, messages};
use rand::{SeedableRng, rng, rngs::StdRng};
use tracing::error;
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)]
pub enum SignTransactionError {
#[error("Wallet not found")]
WalletNotFound,
#[error("Decrypted key does not match requested wallet address")]
KeyAddressMismatch,
#[error("Internal signing error")]
Internal,
#[error("Database error: {0}")]
Database(#[from] DatabaseError),
@@ -85,12 +64,6 @@ pub enum 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)]
pub struct EvmActor {
pub vault: ActorRef<Vault>,
@@ -124,39 +97,20 @@ impl EvmActor {
let aead_id: i32 = self
.vault
.ask(CreateNew {
plaintext,
aad: address.as_slice().to_vec(),
})
.ask(CreateNew { plaintext })
.await
.map_err(|_| Error::VaultSend)?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet_id = conn
.exclusive_transaction(async |conn| {
let wallet_id: i32 = insert_into(schema::evm_wallet::table)
.values(&models::NewEvmWallet {
address: address.as_slice().to_vec(),
aead_encrypted_id: aead_id,
})
.returning(schema::evm_wallet::id)
.get_result(conn)
.await
.map_err(DatabaseError::from)
.map_err(Error::Database)?;
integrity::sign_entity(
conn,
&self.vault,
&EvmWalletIntegrity { address, aead_encrypted_id: aead_id },
wallet_id,
)
.await
.map_err(Error::Integrity)?;
Ok::<i32, Error>(wallet_id)
let wallet_id = insert_into(schema::evm_wallet::table)
.values(&models::NewEvmWallet {
address: address.as_slice().to_vec(),
aead_encrypted_id: aead_id,
})
.await?;
.returning(schema::evm_wallet::id)
.get_result(&mut conn)
.await
.map_err(DatabaseError::from)?;
Ok((wallet_id, address))
}
@@ -206,14 +160,23 @@ impl EvmActor {
}
#[message]
pub async fn useragent_delete_grant(
&mut self,
grant_id: i32,
) -> Result<(), Error> {
self.engine
.revoke_grant(grant_id)
pub async fn operator_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let affected = diesel::update(schema::evm_basic_grant::table)
.filter(schema::evm_basic_grant::id.eq(grant_id))
.set(schema::evm_basic_grant::revoked_at.eq(models::SqliteTimestamp::now()))
.execute(&mut conn)
.await
.map_err(Error::from)
.map_err(DatabaseError::from)?;
if affected == 0 {
return Err(Error::Database(DatabaseError::from(
diesel::result::Error::NotFound,
)));
}
Ok(())
}
#[message]
@@ -287,51 +250,16 @@ impl EvmActor {
.ok_or(SignTransactionError::WalletNotFound)?;
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
.vault
.ask(Decrypt {
aead_id: wallet.aead_encrypted_id,
aad: wallet.address.clone(),
})
.await
.map_err(|_| SignTransactionError::VaultSend)?;
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
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.await?;

View File

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

View File

@@ -20,8 +20,6 @@ pub mod client_connect_approval;
pub struct FlowCoordinator {
pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
/// Maps DB `client_id` → `ActorId` for fast connected-client lookup.
client_ids: HashMap<i32, ActorId>,
operator_registry: ActorRef<OperatorRegistry>,
}
@@ -29,7 +27,6 @@ impl FlowCoordinator {
pub fn new(operator_registry: ActorRef<OperatorRegistry>) -> Self {
Self {
clients: HashMap::default(),
client_ids: HashMap::default(),
operator_registry,
}
}
@@ -51,7 +48,6 @@ impl Actor for FlowCoordinator {
_: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
if self.clients.remove(&id).is_some() {
self.client_ids.retain(|_, actor_id| *actor_id != id);
info!(
?id,
actor = "FlowCoordinator",
@@ -79,28 +75,14 @@ impl FlowCoordinator {
#[message(ctx)]
pub async fn register_client(
&mut self,
client_id: i32,
actor: ActorRef<ClientSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), client_id, actor = "FlowCoordinator", event = "client.connected");
info!(id = %actor.id(), actor = "FlowCoordinator", event = "client.connected");
ctx.actor_ref().link(&actor).await;
self.client_ids.insert(client_id, actor.id());
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)]
pub async fn request_client_approval(
&mut self,

View File

@@ -1,16 +1,12 @@
use crate::{
actors::{
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
operator_registry::OperatorRegistry, vault::Vault, vault_coordinator::VaultCoordinator,
},
db::{
self,
custody::{CustodyStore, DieselCustodyStore},
operator_registry::OperatorRegistry, proposal_manager::ProposalManager, vault::Vault,
vault_coordinator::VaultCoordinator,
},
db,
};
use std::sync::Arc;
use kameo::actor::{ActorRef, Spawn};
use kameo_actors::{DeliveryStrategy, message_bus::MessageBus};
use thiserror::Error;
@@ -19,6 +15,7 @@ pub mod bootstrap;
pub mod evm;
pub mod flow_coordinator;
pub mod operator_registry;
pub mod proposal_manager;
pub mod vault;
pub mod vault_coordinator;
@@ -26,20 +23,21 @@ pub mod vault_coordinator;
pub enum SpawnError {
#[error("Failed to spawn Bootstrapper actor")]
Bootstrapper(#[from] bootstrap::Error),
#[error("Failed to spawn Vault actor")]
Vault(#[from] vault::Error),
#[error("Failed to spawn VaultCoordinator actor")]
VaultCoordinator(#[from] vault_coordinator::Error),
}
/// Long-lived actors that are shared across all connections and handle global state and operations
#[derive(Clone)]
pub struct GlobalActors {
pub vault: ActorRef<Vault>,
pub vault_coordinator: ActorRef<VaultCoordinator>,
pub bootstrapper: ActorRef<Bootstrapper>,
pub vault_coordinator: ActorRef<VaultCoordinator>,
pub flow_coordinator: ActorRef<FlowCoordinator>,
pub operator_registry: ActorRef<OperatorRegistry>,
pub evm: ActorRef<EvmActor>,
pub proposal_manager: ActorRef<ProposalManager>,
pub events: ActorRef<MessageBus>,
}
@@ -49,24 +47,30 @@ impl GlobalActors {
}
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
let events = Self::spawn_message_bus();
let custody: Arc<dyn CustodyStore> = Arc::new(DieselCustodyStore);
let vault =
Vault::spawn(Vault::new(db.clone(), events.clone(), Arc::clone(&custody)).await?);
let bootstrapper = Bootstrapper::spawn(Bootstrapper::new(&db, events.clone()).await?);
let vault_coordinator =
VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault.clone(), custody));
let message_bus = Self::spawn_message_bus();
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
let evm = EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone()));
let vault_coordinator = VaultCoordinator::spawn(VaultCoordinator::new(
db.clone(),
key_holder.clone(),
));
Ok(Self {
bootstrapper,
evm: EvmActor::spawn(EvmActor::new(vault.clone(), db.clone())),
vault,
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
proposal_manager: ProposalManager::spawn(ProposalManager::new(
db,
key_holder.clone(),
evm.clone(),
vault_coordinator.clone(),
)),
vault: key_holder,
vault_coordinator,
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
operator_registry.clone(),
)),
operator_registry,
events,
events: message_bus,
evm,
})
}
}

View File

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

View File

@@ -0,0 +1,978 @@
use crate::{
actors::{
evm::EvmActor,
vault::Vault,
vault_coordinator::{StartRekey, VaultCoordinator},
},
db::{
self,
models::{
NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest,
Proposal, ProposalStatus, SqliteTimestamp,
},
schema,
},
};
use chrono::Utc;
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{Actor, actor::ActorRef, messages};
use strum::{Display, EnumString, IntoStaticStr};
use tracing::{error, warn};
pub const DEFAULT_TTL_SECS: i64 = 7 * 24 * 60 * 60; // 7 days
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, IntoStaticStr)]
#[strum(serialize_all = "snake_case")]
pub enum ProposalKindTag {
ApproveSdkClient,
GrantWalletAccess,
ApproveServerUpdate,
ReplaceOperator,
UpdateShamirParameters,
ApprovePersistentGrant,
ApproveOneOffTransaction,
}
#[derive(Debug, Clone)]
pub enum ProposalKind {
ApproveSdkClient {
client_id: i32,
},
GrantWalletAccess {
wallet_id: i32,
client_id: i32,
},
ApproveServerUpdate,
ReplaceOperator {
old_operator_id: i32,
new_pubkey: Vec<u8>,
},
UpdateShamirParameters {
new_n: u8,
},
ApprovePersistentGrant {
payload_bytes: Vec<u8>,
},
ApproveOneOffTransaction {
payload_bytes: Vec<u8>,
},
}
impl ProposalKind {
pub const fn tag(&self) -> ProposalKindTag {
match self {
Self::ApproveSdkClient { .. } => ProposalKindTag::ApproveSdkClient,
Self::GrantWalletAccess { .. } => ProposalKindTag::GrantWalletAccess,
Self::ApproveServerUpdate => ProposalKindTag::ApproveServerUpdate,
Self::ReplaceOperator { .. } => ProposalKindTag::ReplaceOperator,
Self::UpdateShamirParameters { .. } => ProposalKindTag::UpdateShamirParameters,
Self::ApprovePersistentGrant { .. } => ProposalKindTag::ApprovePersistentGrant,
Self::ApproveOneOffTransaction { .. } => ProposalKindTag::ApproveOneOffTransaction,
}
}
pub fn kind_str(&self) -> &'static str {
self.tag().into()
}
pub fn encode_payload(&self) -> Vec<u8> {
match self {
Self::ApproveSdkClient { client_id } => client_id.to_be_bytes().to_vec(),
Self::GrantWalletAccess {
wallet_id,
client_id,
} => {
let mut buf = Vec::with_capacity(8);
buf.extend_from_slice(&wallet_id.to_be_bytes());
buf.extend_from_slice(&client_id.to_be_bytes());
buf
}
Self::ApproveServerUpdate => vec![],
Self::ReplaceOperator {
old_operator_id,
new_pubkey,
} => {
let len = u32::try_from(new_pubkey.len()).expect("pubkey len fits in u32");
let mut buf = Vec::with_capacity(4 + 4 + new_pubkey.len());
buf.extend_from_slice(&old_operator_id.to_be_bytes());
buf.extend_from_slice(&len.to_be_bytes());
buf.extend_from_slice(new_pubkey);
buf
}
Self::UpdateShamirParameters { new_n } => vec![*new_n],
Self::ApprovePersistentGrant { payload_bytes }
| Self::ApproveOneOffTransaction { payload_bytes } => payload_bytes.clone(),
}
}
/// Key-rotation proposals require every operator to approve (§3.3).
#[must_use]
pub fn requires_full_quorum(kind: &str) -> bool {
matches!(
kind.parse::<ProposalKindTag>(),
Ok(ProposalKindTag::ReplaceOperator | ProposalKindTag::UpdateShamirParameters)
)
}
pub fn decode(kind: &str, payload: &[u8]) -> Result<Self, String> {
let tag = kind
.parse::<ProposalKindTag>()
.map_err(|_| format!("unknown proposal kind: {kind}"))?;
match tag {
ProposalKindTag::ApproveSdkClient => {
let bytes = <[u8; 4]>::try_from(payload)
.map_err(|_| "invalid payload for approve_sdk_client".to_owned())?;
Ok(Self::ApproveSdkClient {
client_id: i32::from_be_bytes(bytes),
})
}
ProposalKindTag::GrantWalletAccess => {
let bytes = <[u8; 8]>::try_from(payload)
.map_err(|_| "invalid payload for grant_wallet_access".to_owned())?;
Ok(Self::GrantWalletAccess {
wallet_id: i32::from_be_bytes(bytes[..4].try_into().unwrap()),
client_id: i32::from_be_bytes(bytes[4..].try_into().unwrap()),
})
}
ProposalKindTag::ApproveServerUpdate => Ok(Self::ApproveServerUpdate),
ProposalKindTag::ReplaceOperator => {
let (id_bytes, rest) = payload
.split_first_chunk::<4>()
.ok_or_else(|| "replace_operator payload too short".to_owned())?;
let old_operator_id = i32::from_be_bytes(*id_bytes);
let (len_bytes, rest) = rest
.split_first_chunk::<4>()
.ok_or_else(|| "replace_operator payload too short".to_owned())?;
let len = u32::from_be_bytes(*len_bytes);
let len = usize::try_from(len).unwrap_or(usize::MAX);
let new_pubkey = rest
.get(..len)
.ok_or_else(|| "replace_operator payload truncated".to_owned())?
.to_vec();
Ok(Self::ReplaceOperator {
old_operator_id,
new_pubkey,
})
}
ProposalKindTag::UpdateShamirParameters => {
let &[new_n] = payload else {
return Err("invalid payload for update_shamir_parameters".to_owned());
};
Ok(Self::UpdateShamirParameters { new_n })
}
ProposalKindTag::ApprovePersistentGrant => Ok(Self::ApprovePersistentGrant {
payload_bytes: payload.to_vec(),
}),
ProposalKindTag::ApproveOneOffTransaction => Ok(Self::ApproveOneOffTransaction {
payload_bytes: payload.to_vec(),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VoteOutcome {
Pending,
QuorumApproved,
QuorumRejected,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Proposal not found")]
ProposalNotFound,
#[error("Proposal is not pending")]
ProposalNotPending,
#[error("Proposal has expired")]
ProposalExpired,
#[error("Operator already voted on this proposal")]
AlreadyVoted,
#[error("Invalid vote signature")]
InvalidSignature,
#[error("Operator not found")]
OperatorNotFound,
#[error("Database connection error: {0}")]
DatabaseConnection(#[from] db::PoolError),
#[error("Database query error: {0}")]
DatabaseQuery(#[from] diesel::result::Error),
#[error("Execution failed: {0}")]
ExecutionFailed(String),
#[error("Recovery operators are sleeping")]
RecoveryNotActive,
#[error("Recovery operators may only vote on operator replacement")]
NotAllowedForRecoveryOperator,
#[error("A recovery wake-up is already pending or active")]
WakeupAlreadyPending,
#[error("No active recovery wake-up to cancel")]
NoActiveWakeup,
}
#[derive(Debug)]
pub struct ProposalSummary {
pub id: i32,
pub kind: String,
pub initiator_id: i32,
pub expires_at: SqliteTimestamp,
pub approve_count: i64,
pub reject_count: i64,
}
#[derive(Actor)]
pub struct ProposalManager {
pub(crate) db: db::DatabasePool,
pub(crate) vault: ActorRef<Vault>,
pub(crate) evm: ActorRef<EvmActor>,
pub(crate) vault_coordinator: ActorRef<VaultCoordinator>,
}
impl ProposalManager {
pub const fn new(
db: db::DatabasePool,
vault: ActorRef<Vault>,
evm: ActorRef<EvmActor>,
vault_coordinator: ActorRef<VaultCoordinator>,
) -> Self {
Self {
db,
vault,
evm,
vault_coordinator,
}
}
}
#[messages]
impl ProposalManager {
#[message]
pub async fn create_proposal(
&mut self,
kind: ProposalKind,
initiator_id: i32,
ttl_secs: Option<i64>,
) -> Result<i32, Error> {
let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
let expires_at = SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(ttl));
let new_proposal = NewProposal {
kind: kind.kind_str().to_owned(),
payload: kind.encode_payload(),
initiator_id,
expires_at,
};
let mut conn = self.db.get().await?;
let id: i32 = diesel::insert_into(schema::proposal::table)
.values(&new_proposal)
.returning(schema::proposal::id)
.get_result(&mut conn)
.await?;
Ok(id)
}
#[message]
pub async fn query_pending(&mut self, operator_id: i32) -> Vec<ProposalSummary> {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #84; this will break in 2038"
)]
let now_ts = Utc::now().timestamp() as i32;
let Ok(mut conn) = self.db.get().await else {
warn!("query_pending: failed to acquire DB connection");
return vec![];
};
let voted_ids: Vec<i32> = schema::proposal_vote::table
.filter(schema::proposal_vote::operator_id.eq(operator_id))
.select(schema::proposal_vote::proposal_id)
.load(&mut conn)
.await
.unwrap_or_default();
let proposals: Vec<Proposal> = schema::proposal::table
.filter(schema::proposal::status.eq(ProposalStatus::Pending))
.filter(schema::proposal::expires_at.gt(now_ts))
.filter(diesel::dsl::not(schema::proposal::id.eq_any(&voted_ids)))
.load(&mut conn)
.await
.unwrap_or_default();
let mut summaries = Vec::with_capacity(proposals.len());
for p in proposals {
let approve_count: i64 = schema::proposal_vote::table
.filter(schema::proposal_vote::proposal_id.eq(p.id))
.filter(schema::proposal_vote::approve.eq(true))
.count()
.get_result(&mut conn)
.await
.unwrap_or(0);
let reject_count: i64 = schema::proposal_vote::table
.filter(schema::proposal_vote::proposal_id.eq(p.id))
.filter(schema::proposal_vote::approve.eq(false))
.count()
.get_result(&mut conn)
.await
.unwrap_or(0);
summaries.push(ProposalSummary {
id: p.id,
kind: p.kind,
initiator_id: p.initiator_id,
expires_at: p.expires_at,
approve_count,
reject_count,
});
}
summaries
}
#[message]
pub async fn cast_vote(
&mut self,
proposal_id: i32,
operator_id: i32,
approve: bool,
signature: Vec<u8>,
) -> Result<VoteOutcome, Error> {
use arbiter_crypto::authn::{self, SigningContext};
let mut conn = self.db.get().await?;
// Load proposal — must exist
let proposal: Proposal = schema::proposal::table
.find(proposal_id)
.first(&mut conn)
.await
.map_err(|e| match e {
diesel::result::Error::NotFound => Error::ProposalNotFound,
other => Error::DatabaseQuery(other),
})?;
// Check for duplicate vote before status check so AlreadyVoted takes priority
let existing: i64 = schema::proposal_vote::table
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
.filter(schema::proposal_vote::operator_id.eq(operator_id))
.count()
.get_result(&mut conn)
.await?;
if existing > 0 {
return Err(Error::AlreadyVoted);
}
if proposal.status != ProposalStatus::Pending {
return Err(Error::ProposalNotPending);
}
if proposal.expires_at.0 <= Utc::now() {
return Err(Error::ProposalExpired);
}
// Load operator public key from operator_identity
let pubkey_bytes: Vec<u8> = schema::operator_identity::table
.find(operator_id)
.select(schema::operator_identity::public_key)
.first(&mut conn)
.await
.map_err(|e| match e {
diesel::result::Error::NotFound => Error::OperatorNotFound,
other => Error::DatabaseQuery(other),
})?;
let pubkey = authn::PublicKey::try_from(pubkey_bytes.as_slice())
.map_err(|()| Error::InvalidSignature)?;
// Canonical vote message: proposal_id (i64 big-endian) || approve (u8)
let mut vote_msg = Vec::with_capacity(9);
vote_msg.extend_from_slice(&i64::from(proposal_id).to_be_bytes());
vote_msg.push(u8::from(approve));
let auth_sig = authn::Signature::try_from(signature.as_slice())
.map_err(|()| Error::InvalidSignature)?;
if !pubkey.verify_message(&vote_msg, SigningContext::GovernanceVote, &auth_sig) {
return Err(Error::InvalidSignature);
}
// Insert vote
diesel::insert_into(schema::proposal_vote::table)
.values(&NewProposalVote {
proposal_id,
operator_id,
approve,
signature,
})
.execute(&mut conn)
.await?;
// Quorum check
let total_operators: i64 = schema::operator_identity::table
.count()
.get_result(&mut conn)
.await?;
let recovery_active = Self::is_recovery_active_conn(&mut conn).await?;
let total_recovery: i64 = if recovery_active {
schema::recovery_operator_identity::table
.count()
.get_result(&mut conn)
.await?
} else {
0
};
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::as_conversions,
reason = "operator count is always a small positive integer"
)]
let threshold = if ProposalKind::requires_full_quorum(&proposal.kind) {
// §3.3: key-rotation proposals require every eligible voter to approve
// §3.5: when recovery is active, recovery operators also vote on replace_operator
(total_operators + total_recovery) as usize
} else {
crate::crypto::shamir::shamir_threshold(total_operators as usize)
};
let ordinary_approve: i64 = schema::proposal_vote::table
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
.filter(schema::proposal_vote::approve.eq(true))
.count()
.get_result(&mut conn)
.await?;
let recovery_approve: i64 = schema::recovery_proposal_vote::table
.filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id))
.filter(schema::recovery_proposal_vote::approve.eq(true))
.count()
.get_result(&mut conn)
.await?;
let approve_count = ordinary_approve + recovery_approve;
let ordinary_reject: i64 = schema::proposal_vote::table
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
.filter(schema::proposal_vote::approve.eq(false))
.count()
.get_result(&mut conn)
.await?;
let recovery_reject: i64 = schema::recovery_proposal_vote::table
.filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id))
.filter(schema::recovery_proposal_vote::approve.eq(false))
.count()
.get_result(&mut conn)
.await?;
let reject_count = ordinary_reject + recovery_reject;
#[expect(
clippy::cast_possible_wrap,
clippy::as_conversions,
reason = "threshold is derived from operator count, always fits i64"
)]
let threshold_i64 = threshold as i64;
if approve_count >= threshold_i64 {
diesel::update(schema::proposal::table.find(proposal_id))
.set(schema::proposal::status.eq(ProposalStatus::Approved))
.execute(&mut conn)
.await?;
drop(conn); // release connection before async execution
self.execute_proposal(&proposal).await?;
return Ok(VoteOutcome::QuorumApproved);
}
let total_eligible = total_operators + total_recovery;
if reject_count > total_eligible - threshold_i64 {
diesel::update(schema::proposal::table.find(proposal_id))
.set(schema::proposal::status.eq(ProposalStatus::Rejected))
.execute(&mut conn)
.await?;
return Ok(VoteOutcome::QuorumRejected);
}
Ok(VoteOutcome::Pending)
}
/// §3.6: Any ordinary operator may request recovery wake-up.
/// Fails if a wake-up is already pending or active.
#[message]
pub async fn request_recovery_wakeup(&mut self, operator_id: i32) -> Result<(), Error> {
let mut conn = self.db.get().await?;
if Self::has_uncancelled_wakeup(&mut conn).await? {
return Err(Error::WakeupAlreadyPending);
}
diesel::insert_into(schema::recovery_wakeup_request::table)
.values(&NewRecoveryWakeupRequest {
requested_by: operator_id,
})
.execute(&mut conn)
.await?;
Ok(())
}
/// §3.6: Any ordinary operator may cancel a pending wake-up request.
/// Fails if there is no uncancelled request.
#[message]
pub async fn cancel_recovery_wakeup(&mut self, operator_id: i32) -> Result<(), Error> {
let mut conn = self.db.get().await?;
let rows_updated = diesel::update(schema::recovery_wakeup_request::table)
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
.set((
schema::recovery_wakeup_request::cancelled_by.eq(Some(operator_id)),
schema::recovery_wakeup_request::cancelled_at.eq(Some(SqliteTimestamp::now())),
))
.execute(&mut conn)
.await?;
if rows_updated == 0 {
return Err(Error::NoActiveWakeup);
}
Ok(())
}
/// §3.5: Recovery operators may only vote on operator replacement proposals.
/// §3.6: Voting is gated behind recovery being active (14-day window elapsed).
#[message]
pub async fn cast_recovery_vote(
&mut self,
proposal_id: i32,
recovery_operator_id: i32,
approve: bool,
signature: Vec<u8>,
) -> Result<VoteOutcome, Error> {
use arbiter_crypto::authn::{self, SigningContext};
let mut conn = self.db.get().await?;
let proposal: Proposal = schema::proposal::table
.find(proposal_id)
.first(&mut conn)
.await
.map_err(|e| match e {
diesel::result::Error::NotFound => Error::ProposalNotFound,
other => Error::DatabaseQuery(other),
})?;
if proposal.kind.parse::<ProposalKindTag>() != Ok(ProposalKindTag::ReplaceOperator) {
return Err(Error::NotAllowedForRecoveryOperator);
}
if !Self::is_recovery_active_conn(&mut conn).await? {
return Err(Error::RecoveryNotActive);
}
let existing: i64 = schema::recovery_proposal_vote::table
.filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id))
.filter(schema::recovery_proposal_vote::recovery_operator_id.eq(recovery_operator_id))
.count()
.get_result(&mut conn)
.await?;
if existing > 0 {
return Err(Error::AlreadyVoted);
}
if proposal.status != ProposalStatus::Pending {
return Err(Error::ProposalNotPending);
}
if proposal.expires_at.0 <= Utc::now() {
return Err(Error::ProposalExpired);
}
let pubkey_bytes: Vec<u8> = schema::recovery_operator_identity::table
.find(recovery_operator_id)
.select(schema::recovery_operator_identity::public_key)
.first(&mut conn)
.await
.map_err(|e| match e {
diesel::result::Error::NotFound => Error::OperatorNotFound,
other => Error::DatabaseQuery(other),
})?;
let pubkey = authn::PublicKey::try_from(pubkey_bytes.as_slice())
.map_err(|()| Error::InvalidSignature)?;
let mut vote_msg = Vec::with_capacity(9);
vote_msg.extend_from_slice(&i64::from(proposal_id).to_be_bytes());
vote_msg.push(u8::from(approve));
let auth_sig = authn::Signature::try_from(signature.as_slice())
.map_err(|()| Error::InvalidSignature)?;
if !pubkey.verify_message(&vote_msg, SigningContext::GovernanceVote, &auth_sig) {
return Err(Error::InvalidSignature);
}
diesel::insert_into(schema::recovery_proposal_vote::table)
.values(&NewRecoveryProposalVote {
proposal_id,
recovery_operator_id,
approve,
signature,
})
.execute(&mut conn)
.await?;
// Quorum: all ordinary + all recovery operators must approve (§3.3 + §3.5)
let total_ordinary: i64 = schema::operator_identity::table
.count()
.get_result(&mut conn)
.await?;
let total_recovery: i64 = schema::recovery_operator_identity::table
.count()
.get_result(&mut conn)
.await?;
let threshold_i64 = total_ordinary + total_recovery;
let ordinary_approve: i64 = schema::proposal_vote::table
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
.filter(schema::proposal_vote::approve.eq(true))
.count()
.get_result(&mut conn)
.await?;
let recovery_approve: i64 = schema::recovery_proposal_vote::table
.filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id))
.filter(schema::recovery_proposal_vote::approve.eq(true))
.count()
.get_result(&mut conn)
.await?;
let approve_count = ordinary_approve + recovery_approve;
if approve_count >= threshold_i64 {
diesel::update(schema::proposal::table.find(proposal_id))
.set(schema::proposal::status.eq(ProposalStatus::Approved))
.execute(&mut conn)
.await?;
drop(conn);
self.execute_proposal(&proposal).await?;
return Ok(VoteOutcome::QuorumApproved);
}
let recovery_reject: i64 = schema::recovery_proposal_vote::table
.filter(schema::recovery_proposal_vote::proposal_id.eq(proposal_id))
.filter(schema::recovery_proposal_vote::approve.eq(false))
.count()
.get_result(&mut conn)
.await?;
let ordinary_reject: i64 = schema::proposal_vote::table
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
.filter(schema::proposal_vote::approve.eq(false))
.count()
.get_result(&mut conn)
.await?;
let reject_count = ordinary_reject + recovery_reject;
if reject_count > threshold_i64 - approve_count - reject_count {
diesel::update(schema::proposal::table.find(proposal_id))
.set(schema::proposal::status.eq(ProposalStatus::Rejected))
.execute(&mut conn)
.await?;
return Ok(VoteOutcome::QuorumRejected);
}
Ok(VoteOutcome::Pending)
}
}
impl ProposalManager {
const WAKEUP_DELAY_SECS: i32 = 14 * 24 * 60 * 60;
/// Returns true when an uncancelled wakeup request has passed the 14-day dispute window.
async fn is_recovery_active_conn(conn: &mut db::DatabaseConnection) -> Result<bool, Error> {
let count: i64 = schema::recovery_wakeup_request::table
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
.filter(
schema::recovery_wakeup_request::requested_at.le(diesel::dsl::sql::<
diesel::sql_types::Integer,
>(&format!(
"unixepoch('now') - {}",
Self::WAKEUP_DELAY_SECS
))),
)
.count()
.get_result(conn)
.await?;
Ok(count > 0)
}
/// Returns true when there is any uncancelled wakeup request (pending or active).
async fn has_uncancelled_wakeup(conn: &mut db::DatabaseConnection) -> Result<bool, Error> {
let count: i64 = schema::recovery_wakeup_request::table
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
.count()
.get_result(conn)
.await?;
Ok(count > 0)
}
async fn execute_proposal(&self, proposal: &Proposal) -> Result<(), Error> {
let kind = ProposalKind::decode(&proposal.kind, &proposal.payload)
.map_err(Error::ExecutionFailed)?;
match kind {
ProposalKind::ApproveSdkClient { client_id } => {
self.execute_approve_sdk_client(client_id).await
}
ProposalKind::GrantWalletAccess {
wallet_id,
client_id,
} => self.execute_grant_wallet_access(wallet_id, client_id).await,
ProposalKind::ApproveServerUpdate => Ok(()),
ProposalKind::ReplaceOperator {
old_operator_id,
new_pubkey,
} => {
self.execute_replace_operator(old_operator_id, new_pubkey)
.await
}
ProposalKind::UpdateShamirParameters { new_n } => {
self.execute_update_shamir_parameters(new_n).await
}
ProposalKind::ApprovePersistentGrant { payload_bytes } => {
self.execute_approve_persistent_grant(payload_bytes).await
}
ProposalKind::ApproveOneOffTransaction { payload_bytes } => {
self.execute_approve_one_off_transaction(proposal.id, payload_bytes)
.await
}
}
}
async fn execute_grant_wallet_access(
&self,
wallet_id: i32,
client_id: i32,
) -> Result<(), Error> {
use crate::db::models::EvmWalletId;
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
diesel::insert_into(schema::evm_wallet_access::table)
.values((
schema::evm_wallet_access::wallet_id.eq(EvmWalletId::from_raw(wallet_id)),
schema::evm_wallet_access::client_id.eq(client_id),
))
.execute(&mut conn)
.await
.map_err(|e| Error::ExecutionFailed(format!("grant wallet access: {e}")))?;
Ok(())
}
/// Updates the old operator's public key in-place (preserving their DB id and history),
/// removes their old Shamir share, then begins a coordinated re-key (§3.3).
async fn execute_replace_operator(
&self,
old_operator_id: i32,
new_pubkey: Vec<u8>,
) -> Result<(), Error> {
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
diesel::update(schema::operator_identity::table)
.filter(schema::operator_identity::id.eq(old_operator_id))
.set(schema::operator_identity::public_key.eq(&new_pubkey))
.execute(&mut conn)
.await
.map_err(|e| Error::ExecutionFailed(format!("update operator pubkey: {e}")))?;
// Remove the old Shamir share; finalize_rekey will store a fresh one.
diesel::delete(schema::operator::table)
.filter(schema::operator::id.eq(Some(old_operator_id)))
.execute(&mut conn)
.await
.map_err(|e| Error::ExecutionFailed(format!("remove old operator share: {e}")))?;
drop(conn);
self.vault_coordinator
.ask(StartRekey {})
.await
.map_err(|e| Error::ExecutionFailed(format!("start rekey: {e}")))?;
Ok(())
}
/// Triggers a Shamir re-key with the current operator set (§3.3).
async fn execute_update_shamir_parameters(&self, _new_n: u8) -> Result<(), Error> {
self.vault_coordinator
.ask(StartRekey {})
.await
.map_err(|e| Error::ExecutionFailed(format!("start rekey: {e}")))?;
Ok(())
}
async fn execute_approve_one_off_transaction(
&self,
proposal_id: i32,
payload_bytes: Vec<u8>,
) -> Result<(), Error> {
use crate::actors::evm::ClientSignTransaction;
use crate::db::models::NewProposalResult;
use alloy::{
consensus::TxEip1559,
eips::eip2930::AccessList,
primitives::{Address, Bytes, TxKind, U256},
};
use arbiter_proto::proto::operator::governance::ApproveOneOffTransactionPayload;
use prost::Message as _;
let p = ApproveOneOffTransactionPayload::decode(payload_bytes.as_slice())
.map_err(|e| Error::ExecutionFailed(format!("decode one-off tx payload: {e}")))?;
let wallet_address = Address::from_slice(p.wallet_address.as_slice());
let to = Address::from_slice(p.to.as_slice());
let transaction = TxEip1559 {
chain_id: p.chain_id,
nonce: p.nonce,
gas_limit: p.gas_limit,
max_fee_per_gas: u128::from_be_bytes(
p.max_fee_per_gas
.as_slice()
.try_into()
.map_err(|_| Error::ExecutionFailed("invalid max_fee_per_gas".to_owned()))?,
),
max_priority_fee_per_gas: u128::from_be_bytes(
p.max_priority_fee_per_gas
.as_slice()
.try_into()
.map_err(|_| {
Error::ExecutionFailed("invalid max_priority_fee_per_gas".to_owned())
})?,
),
to: TxKind::Call(to),
value: U256::from_be_slice(p.value.as_slice()),
input: Bytes::from(p.input),
access_list: AccessList::default(),
};
let sig = self
.evm
.ask(ClientSignTransaction {
client_id: p.client_id,
wallet_address,
transaction,
})
.await
.map_err(|e| Error::ExecutionFailed(format!("sign one-off tx: {e}")))?;
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
diesel::insert_into(schema::proposal_result::table)
.values(NewProposalResult {
proposal_id,
data: sig.as_bytes().to_vec(),
})
.execute(&mut conn)
.await
.map_err(|e| Error::ExecutionFailed(format!("store proposal result: {e}")))?;
Ok(())
}
async fn execute_approve_persistent_grant(&self, payload_bytes: Vec<u8>) -> Result<(), Error> {
use crate::{
actors::evm::OperatorCreateGrant,
evm::policies::{
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit,
ether_transfer, token_transfers,
},
};
use alloy::primitives::{Address, U256};
use arbiter_proto::proto::operator::governance::{
ApprovePersistentGrantPayload, approve_persistent_grant_payload::Specific,
};
use chrono::Duration;
use prost::Message as _;
let payload = ApprovePersistentGrantPayload::decode(payload_bytes.as_slice())
.map_err(|e| Error::ExecutionFailed(format!("decode grant payload: {e}")))?;
let basic = SharedGrantSettings {
wallet_access_id: payload.wallet_access_id,
chain: payload.chain_id,
valid_from: payload
.valid_from_secs
.and_then(|s| chrono::DateTime::from_timestamp(s, 0)),
valid_until: payload
.valid_until_secs
.and_then(|s| chrono::DateTime::from_timestamp(s, 0)),
max_gas_fee_per_gas: payload
.max_gas_fee_per_gas
.map(|b| U256::from_be_slice(b.as_slice())),
max_priority_fee_per_gas: payload
.max_priority_fee_per_gas
.map(|b| U256::from_be_slice(b.as_slice())),
rate_limit: payload.rate_limit.map(|r| TransactionRateLimit {
count: r.count,
window: Duration::seconds(r.window_secs),
}),
};
let grant = match payload.specific {
Some(Specific::EtherTransfer(spec)) => {
let target: Vec<Address> = spec
.targets
.iter()
.map(|b| Address::from_slice(b.as_slice()))
.collect();
let limit = spec
.limit
.map(|l| VolumeRateLimit {
max_volume: U256::from_be_slice(l.max_volume.as_slice()),
window: Duration::seconds(l.window_secs),
})
.ok_or_else(|| {
Error::ExecutionFailed("missing ether transfer limit".to_owned())
})?;
SpecificGrant::EtherTransfer(ether_transfer::Settings { target, limit })
}
Some(Specific::TokenTransfer(spec)) => {
let token_contract = Address::from_slice(spec.token_contract.as_slice());
let target = spec.target.map(|b| Address::from_slice(b.as_slice()));
let volume_limits: Vec<VolumeRateLimit> = spec
.volume_limits
.iter()
.map(|l| VolumeRateLimit {
max_volume: U256::from_be_slice(l.max_volume.as_slice()),
window: Duration::seconds(l.window_secs),
})
.collect();
SpecificGrant::TokenTransfer(token_transfers::Settings {
token_contract,
target,
volume_limits,
})
}
None => return Err(Error::ExecutionFailed("missing grant specific".to_owned())),
};
self.evm
.ask(OperatorCreateGrant { basic, grant })
.await
.map_err(|e| Error::ExecutionFailed(format!("create grant: {e}")))?;
Ok(())
}
async fn execute_approve_sdk_client(&self, client_id: i32) -> Result<(), Error> {
use crate::{crypto::integrity, peers::client::ClientCredentials};
use arbiter_crypto::authn;
let mut conn = self.db.get().await.map_err(Error::DatabaseConnection)?;
let pubkey_bytes: Vec<u8> = schema::program_client::table
.find(client_id)
.select(schema::program_client::public_key)
.first(&mut conn)
.await
.map_err(|e| Error::ExecutionFailed(format!("client not found: {e}")))?;
let pubkey = authn::PublicKey::try_from(pubkey_bytes.as_slice())
.map_err(|()| Error::ExecutionFailed("invalid client public key".to_owned()))?;
let creds = ClientCredentials { pubkey };
integrity::sign_entity(&mut conn, &self.vault, &creds, client_id)
.await
.map_err(|e| {
error!(?e, "Failed to sign integrity envelope for client");
Error::ExecutionFailed(e.to_string())
})
}
}

View File

@@ -6,14 +6,11 @@ use crate::{
},
db::{
self,
custody::{CustodyRecord, CustodyStore},
models::{self, RootKeyHistory, RootKeyHistoryId},
schema::{self},
schema,
},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use std::sync::Arc;
use chrono::Utc;
use diesel::{
@@ -25,7 +22,7 @@ use hmac::{KeyInit as _, Mac as _};
use kameo::{Actor, Reply, actor::ActorRef, messages};
use kameo_actors::message_bus::{MessageBus, Publish};
use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info, warn};
use tracing::{error, info};
pub mod events {
#[derive(Clone, Copy)]
@@ -48,8 +45,6 @@ pub enum Error {
Sealed,
#[error("Invalid key provided")]
InvalidKey,
#[error("Vault locked: too many failed unseal attempts")]
LockedOut,
#[error("Requested aead entry not found")]
NotFound,
@@ -63,14 +58,8 @@ pub enum Error {
#[error("Database transaction error: {0}")]
DatabaseTransaction(#[from] diesel::result::Error),
#[error("Custody storage error: {0}")]
Custody(#[from] db::custody::Error),
#[error("Broken database")]
BrokenDatabase,
#[error("Integrity key version mismatch: envelope uses key {envelope:?}, current key is {current:?}")]
KeyVersionMismatch { envelope: RootKeyHistoryId, current: RootKeyHistoryId },
}
struct Unsealed {
@@ -90,8 +79,6 @@ enum State {
Unsealed(Unsealed),
}
const MAX_UNSEAL_ATTEMPTS: u32 = 5;
/// 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.
@@ -101,17 +88,10 @@ pub struct Vault {
db: db::DatabasePool,
state: State,
events: ActorRef<MessageBus>,
custody: Arc<dyn CustodyStore>,
unseal_failures: u32,
}
#[messages]
impl Vault {
pub async fn new(
db: db::DatabasePool,
events: ActorRef<MessageBus>,
custody: Arc<dyn CustodyStore>,
) -> Result<Self, Error> {
pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
let state = {
let mut conn = db.get().await?;
@@ -129,16 +109,10 @@ impl Vault {
}
};
Ok(Self {
db,
state,
events,
custody,
unseal_failures: 0,
})
Ok(Self { db, state, events })
}
// Exclusive transaction to avoid race condtions if multiple vaults write
// Exclusive transaction to avoid race conditions if multiple vaults write
// additional layer of protection against nonce-reuse
async fn get_new_nonce(
pool: &db::DatabasePool,
@@ -183,37 +157,37 @@ impl Vault {
State::Sealed { .. } => Err(Error::Sealed),
}
}
}
/// Create the root key and take the vault into the unsealed state.
#[messages]
impl Vault {
#[message]
pub async fn bootstrap(
&mut self,
mut seal_key: KeyCell,
custody: Option<CustodyRecord>,
) -> Result<(), Error> {
if !matches!(self.state, State::Unbootstrapped) {
pub async fn bootstrap(&mut self, mut seal_key: KeyCell) -> Result<(), Error> {
if !matches!(&self.state, State::Unbootstrapped) {
return Err(Error::AlreadyBootstrapped);
}
let mut root_key = KeyCell::new_secure_random();
// Zero nonces are fine because they are one-time
let root_key_nonce = Nonce::default();
let data_encryption_nonce = Nonce::default();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
let root_key_reader = reader.as_slice();
// Generate salt (kept for schema compat)
let root_key_salt = v1::generate_salt();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|rk| {
seal_key
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader)
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, rk.as_slice())
.map_err(|err| {
error!(?err, "Fatal bootstrap error");
Error::Encryption(err)
})
})?;
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let mut conn = self.db.get().await?;
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let custody_store = Arc::clone(&self.custody);
let root_key_history_id = conn
.transaction(async |conn| {
let root_key_history_id = insert_into(schema::root_key_history::table)
@@ -223,7 +197,7 @@ impl Vault {
root_key_encryption_nonce: root_key_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce_bytes.clone(),
schema_version: 1,
salt: v1::generate_salt().to_vec(),
salt: root_key_salt.to_vec(),
})
.returning(schema::root_key_history::id)
.get_result(&mut *conn)
@@ -234,11 +208,9 @@ impl Vault {
.execute(&mut *conn)
.await?;
if let Some(record) = custody.as_ref() {
custody_store.write_record(&mut *conn, record).await?;
}
Result::<_, Error>::Ok(RootKeyHistoryId::from_raw(root_key_history_id))
Result::<_, diesel::result::Error>::Ok(RootKeyHistoryId::from_raw(
root_key_history_id,
))
})
.await?;
@@ -255,62 +227,46 @@ impl Vault {
#[message]
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 {
root_key_history_id,
} = &self.state
else {
return Err(Error::NotBootstrapped);
};
let root_key_history_id = *root_key_history_id;
// We don't want to hold connection while doing expensive KDF work
// We don't want to hold connection while doing expensive work
let current_key = {
let mut conn = self.db.get().await?;
schema::root_key_history::table
.filter(schema::root_key_history::id.eq(*root_key_history_id))
.filter(schema::root_key_history::id.eq(root_key_history_id))
.select(RootKeyHistory::as_select())
.first(&mut conn)
.await?
};
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
let nonce =
Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| {
error!("Broken database: invalid nonce for root key");
Error::BrokenDatabase
})?;
if seal_key
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
.is_err()
{
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);
}
let mut root_key_bytes = SafeCell::new(current_key.ciphertext.clone());
seal_key
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key_bytes)
.map_err(|err| {
error!(?err, "Failed to unseal root key: invalid seal key");
Error::InvalidKey
})?;
let root_key = KeyCell::try_from(root_key_bytes).map_err(|()| {
error!("Broken database: invalid encryption key size");
Error::BrokenDatabase
})?;
self.unseal_failures = 0;
self.state = State::Unsealed(Unsealed {
root_key_history_id: current_key.id,
root_key: KeyCell::try_from(root_key).map_err(|err| {
error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase
})?,
root_key,
});
info!("Vault unsealed successfully");
@@ -319,10 +275,79 @@ impl Vault {
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.
/// Re-encrypts the root key with `new_seal_key` and records a new root_key_history row.
/// Called after a Shamir re-key so the old seal key is no longer sufficient to unseal.
#[message]
pub async fn decrypt(&mut self, aead_id: i32, aad: Vec<u8>) -> Result<SafeCell<Vec<u8>>, Error> {
pub async fn rekey_root_key(&mut self, mut new_seal_key: KeyCell) -> Result<(), Error> {
let Unsealed {
root_key,
root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?;
let new_nonce = Nonce::default();
let new_salt = v1::generate_salt();
let new_ciphertext: Vec<u8> = root_key.0.read_inline(|rk| {
new_seal_key
.encrypt(&new_nonce, v1::ROOT_KEY_TAG, rk.as_slice())
.map_err(|err| {
error!(?err, "Fatal rekey error");
Error::Encryption(err)
})
})?;
let data_encryption_nonce = Nonce::default();
let mut conn = self.db.get().await?;
let new_root_key_history_id: i32 = conn
.transaction(async |conn| {
let new_id = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: new_ciphertext,
tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: new_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce.to_vec(),
schema_version: 1,
salt: new_salt.to_vec(),
})
.returning(schema::root_key_history::id)
.get_result::<i32>(&mut *conn)
.await?;
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(new_id))
.execute(&mut *conn)
.await?;
Result::<_, diesel::result::Error>::Ok(new_id)
})
.await?;
*root_key_history_id = RootKeyHistoryId::from_raw(new_root_key_history_id);
info!("Vault root key rekeyed successfully");
Ok(())
}
#[message]
pub async fn seal(&mut self) -> Result<(), Error> {
let Unsealed {
root_key_history_id,
..
} = Self::expect_unsealed(&mut self.state)?;
self.state = State::Sealed {
root_key_history_id: *root_key_history_id,
};
let _ = self.events.tell(Publish(events::VaultResealed)).await;
Ok(())
}
}
// Server-side cryptographic operations
#[messages]
impl Vault {
#[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?;
let row: models::AeadEncrypted = {
@@ -344,15 +369,13 @@ impl Vault {
Error::BrokenDatabase
})?;
let mut output = SafeCell::new(row.ciphertext);
root_key.decrypt_in_place(&nonce, &aad, &mut output)?;
root_key.decrypt_in_place(&nonce, v1::TAG, &mut output)?;
Ok(output)
}
/// 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
#[message]
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>, aad: Vec<u8>) -> Result<i32, Error> {
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> {
let Unsealed {
root_key,
root_key_history_id,
@@ -364,7 +387,7 @@ impl Vault {
let mut ciphertext_buffer = plaintext.write();
let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut();
root_key.encrypt_in_place(&nonce, &aad, &mut *ciphertext_buffer)?;
root_key.encrypt_in_place(&nonce, v1::TAG, &mut *ciphertext_buffer)?;
let ciphertext = std::mem::take(ciphertext_buffer);
@@ -424,10 +447,7 @@ impl Vault {
} = Self::expect_unsealed(&mut self.state)?;
if *root_key_history_id != key_version {
return Err(Error::KeyVersionMismatch {
envelope: key_version,
current: *root_key_history_id,
});
return Ok(false);
}
let mut hmac = root_key.0.read_inline(|k| {
@@ -439,38 +459,20 @@ impl Vault {
Ok(hmac.verify_slice(&expected_mac).is_ok())
}
#[message]
pub async fn seal(&mut self) -> Result<(), Error> {
let Unsealed {
root_key_history_id,
..
} = Self::expect_unsealed(&mut self.state)?;
self.state = State::Sealed {
root_key_history_id: *root_key_history_id,
};
let _ = self.events.tell(Publish(events::VaultResealed)).await;
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::{actors::GlobalActors, db::custody::DieselCustodyStore};
use crate::actors::GlobalActors;
use arbiter_crypto::safecell::SafeCellHandle as _;
use super::*;
async fn bootstrapped_actor(db: &db::DatabasePool) -> Vault {
let mut actor = Vault::new(
db.clone(),
GlobalActors::spawn_message_bus(),
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let seal_key = KeyCell::from([0u8; 32]);
actor.bootstrap(seal_key, None).await.unwrap();
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
actor.bootstrap(KeyCell::from([0u8; 32])).await.unwrap();
actor
}
@@ -479,13 +481,12 @@ mod tests {
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await;
let State::Unsealed(Unsealed {
root_key_history_id,
..
}) = actor.state
else {
panic!("expected unsealed state")
panic!("expected unsealed state");
};
let n1 = Vault::get_new_nonce(&db, root_key_history_id)
@@ -505,7 +506,7 @@ mod tests {
assert_eq!(root_row.data_encryption_nonce, n2.to_vec());
let id = actor
.create_new(SafeCell::new(b"post-interleave".to_vec()), b"test-aad".to_vec())
.create_new(SafeCell::new(b"post-interleave".to_vec()))
.await
.unwrap();
let row: models::AeadEncrypted = schema::aead_encrypted::table

View File

@@ -1,109 +1,75 @@
//! 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 [`CustodyStore`].
use std::sync::Arc;
use std::collections::HashMap;
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 diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{Actor, actor::ActorRef, messages};
use rand_core::{OsRng, RngCore as _};
use tracing::error;
use crate::{
actors::vault::{self, Bootstrap, TryUnseal, Vault},
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir},
db::{
self,
custody::{CustodyRecord, CustodyStore, EncryptedShare},
models::OperatorId,
},
actors::vault::{Bootstrap, RekeyRootKey, TryUnseal, Vault},
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir, shamir::shamir_threshold},
db::{self, models, schema},
};
const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("An ordinary committee is already being coordinated")]
#[error("Already coordinating a bootstrap")]
AlreadyBootstrapping,
#[error("An unseal is already being coordinated")]
#[error("Already coordinating an unseal")]
AlreadyUnsealing,
#[error("Bootstrap is not in progress")]
#[error("Rekey not in progress")]
NotRekeying,
#[error("Bootstrap not in progress")]
NotBootstrapping,
#[error("The operator already contributed")]
#[error("Unseal not in progress")]
NotUnsealing,
#[error("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")]
#[error("Operator not found in database")]
OperatorNotFound,
#[error("Invalid passphrase (decryption failed)")]
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] db::custody::Error),
#[error("Database query error: {0}")]
DatabaseQuery(#[from] diesel::result::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()
}
VaultError,
#[error("Two-operator vaults require at least one recovery share")]
TwoOperatorsRequireRecovery,
#[error("Broken database")]
BrokenDatabase,
}
// Passphrases stored as plain Vec<u8> (not SafeCell) so CoordinatorState is Sync.
// They are ephemeral and dropped immediately after use.
enum CoordinatorState {
Idle,
Bootstrapping {
/// 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,
recovery_count: usize,
passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
},
Unsealing {
threshold: usize,
contributions: Contributions,
retryable: bool,
ordinary_passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
},
/// Shamir re-key after `replace_operator` or `update_shamir_parameters` is approved (§3.3).
/// Collects new passphrases from all current operators, then generates a fresh seal key,
/// re-splits it, and re-encrypts the vault root key.
Rekeying {
ordinary_count: usize,
recovery_count: usize,
passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
},
}
@@ -111,287 +77,645 @@ enum CoordinatorState {
pub struct VaultCoordinator {
db: db::DatabasePool,
vault: ActorRef<Vault>,
custody: Arc<dyn CustodyStore>,
state: CoordinatorState,
}
impl VaultCoordinator {
pub fn new(
db: db::DatabasePool,
vault: ActorRef<Vault>,
custody: Arc<dyn CustodyStore>,
) -> Self {
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
Self {
db,
vault,
custody,
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,
}
}
const SHARE_AAD: &[u8] = b"arbiter/shamir-share/v1";
fn encrypt_share(
passphrase: &mut SafeCell<Vec<u8>>,
passphrase_bytes: Vec<u8>,
share: &[u8],
) -> Result<EncryptedShare, Error> {
let mut salt = [0u8; RECOMMENDED_SALT_LEN];
UnwrapErr(SysRng).fill_bytes(&mut salt);
) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), Error> {
let mut share_salt = vec![0u8; 32];
OsRng.fill_bytes(&mut share_salt);
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
let mut share_seal_key = derive_key(&mut passphrase_cell, &share_salt);
let nonce = Nonce::default();
let ciphertext = derive_key(passphrase, &salt)
let encrypted_share = share_seal_key
.encrypt(&nonce, SHARE_AAD, share)
.map_err(|_| Error::Encryption)?;
Ok(EncryptedShare {
ciphertext,
nonce: nonce.to_vec(),
salt: salt.to_vec(),
})
Ok((encrypted_share, nonce.to_vec(), share_salt))
}
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)?;
passphrase_bytes: Vec<u8>,
encrypted_share: Vec<u8>,
share_nonce_bytes: &[u8],
share_salt: &[u8],
operator_id: i32,
) -> Result<Vec<u8>, Error> {
let nonce = Nonce::try_from(share_nonce_bytes).map_err(|()| {
error!(operator_id, "Invalid nonce in DB");
Error::BrokenDatabase
})?;
let mut buffer = SafeCell::new(share.ciphertext);
derive_key(passphrase, &share.salt)
.decrypt_in_place(&nonce, SHARE_AAD, &mut buffer)
let mut passphrase_cell = SafeCell::new(passphrase_bytes);
let mut share_seal_key = derive_key(&mut passphrase_cell, share_salt);
let mut share_buffer = SafeCell::new(encrypted_share);
share_seal_key
.decrypt_in_place(&nonce, SHARE_AAD, &mut share_buffer)
.map_err(|_| Error::InvalidPassphrase)?;
Ok(buffer)
Ok(share_buffer.read().clone())
}
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.
/// §3.4: Split the seal key across ordinary + recovery operators.
/// Threshold = `shamir_threshold(ordinary_count)`; total shares = ordinary + recovery.
/// When `ordinary_count` == 1 (threshold = 1), vsss-rs does not support a proper split,
/// so each share is the seal key itself — any single participant can reconstruct.
async fn finalize_bootstrap(
vault: &ActorRef<Vault>,
contributions: &mut Contributions,
db: db::DatabasePool,
vault: ActorRef<Vault>,
ordinary_passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
) -> Result<(), Error> {
let total = contributions.len();
let threshold = shamir::shamir_threshold(total).ok_or_else(|| committee_error(total))?;
let ordinary_count = ordinary_passphrases.len();
let recovery_count = recovery_passphrases.len();
let total = ordinary_count + recovery_count;
let threshold = shamir_threshold(ordinary_count);
let mut seal_key = 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()))?;
let mut seal_key_bytes = [0u8; 32];
OsRng.fill_bytes(&mut seal_key_bytes);
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,
custody: &Arc<dyn CustodyStore>,
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?
// threshold == 1 means any single share reconstructs the key (degenerate split).
// vsss-rs requires threshold >= 2, so we store the key directly in this case.
let shares: Vec<Vec<u8>> = if threshold >= 2 {
shamir::split_key(threshold, total, &seal_key_bytes, OsRng)
.map_err(|e| Error::Shamir(e.to_string()))?
} else {
std::iter::repeat_with(|| seal_key_bytes.to_vec()).take(total).collect()
};
let 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 = KeyCell::from(seal_key_bytes);
let mut conn = db.get().await?;
let mut shares_iter = shares.into_iter();
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
let share = shares_iter
.next()
.expect("split_key returned enough shares");
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
diesel::replace_into(schema::operator::table)
.values((
schema::operator::id.eq(Some(operator_id_raw)),
schema::operator::share.eq(&encrypted_share),
schema::operator::share_nonce.eq(&nonce_bytes),
schema::operator::share_salt.eq(&share_salt),
schema::operator::created_at.eq(models::SqliteTimestamp::now()),
schema::operator::updated_at.eq(models::SqliteTimestamp::now()),
))
.execute(&mut conn)
.await?;
}
let seal_key = shamir::combine_shares(threshold, &mut plaintext)
.map_err(|error| Error::Shamir(error.to_string()))?;
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
let share = shares_iter
.next()
.expect("split_key returned enough shares");
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
diesel::replace_into(schema::recovery_operator::table)
.values((
schema::recovery_operator::id.eq(recovery_id_raw),
schema::recovery_operator::share.eq(&encrypted_share),
schema::recovery_operator::share_nonce.eq(&nonce_bytes),
schema::recovery_operator::share_salt.eq(&share_salt),
schema::recovery_operator::created_at.eq(models::SqliteTimestamp::now()),
schema::recovery_operator::updated_at.eq(models::SqliteTimestamp::now()),
))
.execute(&mut conn)
.await?;
}
vault.ask(Bootstrap { seal_key }).await.map_err(|err| {
error!(?err, "Vault bootstrap failed");
Error::VaultError
})?;
Ok(())
}
/// §3.5: Unseal using any threshold-sized mix of ordinary + recovery shares.
async fn finalize_unseal(
db: db::DatabasePool,
vault: ActorRef<Vault>,
ordinary_passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
) -> Result<(), Error> {
let mut conn = db.get().await?;
// Determine whether shares were stored as raw keys (threshold=1) or vsss-rs splits (threshold>=2).
let ordinary_operator_count: i64 = schema::operator::table
.count()
.get_result(&mut conn)
.await?;
let threshold = shamir_threshold(ordinary_operator_count as usize);
let mut shares: Vec<Vec<u8>> = Vec::new();
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
schema::operator::table
.filter(schema::operator::id.eq(Some(operator_id_raw)))
.select((
schema::operator::share,
schema::operator::share_nonce,
schema::operator::share_salt,
))
.first(&mut conn)
.await
.map_err(|_| Error::OperatorNotFound)?;
shares.push(decrypt_share(
passphrase_bytes,
encrypted_share,
&share_nonce_bytes,
&share_salt,
operator_id_raw,
)?);
}
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
schema::recovery_operator::table
.find(recovery_id_raw)
.select((
schema::recovery_operator::share,
schema::recovery_operator::share_nonce,
schema::recovery_operator::share_salt,
))
.first(&mut conn)
.await
.map_err(|_| Error::OperatorNotFound)?;
shares.push(decrypt_share(
passphrase_bytes,
encrypted_share,
&share_nonce_bytes,
&share_salt,
recovery_id_raw,
)?);
}
// When threshold==1, shares are raw 32-byte seal keys (vsss-rs cannot split 1-of-N).
// Any single decrypted share is the key itself.
let seal_key_bytes: [u8; 32] = if threshold <= 1 {
let raw = shares
.into_iter()
.next()
.ok_or_else(|| Error::Shamir("No shares available".into()))?;
raw.try_into()
.map_err(|_| Error::Shamir("Invalid share length".into()))?
} else {
shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))?
};
let seal_key = KeyCell::from(seal_key_bytes);
vault.ask(TryUnseal { seal_key }).await.map_err(|err| {
error!(?err, "Vault unseal failed");
Error::VaultError
})?;
Ok(())
}
/// §3.3: Generate a fresh seal key, split across current operators, re-encrypt the vault root key.
/// Called after `replace_operator` or `update_shamir_parameters` is approved and all contributors submit.
async fn finalize_rekey(
db: db::DatabasePool,
vault: ActorRef<Vault>,
ordinary_passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
) -> Result<(), Error> {
let ordinary_count = ordinary_passphrases.len();
let recovery_count = recovery_passphrases.len();
let total = ordinary_count + recovery_count;
let threshold = shamir_threshold(ordinary_count);
let mut new_seal_key_bytes = [0u8; 32];
OsRng.fill_bytes(&mut new_seal_key_bytes);
let shares: Vec<Vec<u8>> = if threshold >= 2 {
shamir::split_key(threshold, total, &new_seal_key_bytes, OsRng)
.map_err(|e| Error::Shamir(e.to_string()))?
} else {
std::iter::repeat_with(|| new_seal_key_bytes.to_vec())
.take(total)
.collect()
};
let mut conn = db.get().await?;
let mut shares_iter = shares.into_iter();
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
let share = shares_iter
.next()
.expect("split_key returned enough shares");
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
diesel::replace_into(schema::operator::table)
.values((
schema::operator::id.eq(Some(operator_id_raw)),
schema::operator::share.eq(&encrypted_share),
schema::operator::share_nonce.eq(&nonce_bytes),
schema::operator::share_salt.eq(&share_salt),
schema::operator::created_at.eq(models::SqliteTimestamp::now()),
schema::operator::updated_at.eq(models::SqliteTimestamp::now()),
))
.execute(&mut conn)
.await?;
}
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
let share = shares_iter
.next()
.expect("split_key returned enough shares");
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
diesel::replace_into(schema::recovery_operator::table)
.values((
schema::recovery_operator::id.eq(recovery_id_raw),
schema::recovery_operator::share.eq(&encrypted_share),
schema::recovery_operator::share_nonce.eq(&nonce_bytes),
schema::recovery_operator::share_salt.eq(&share_salt),
schema::recovery_operator::created_at.eq(models::SqliteTimestamp::now()),
schema::recovery_operator::updated_at.eq(models::SqliteTimestamp::now()),
))
.execute(&mut conn)
.await?;
}
drop(conn);
let new_seal_key = KeyCell::from(new_seal_key_bytes);
vault
.ask(TryUnseal { seal_key })
.ask(RekeyRootKey { new_seal_key })
.await
.map_err(|_| Error::Vault)
.map_err(|err| {
error!(?err, "Vault rekey failed");
Error::VaultError
})?;
Ok(())
}
#[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.
/// Phase 1 of multi-operator bootstrap: declare the committee size.
#[message]
pub fn start_bootstrap(
#[expect(clippy::unused_async, reason = "kameo requires messages to be async")]
pub async fn start_bootstrap(
&mut self,
operator_id: OperatorId,
operator_id: i32,
declared_count: usize,
recovery_count: usize,
) -> Result<(), Error> {
if shamir::shamir_threshold(declared_count).is_none() {
return Err(committee_error(declared_count));
let _ = operator_id; // fixme!: any authenticated operator may announce the committee size. the first call wins
if !matches!(self.state, CoordinatorState::Idle) {
return Err(Error::AlreadyBootstrapping);
}
match &self.state {
CoordinatorState::Unsealing { .. } => return Err(Error::AlreadyUnsealing),
CoordinatorState::Bootstrapping { declarer, .. } if *declarer != operator_id => {
return Err(Error::AlreadyBootstrapping);
}
CoordinatorState::Bootstrapping { .. } | CoordinatorState::Idle => {}
if declared_count == 2 && recovery_count == 0 {
return Err(Error::TwoOperatorsRequireRecovery);
}
self.state = CoordinatorState::Bootstrapping {
declarer: operator_id,
declared_count,
contributions: Contributions::default(),
retryable: false,
recovery_count,
passphrases: HashMap::new(),
recovery_passphrases: HashMap::new(),
};
Ok(())
}
/// Phase 2 of multi-operator bootstrap: ordinary operator contributes a passphrase.
/// Returns Ok(true) when all ordinary + recovery operators contributed and bootstrap finalized.
#[message]
pub async fn contribute_bootstrap(
&mut self,
operator_id: OperatorId,
passphrase: SafeCell<Vec<u8>>,
operator_id: i32,
mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
let CoordinatorState::Bootstrapping {
declared_count,
contributions,
retryable,
..
recovery_count,
passphrases,
recovery_passphrases,
} = &mut self.state
else {
return Err(Error::NotBootstrapping);
};
if contributions.contains(operator_id) && !*retryable {
if passphrases.contains_key(&operator_id) {
return Err(Error::DuplicateContribution);
}
contributions.put(operator_id, passphrase);
*retryable = false;
if contributions.len() < *declared_count {
let passphrase_bytes = passphrase.read().to_vec();
passphrases.insert(operator_id, passphrase_bytes);
if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count {
return Ok(false);
}
let state = std::mem::replace(&mut self.state, CoordinatorState::Idle);
let CoordinatorState::Bootstrapping {
declarer,
declared_count,
mut contributions,
passphrases,
recovery_passphrases,
..
} = state
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
else {
unreachable!("state was matched as Bootstrapping above")
unreachable!()
};
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)
}
}
finalize_bootstrap(
self.db.clone(),
self.vault.clone(),
passphrases,
recovery_passphrases,
)
.await?;
Ok(true)
}
/// Phase 2 of multi-operator bootstrap: recovery operator contributes a passphrase.
/// Returns Ok(true) when all contributors are in and bootstrap finalized.
#[message]
pub async fn contribute_recovery_bootstrap(
&mut self,
recovery_operator_id: i32,
mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
let CoordinatorState::Bootstrapping {
declared_count,
recovery_count,
passphrases,
recovery_passphrases,
} = &mut self.state
else {
return Err(Error::NotBootstrapping);
};
if recovery_passphrases.contains_key(&recovery_operator_id) {
return Err(Error::DuplicateContribution);
}
let passphrase_bytes = passphrase.read().to_vec();
recovery_passphrases.insert(recovery_operator_id, passphrase_bytes);
if passphrases.len() < *declared_count || recovery_passphrases.len() < *recovery_count {
return Ok(false);
}
let CoordinatorState::Bootstrapping {
passphrases,
recovery_passphrases,
..
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
else {
unreachable!()
};
finalize_bootstrap(
self.db.clone(),
self.vault.clone(),
passphrases,
recovery_passphrases,
)
.await?;
Ok(true)
}
/// Contribute a passphrase for vault unseal (ordinary operator).
/// Returns Ok(true) when threshold reached and vault is unsealed.
#[message]
pub async fn contribute_unseal(
&mut self,
operator_id: OperatorId,
passphrase: SafeCell<Vec<u8>>,
operator_id: i32,
mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
if matches!(self.state, CoordinatorState::Idle) {
let threshold = {
let mut conn = self.db.get().await?;
self.custody.threshold(&mut conn).await?
};
self.state = CoordinatorState::Unsealing {
threshold,
contributions: Contributions::default(),
retryable: false,
};
}
self.ensure_unsealing_state().await?;
let CoordinatorState::Unsealing {
threshold,
contributions,
retryable,
ordinary_passphrases,
recovery_passphrases,
} = &mut self.state
else {
return Err(Error::AlreadyBootstrapping);
return Err(Error::NotUnsealing);
};
if contributions.contains(operator_id) && !*retryable {
if ordinary_passphrases.contains_key(&operator_id) {
return Err(Error::DuplicateContribution);
}
contributions.put(operator_id, passphrase);
*retryable = false;
if contributions.len() < *threshold {
let passphrase_bytes = passphrase.read().to_vec();
ordinary_passphrases.insert(operator_id, passphrase_bytes);
if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold {
return Ok(false);
}
let state = std::mem::replace(&mut self.state, CoordinatorState::Idle);
self.do_finalize_unseal().await
}
/// Contribute a passphrase for vault unseal (recovery operator, §3.5).
/// Recovery operators may contribute during unseal when recovery is active.
/// Returns Ok(true) when threshold reached and vault is unsealed.
#[message]
pub async fn contribute_recovery_unseal(
&mut self,
recovery_operator_id: i32,
mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
self.ensure_unsealing_state().await?;
let CoordinatorState::Unsealing {
threshold,
mut contributions,
..
} = state
ordinary_passphrases,
recovery_passphrases,
} = &mut self.state
else {
unreachable!("state was matched as Unsealing above")
return Err(Error::NotUnsealing);
};
match finalize_unseal(
&self.db,
&self.custody,
&self.vault,
threshold,
&mut contributions,
)
.await
{
Ok(()) => Ok(true),
Err(error) => {
self.state = CoordinatorState::Unsealing {
threshold,
contributions,
retryable: true,
};
Err(error)
}
if recovery_passphrases.contains_key(&recovery_operator_id) {
return Err(Error::DuplicateContribution);
}
let passphrase_bytes = passphrase.read().to_vec();
recovery_passphrases.insert(recovery_operator_id, passphrase_bytes);
if ordinary_passphrases.len() + recovery_passphrases.len() < *threshold {
return Ok(false);
}
self.do_finalize_unseal().await
}
}
impl VaultCoordinator {
/// Initializes `CoordinatorState::Unsealing` on first call if still `Idle`.
/// Threshold is based on ordinary operator count only (§3.4).
async fn ensure_unsealing_state(&mut self) -> Result<(), Error> {
if matches!(self.state, CoordinatorState::Idle) {
let mut conn = self.db.get().await?;
let ordinary_count: i64 = schema::operator::table
.count()
.get_result(&mut conn)
.await?;
let threshold = shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default());
self.state = CoordinatorState::Unsealing {
threshold,
ordinary_passphrases: HashMap::new(),
recovery_passphrases: HashMap::new(),
};
}
Ok(())
}
/// Moves state back to Idle and calls finalize_unseal.
async fn do_finalize_unseal(&mut self) -> Result<bool, Error> {
let CoordinatorState::Unsealing {
ordinary_passphrases,
recovery_passphrases,
..
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
else {
unreachable!()
};
finalize_unseal(
self.db.clone(),
self.vault.clone(),
ordinary_passphrases,
recovery_passphrases,
)
.await?;
Ok(true)
}
async fn do_finalize_rekey(&mut self) -> Result<bool, Error> {
let CoordinatorState::Rekeying {
passphrases,
recovery_passphrases,
..
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
else {
unreachable!()
};
finalize_rekey(
self.db.clone(),
self.vault.clone(),
passphrases,
recovery_passphrases,
)
.await?;
Ok(true)
}
}
#[messages]
impl VaultCoordinator {
/// Begin Shamir re-key after a key-rotation proposal is approved (§3.3).
/// Queries the current operator and recovery operator counts from the DB,
/// then transitions to Rekeying state awaiting contributions from all of them.
#[message]
pub async fn start_rekey(&mut self) -> Result<(), Error> {
if !matches!(self.state, CoordinatorState::Idle) {
return Err(Error::AlreadyBootstrapping);
}
let mut conn = self.db.get().await?;
let ordinary_count: i64 = schema::operator_identity::table
.count()
.get_result(&mut conn)
.await?;
let recovery_count: i64 = schema::recovery_operator_identity::table
.count()
.get_result(&mut conn)
.await?;
self.state = CoordinatorState::Rekeying {
ordinary_count: ordinary_count as usize,
recovery_count: recovery_count as usize,
passphrases: HashMap::new(),
recovery_passphrases: HashMap::new(),
};
Ok(())
}
/// Contribute an ordinary operator passphrase for the re-key.
/// Returns Ok(true) when all contributors have submitted and the re-key is complete.
#[message]
pub async fn contribute_rekey(
&mut self,
operator_id: i32,
mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
let CoordinatorState::Rekeying {
ordinary_count,
recovery_count,
passphrases,
recovery_passphrases,
} = &mut self.state
else {
return Err(Error::NotRekeying);
};
if passphrases.contains_key(&operator_id) {
return Err(Error::DuplicateContribution);
}
passphrases.insert(operator_id, passphrase.read().to_vec());
if passphrases.len() < *ordinary_count || recovery_passphrases.len() < *recovery_count {
return Ok(false);
}
self.do_finalize_rekey().await
}
/// Contribute a recovery operator passphrase for the re-key.
/// Returns Ok(true) when all contributors have submitted and the re-key is complete.
#[message]
pub async fn contribute_recovery_rekey(
&mut self,
recovery_operator_id: i32,
mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
let CoordinatorState::Rekeying {
ordinary_count,
recovery_count,
passphrases,
recovery_passphrases,
} = &mut self.state
else {
return Err(Error::NotRekeying);
};
if recovery_passphrases.contains_key(&recovery_operator_id) {
return Err(Error::DuplicateContribution);
}
recovery_passphrases.insert(recovery_operator_id, passphrase.read().to_vec());
if passphrases.len() < *ordinary_count || recovery_passphrases.len() < *recovery_count {
return Ok(false);
}
self.do_finalize_rekey().await
}
}

View File

@@ -62,10 +62,11 @@ mod tests {
fn derive_seal_key_deterministic() {
static PASSWORD: &[u8] = b"password";
let mut password = SafeCell::new(PASSWORD.to_vec());
let mut password2 = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key1 = derive_key(&mut password, &salt);
let mut key2 = derive_key(&mut password, &salt);
let mut key2 = derive_key(&mut password2, &salt);
let key1_reader = key1.0.read();
let key2_reader = key2.0.read();

View File

@@ -192,9 +192,7 @@ pub async fn verify_entity<E: Integrable>(
Ok(false) => Err(Error::MacMismatch {
entity_kind: E::KIND,
}),
Err(SendError::HandlerError(
vault::Error::Sealed | vault::Error::KeyVersionMismatch { .. },
)) => Ok(AttestationStatus::Unavailable),
Err(SendError::HandlerError(vault::Error::Sealed)) => Ok(AttestationStatus::Unavailable),
Err(_) => Err(Error::VaultSend),
}
}
@@ -206,9 +204,6 @@ pub async fn is_signing_available(vault: &ActorRef<Vault>) -> Result<bool, Error
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::db::custody::DieselCustodyStore;
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
@@ -218,10 +213,8 @@ mod tests {
GlobalActors,
vault::{Bootstrap, Vault},
},
crypto::KeyCell,
db::{self, schema},
};
use super::{Error, Integrable, sign_entity, verify_entity};
#[derive(Clone, arbiter_macros::Hashable)]
struct DummyEntity {
@@ -234,18 +227,13 @@ mod tests {
async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef<Vault> {
let actor = Vault::spawn(
Vault::new(
db.clone(),
GlobalActors::spawn_message_bus(),
Arc::new(DieselCustodyStore),
)
.await
.unwrap(),
Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap(),
);
actor
.ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]),
custody: None,
seal_key: crate::crypto::KeyCell::from([0u8; 32]),
})
.await
.unwrap();
@@ -341,47 +329,4 @@ mod tests {
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));
}
#[tokio::test]
async fn key_version_mismatch_returns_unavailable_not_mac_mismatch() {
use crate::db::schema::integrity_envelope;
use super::AttestationStatus;
const ENTITY_ID: &[u8] = b"entity-id-rotation-test";
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap();
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap();
// Simulate key rotation: update the stored key_version to a stale value.
// After real rotation the vault's root_key_history_id would advance, but
// here we achieve the same mismatch by back-dating the envelope's key_version.
diesel::update(integrity_envelope::table)
.filter(integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(integrity_envelope::entity_id.eq(ENTITY_ID))
.set(integrity_envelope::key_version.eq(0))
.execute(&mut conn)
.await
.unwrap();
// Must NOT error — version mismatch is Unavailable, not tampered.
let status = verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.expect("key version mismatch must not be treated as an error");
assert_eq!(
status,
AttestationStatus::Unavailable,
"stale key_version must yield Unavailable, not MacMismatch"
);
}
}

View File

@@ -21,10 +21,9 @@ impl From<SafeCell<Key>> for KeyCell {
Self(value)
}
}
impl From<[u8; 32]> for KeyCell {
fn from(bytes: [u8; 32]) -> Self {
let cell = SafeCell::new_inline(|key: &mut Key| {
let cell = SafeCell::new_inline_default(|key: &mut Key| {
key.copy_from_slice(&bytes);
});
Self(cell)
@@ -39,7 +38,7 @@ impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
if value.len() != size_of::<Key>() {
return Err(());
}
let cell = SafeCell::new_inline(|cell_write: &mut Key| {
let cell = SafeCell::new_inline_default(|cell_write: &mut Key| {
cell_write.copy_from_slice(&value);
});
Ok(Self(cell))
@@ -48,7 +47,7 @@ impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
impl KeyCell {
pub fn new_secure_random() -> Self {
let key = SafeCell::new_inline(|key_buffer: &mut Key| {
let key = SafeCell::new_inline_default(|key_buffer: &mut Key| {
let mut rng = StdRng::try_from_rng(&mut SysRng)
.expect("Rng failure is unrecoverable and should panic");
rng.fill_bytes(key_buffer);
@@ -69,7 +68,6 @@ impl KeyCell {
let buffer = buffer.as_mut();
cipher.encrypt_in_place(nonce, associated_data, buffer)
}
pub fn decrypt_in_place(
&mut self,
nonce: &Nonce,
@@ -105,10 +103,7 @@ impl KeyCell {
}
}
/// Derive a fixed-length key from a passphrase using Argon2id.
///
/// The passphrase is borrowed so that callers can keep it in protected memory
/// and reuse it across retries instead of handing over a copy.
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_key(password: &mut SafeCell<Vec<u8>>, salt: &[u8]) -> KeyCell {
let params = {
#[cfg(debug_assertions)]
@@ -151,7 +146,7 @@ mod tests {
let salt = generate_salt();
let mut key = derive_key(&mut password, &salt);
let nonce = Nonce(*b"unique nonce 123 1231233");
let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
let associated_data = b"associated data";
let mut buffer = b"secret data".to_vec();

View File

@@ -1,221 +1,41 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use rand_core::CryptoRng;
use vsss_rs::Gf256;
use 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}")]
#[error("Failed to split key: {0}")]
Split(String),
#[error("failed to combine shares: {0}")]
#[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.
/// Split `key` into `total` shares where any `threshold` shares can reconstruct it.
/// Each returned Vec<u8> is a share with format [`identifier_byte`, `value_bytes`...].
pub fn split_key(
threshold: usize,
total: usize,
key: &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:?}")))
})
key: &[u8; 32],
rng: impl rand_core::RngCore + rand_core::CryptoRng,
) -> Result<Vec<Vec<u8>>, ShamirError> {
Gf256::split_array(threshold, total, key.as_slice(), rng)
.map_err(|e| ShamirError::Split(format!("{e:?}")))
}
/// 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"
);
/// Returns the minimum number of shares required to reconstruct the secret
/// for a committee of `n` operators.
#[must_use]
pub const fn shamir_threshold(n: usize) -> usize {
match n {
0 => panic!("No operators"),
1 => 1,
2 => 2,
n => n / 2 + 1,
}
}
/// Reconstruct the secret from `threshold` or more shares.
pub fn combine_shares(shares: &[Vec<u8>]) -> Result<[u8; 32], ShamirError> {
let bytes = Gf256::combine_array(shares)
.map_err(|e| ShamirError::Combine(format!("{e:?}")))?;
<[u8; 32]>::try_from(bytes.as_slice())
.map_err(|_| ShamirError::Combine("unexpected reconstructed key length".to_owned()))
}

View File

@@ -1,162 +0,0 @@
//! Storage for Shamir custody material: the reconstruction threshold and the
//! per-operator encrypted shares of the vault seal key.
//!
//! Every query lives behind [`CustodyStore`] so that the actors above it hold
//! no Diesel code of their own. [`CustodyStore::write_record`] borrows the
//! caller's connection instead of taking one from the pool, which lets the
//! vault write custody material inside the same transaction that stores the
//! root key.
use std::collections::HashMap;
use async_trait::async_trait;
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),
}
#[async_trait]
pub trait CustodyStore: std::fmt::Debug + Send + Sync {
/// Persist threshold and shares on the caller's connection, joining any
/// transaction the caller has already opened.
async fn write_record(
&self,
conn: &mut db::DatabaseConnection,
record: &CustodyRecord,
) -> Result<(), Error>;
/// Number of shares required to reconstruct the seal key.
async fn threshold(&self, conn: &mut db::DatabaseConnection) -> Result<usize, Error>;
/// Load the shares of `operators` in one query, in the order requested.
async fn shares(
&self,
conn: &mut db::DatabaseConnection,
operators: &[OperatorId],
) -> Result<Vec<EncryptedShare>, Error>;
}
/// The production [`CustodyStore`], backed by the `SQLite` schema.
#[derive(Debug, Clone, Copy, Default)]
pub struct DieselCustodyStore;
#[async_trait]
impl CustodyStore for DieselCustodyStore {
async fn write_record(
&self,
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(())
}
async fn threshold(&self, 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)
}
async fn shares(
&self,
conn: &mut db::DatabaseConnection,
operators: &[OperatorId],
) -> Result<Vec<EncryptedShare>, Error> {
let wanted: Vec<Option<OperatorId>> = operators.iter().copied().map(Some).collect();
let rows: Vec<(Option<OperatorId>, Vec<u8>, Vec<u8>, Vec<u8>)> = schema::operator::table
.filter(schema::operator::id.eq_any(wanted))
.select((
schema::operator::id,
schema::operator::share,
schema::operator::share_nonce,
schema::operator::share_salt,
))
.load(conn)
.await?;
let mut found: HashMap<OperatorId, EncryptedShare> = rows
.into_iter()
.filter_map(|(id, ciphertext, nonce, salt)| {
id.map(|id| {
(
id,
EncryptedShare {
ciphertext,
nonce,
salt,
},
)
})
})
.collect();
operators
.iter()
.map(|id| found.remove(id).ok_or(Error::MissingShare(*id)))
.collect()
}
}

View File

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

View File

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

View File

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

View File

@@ -1,34 +1,28 @@
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use crate::{
actors::vault::Vault,
crypto::integrity,
db::{
self, DatabaseError,
models::{
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget,
EvmEtherTransferLimit, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit,
EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{self, evm_transaction_log},
},
evm::policies::{
CombinedSettings, DatabaseID, EvalContext, EvalViolation, Grant, Policy,
SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
token_transfers::TokenTransfer,
},
};
use alloy::{
consensus::TxEip1559,
primitives::{Address, TxKind, U256},
primitives::{TxKind, U256},
};
use chrono::Utc;
use diesel::{
ExpressionMethods as _, OptionalExtension, QueryDsl as _, QueryResult, SelectableHelper,
insert_into, sqlite::Sqlite, update,
};
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
pub mod abi;
pub mod safe_signer;
@@ -278,151 +272,6 @@ impl Engine {
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>(
&self,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
@@ -501,19 +350,12 @@ impl Engine {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::db::custody::DieselCustodyStore;
use alloy::primitives::{Address, Bytes, U256, address};
use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
use rstest::rstest;
use crate::actors::{GlobalActors, vault::{Bootstrap, Vault}};
use crate::crypto::KeyCell;
use crate::crypto::integrity;
use crate::db::{
self, DatabaseConnection,
models::{
@@ -522,10 +364,8 @@ mod tests {
},
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::policies::ether_transfer::EtherTransfer;
use crate::evm::policies::{
CombinedSettings, EvalContext, EvalViolation, Policy, SharedGrantSettings,
TransactionRateLimit, VolumeRateLimit,
EvalContext, EvalViolation, SharedGrantSettings, TransactionRateLimit,
};
use super::check_shared_constraints;
@@ -557,7 +397,6 @@ mod tests {
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,
@@ -766,120 +605,4 @@ mod tests {
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(),
Arc::new(DieselCustodyStore),
)
.await
.unwrap(),
);
actor
.ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]),
custody: None,
})
.await
.unwrap();
actor
}
#[tokio::test]
async fn revoke_grant_preserves_revoked_integrity() {
use crate::db::schema::evm_basic_grant;
use diesel::ExpressionMethods as _;
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let engine = super::Engine::new(db.clone(), vault.clone());
let full_grant = CombinedSettings {
shared: SharedGrantSettings {
wallet_access_id: WALLET_ACCESS_ID,
chain: CHAIN_ID,
valid_from: None,
valid_until: None,
revoked_at: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
},
specific: super::policies::ether_transfer::Settings {
target: vec![RECIPIENT],
limit: VolumeRateLimit {
max_volume: U256::from(100u64),
window: Duration::hours(1),
},
},
};
let grant_id = engine
.create_grant::<EtherTransfer>(full_grant)
.await
.unwrap();
engine.revoke_grant(grant_id).await.unwrap();
let mut conn = db.get().await.unwrap();
diesel::update(evm_basic_grant::table)
.filter(evm_basic_grant::id.eq(grant_id))
.set(evm_basic_grant::revoked_at.eq::<Option<SqliteTimestamp>>(None))
.execute(&mut conn)
.await
.unwrap();
let wallet_access = EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: EvmWalletId::from_raw(10),
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
};
let context = EvalContext {
target: wallet_access,
chain: CHAIN_ID,
to: RECIPIENT,
value: U256::ONE,
calldata: Bytes::new(),
max_fee_per_gas: 1,
max_priority_fee_per_gas: 1,
};
let grant = EtherTransfer::try_find_grant(
&context, &mut conn,
)
.await
.unwrap()
.unwrap();
let result =
integrity::verify_entity(&mut conn, &vault, &grant.settings, grant.id).await;
assert!(matches!(
result,
Err(integrity::Error::MacMismatch { .. })
));
}
#[test]
fn shared_settings_hash_changes_when_revoked_at_changes() {
use arbiter_crypto::hashing::Hashable;
use sha2::Digest;
let active = shared_settings();
let revoked = SharedGrantSettings {
revoked_at: Some(Utc::now()),
..shared_settings()
};
let mut active_hash = sha2::Sha256::new();
active.hash(&mut active_hash);
let mut revoked_hash = sha2::Sha256::new();
revoked.hash(&mut revoked_hash);
assert_ne!(active_hash.finalize(), revoked_hash.finalize());
}
}

View File

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

View File

@@ -80,7 +80,6 @@ fn shared() -> SharedGrantSettings {
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,

View File

@@ -98,7 +98,6 @@ fn shared() -> SharedGrantSettings {
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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -217,11 +217,6 @@ async fn handle_sign_transaction(
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)) => {
EvmSignTransactionResponse {
result: Some(EvmSignTransactionResult::Error(

View File

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

View File

@@ -86,7 +86,6 @@ impl TryConvert for ProtoSharedSettings {
.valid_until
.map(ProtoTimestamp::try_convert)
.transpose()?,
revoked_at: None,
max_gas_fee_per_gas: self
.max_gas_fee_per_gas
.as_deref()

View File

@@ -1,12 +1,20 @@
use crate::{
actors::vault::VaultState,
peers::operator::{OperatorSession, session::handlers::HandleQueryVaultState},
peers::operator::{
OperatorSession,
session::handlers::{
HandleContributeRecoveryRekeyPassphrase, HandleContributeRekeyPassphrase,
HandleQueryVaultState,
},
},
};
use arbiter_proto::{
proto::operator::{
operator_response::Payload as OperatorResponsePayload,
vault::{
self as proto_vault, request::Payload as VaultRequestPayload,
self as proto_vault,
rekey::{self as proto_rekey, RekeyResult as ProtoRekeyResult},
request::Payload as VaultRequestPayload,
response::Payload as VaultResponsePayload,
},
},
@@ -33,14 +41,60 @@ pub(super) async fn dispatch(
match payload {
VaultRequestPayload::QueryState(()) => handle_query_vault_state(actor).await,
VaultRequestPayload::Unseal(_)
| VaultRequestPayload::Bootstrap(_)
| VaultRequestPayload::Rekey(_) => Err(Status::permission_denied(
"Vault is already unsealed; unseal/bootstrap not permitted in session",
)),
VaultRequestPayload::Rekey(req) => handle_rekey(actor, req).await,
VaultRequestPayload::Unseal(_) | VaultRequestPayload::Bootstrap(_) => {
Err(Status::permission_denied(
"Vault is already unsealed; unseal/bootstrap not permitted in session",
))
}
}
}
async fn handle_rekey(
actor: &ActorRef<OperatorSession>,
req: proto_rekey::Request,
) -> Result<Option<OperatorResponsePayload>, Status> {
use arbiter_proto::proto::operator::vault::rekey::request::Payload as RekeyPayload;
let payload = req
.payload
.ok_or_else(|| Status::invalid_argument("Missing rekey payload"))?;
let done: bool = match payload {
RekeyPayload::ContributePassphrase(cp) => actor
.ask(HandleContributeRekeyPassphrase {
passphrase: cp.passphrase,
})
.await
.map_err(|e| {
warn!(?e, "rekey passphrase contribution failed");
Status::internal("Rekey contribution failed")
})?,
RekeyPayload::ContributeRecoveryPassphrase(crp) => actor
.ask(HandleContributeRecoveryRekeyPassphrase {
recovery_operator_id: crp.recovery_operator_id,
passphrase: crp.passphrase,
})
.await
.map_err(|e| {
warn!(?e, "rekey recovery passphrase contribution failed");
Status::internal("Rekey recovery contribution failed")
})?,
};
let proto_result = if done {
ProtoRekeyResult::Success
} else {
ProtoRekeyResult::AwaitingContributions
};
Ok(Some(wrap_vault_response(VaultResponsePayload::Rekey(
proto_rekey::Response {
result: proto_result.into(),
},
))))
}
async fn handle_query_vault_state(
actor: &ActorRef<OperatorSession>,
) -> Result<Option<OperatorResponsePayload>, Status> {

View File

@@ -1,8 +1,8 @@
use crate::{
crypto::shamir,
grpc::{Convert, TryConvert},
peers::operator::vault_gate::{
self as vault_gate, HandleBootstrapEncryptedKey, HandleContributeBootstrapPassphrase,
HandleContributeRecoveryBootstrapPassphrase, HandleContributeRecoveryUnsealPassphrase,
HandleContributeUnsealPassphrase, HandleDeclareCommittee, HandleHandshake,
HandleUnsealEncryptedKey,
},
@@ -53,7 +53,9 @@ impl TryConvert for VaultRequestPayload {
Self::QueryState(()) => Ok(vault_gate::Inbound::HandleVaultState),
Self::Unseal(req) => req.try_convert(),
Self::Bootstrap(req) => req.try_convert(),
Self::Rekey(_) => Err(Status::unimplemented("Vault re-key is not available")),
Self::Rekey(_) => Err(Status::permission_denied(
"Rekey requires an authenticated session",
)),
}
}
}
@@ -77,16 +79,21 @@ impl TryConvert for UnsealRequestPayload {
match self {
Self::Start(start) => start.try_convert(),
Self::EncryptedKey(key) => Ok(key.convert()),
Self::ContributePassphrase(passphrase) => {
Ok(vault_gate::Inbound::HandleContributeUnsealPassphrase(
Self::ContributePassphrase(cp) => Ok(
vault_gate::Inbound::HandleContributeUnsealPassphrase(
HandleContributeUnsealPassphrase {
passphrase: passphrase.passphrase,
passphrase: cp.passphrase,
},
))
}
Self::ContributeRecoveryPassphrase(_) => Err(Status::unimplemented(
"Recovery operator contributions are not available",
)),
),
),
Self::ContributeRecoveryPassphrase(crp) => Ok(
vault_gate::Inbound::HandleContributeRecoveryUnsealPassphrase(
HandleContributeRecoveryUnsealPassphrase {
recovery_operator_id: crp.recovery_operator_id,
passphrase: crp.passphrase,
},
),
),
}
}
}
@@ -134,35 +141,27 @@ impl TryConvert for BootstrapRequestPayload {
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
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(
Self::DeclareCommittee(dc) => Ok(
vault_gate::Inbound::HandleDeclareCommittee(HandleDeclareCommittee {
count: dc.count as usize,
recovery_count: dc.recovery_count as usize,
}),
),
Self::ContributePassphrase(cp) => Ok(
vault_gate::Inbound::HandleContributeBootstrapPassphrase(
HandleContributeBootstrapPassphrase {
passphrase: cp.passphrase,
},
))
}
Self::ContributeRecoveryPassphrase(_) => Err(Status::unimplemented(
"Recovery operator contributions are not available",
)),
),
),
Self::ContributeRecoveryPassphrase(crp) => Ok(
vault_gate::Inbound::HandleContributeRecoveryBootstrapPassphrase(
HandleContributeRecoveryBootstrapPassphrase {
recovery_operator_id: crp.recovery_operator_id,
passphrase: crp.passphrase,
},
),
),
}
}
}

View File

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

View File

@@ -8,23 +8,24 @@ use crate::{
crypto::integrity::{self, AttestationStatus},
db::{
self,
models::ProgramClientMetadata,
models::{ProgramClientMetadata, SqliteTimestamp},
schema::program_client,
},
};
use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT};
use arbiter_crypto::authn::{self, AuthChallenge, SigningContext};
use arbiter_proto::{
ClientMetadata,
transport::{Bi, expect_message},
};
use chrono::Utc;
use diesel::{
ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, SelectableHelper as _,
dsl::insert_into,
dsl::insert_into, update,
};
use diesel_async::RunQueryDsl as _;
use kameo::{actor::ActorRef, error::SendError};
use tracing::{error, warn};
use tracing::error;
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum Error {
@@ -210,47 +211,71 @@ async fn insert_client(
.await
}
/// 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(
async fn sync_client_metadata(
db: &db::DatabasePool,
client_id: i32,
presented: &ClientMetadata,
metadata: &ClientMetadata,
) -> Result<(), Error> {
use crate::db::schema::client_metadata;
use crate::db::schema::{client_metadata, client_metadata_history};
let now = SqliteTimestamp(Utc::now());
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
let current: ProgramClientMetadata = program_client::table
.find(client_id)
.inner_join(client_metadata::table)
.select(ProgramClientMetadata::as_select())
.first(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})?;
conn.exclusive_transaction(async |conn| {
let (current_metadata_id, current): (i32, ProgramClientMetadata) = program_client::table
.find(client_id)
.inner_join(client_metadata::table)
.select((
program_client::metadata_id,
ProgramClientMetadata::as_select(),
))
.first(&mut *conn)
.await?;
let changed = current.name != presented.name
|| current.description != presented.description
|| current.version != presented.version;
let unchanged = current.name == metadata.name
&& current.description == metadata.description
&& current.version == metadata.version;
if unchanged {
return Ok(());
}
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"
);
}
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(&mut *conn)
.await?;
Ok(())
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
client_metadata::description.eq(&metadata.description),
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(&mut *conn)
.await?;
update(program_client::table.find(client_id))
.set((
program_client::metadata_id.eq(metadata_id),
program_client::updated_at.eq(now),
))
.execute(&mut *conn)
.await?;
Ok::<(), diesel::result::Error>(())
})
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})
}
async fn challenge_client<T>(
@@ -281,7 +306,7 @@ where
Error::Transport
})?;
if !pubkey.verify(&challenge, CLIENT_CONTEXT, &signature) {
if !pubkey.verify(&challenge, SigningContext::Client, &signature) {
error!("Challenge solution verification failed");
return Err(Error::InvalidChallengeSolution);
}
@@ -299,7 +324,6 @@ where
let client_id = if let Some(id) = get_client_id(&props.db, &pubkey).await? {
verify_integrity(&props.db, &props.actors.vault, &pubkey).await?;
check_metadata_drift(&props.db, id, &metadata).await?;
id
} else {
approve_new_client(
@@ -313,6 +337,8 @@ where
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());
challenge_client(transport, pubkey, challenge).await?;

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,17 +2,14 @@ use super::{Error, OperatorSession};
use crate::{
actors::{
evm::{
ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorListGrants,
SignTransactionError as EvmSignError,
ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorDeleteGrant,
OperatorListGrants, SignTransactionError as EvmSignError,
},
flow_coordinator::{IsClientConnected, client_connect_approval::ClientApprovalAnswer},
flow_coordinator::client_connect_approval::ClientApprovalAnswer,
vault::VaultState,
},
db::{
models::{
EvmWalletAccess, EvmWalletId, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
},
schema::program_client,
db::models::{
EvmWalletAccess, EvmWalletId, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
},
evm::policies::{Grant, SpecificGrant},
};
@@ -22,16 +19,13 @@ use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{error::SendError, messages, prelude::Context};
use tracing::{error, info, warn};
use tracing::error;
#[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,
}
@@ -128,22 +122,23 @@ impl OperatorSession {
}
#[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!()
pub(crate) async 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)
}
}
}
#[message]
@@ -153,30 +148,6 @@ impl OperatorSession {
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
@@ -232,7 +203,7 @@ impl OperatorSession {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::id.eq(entry))
.filter(evm_wallet_access::wallet_id.eq(entry))
.execute(&mut *conn)
.await?;
}
@@ -247,8 +218,9 @@ impl OperatorSession {
pub(crate) async fn handle_list_wallet_access(
&mut self,
) -> Result<Vec<EvmWalletAccess>, Error> {
use crate::db::schema::evm_wallet_access;
let mut conn = self.props.db.get().await?;
let access_entries = crate::db::schema::evm_wallet_access::table
let access_entries = evm_wallet_access::table
.select(EvmWalletAccess::as_select())
.load::<_>(&mut conn)
.await?;
@@ -285,30 +257,6 @@ impl OperatorSession {
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(())
}
@@ -332,141 +280,101 @@ impl OperatorSession {
}
}
#[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,
#[messages]
impl OperatorSession {
#[message]
pub(crate) async fn handle_create_proposal(
&mut self,
kind: crate::actors::proposal_manager::ProposalKind,
ttl_secs: Option<i64>,
) -> Result<i32, Error> {
use crate::actors::proposal_manager::CreateProposal;
let initiator_id = self.credentials.id;
self.props
.actors
.proposal_manager
.ask(CreateProposal { kind, initiator_id, ttl_secs })
.await
.map_err(|e| {
error!(?e, "create_proposal failed");
Error::internal("Failed to create proposal")
})
.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,
#[message]
pub(crate) async fn handle_cast_vote(
&mut self,
proposal_id: i32,
approve: bool,
signature: Vec<u8>,
) -> Result<crate::actors::proposal_manager::VoteOutcome, crate::actors::proposal_manager::Error> {
use crate::actors::proposal_manager::CastVote;
let operator_id = self.credentials.id;
self.props
.actors
.proposal_manager
.ask(CastVote { proposal_id, operator_id, approve, signature })
.await
.map_err(|err| match err {
SendError::HandlerError(e) => e,
_ => crate::actors::proposal_manager::Error::ExecutionFailed("actor unavailable".to_owned()),
})
.returning(EvmWalletAccess::as_select())
.get_result(&mut *conn)
}
#[message]
pub(crate) async fn handle_query_pending(
&mut self,
) -> Vec<crate::actors::proposal_manager::ProposalSummary> {
use crate::actors::proposal_manager::QueryPending;
let operator_id = self.credentials.id;
self.props
.actors
.proposal_manager
.ask(QueryPending { operator_id })
.await
.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");
.unwrap_or_default()
}
}
#[messages]
impl OperatorSession {
#[message]
pub(crate) async fn handle_contribute_rekey_passphrase(
&mut self,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
use crate::actors::vault_coordinator::ContributeRekey;
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
let operator_id = self.credentials.id;
self.props
.actors
.vault_coordinator
.ask(ContributeRekey {
operator_id,
passphrase: SafeCell::new(passphrase),
})
.await
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
}
#[message]
pub(crate) async fn handle_contribute_recovery_rekey_passphrase(
&mut self,
recovery_operator_id: i32,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
use crate::actors::vault_coordinator::ContributeRecoveryRekey;
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
self.props
.actors
.vault_coordinator
.ask(ContributeRecoveryRekey {
recovery_operator_id,
passphrase: SafeCell::new(passphrase),
})
.await
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
}
}

View File

@@ -1,14 +1,16 @@
use super::{OutOfBand, OperatorConnection};
use super::{Credentials, OutOfBand, OperatorConnection};
use crate::{
actors::{
flow_coordinator::{GetConnectedClientIds, client_connect_approval::{ClientApprovalAnswer, ClientApprovalController}}, operator_registry::ConnectOperator,
}, peers::client::ClientProfile,
flow_coordinator::client_connect_approval::ClientApprovalController,
operator_registry::ConnectOperator,
},
peers::client::ClientProfile,
};
use arbiter_crypto::authn;
use arbiter_proto::transport::Sender;
use kameo::{Actor, actor::ActorRef, messages};
use std::{borrow::Cow, collections::{HashMap, HashSet}};
use std::{borrow::Cow, collections::HashMap};
use thiserror::Error;
use tracing::error;
@@ -49,24 +51,21 @@ pub struct PendingClientApproval {
pub struct OperatorSession {
props: OperatorConnection,
credentials: Credentials,
sender: Box<dyn Sender<OutOfBand>>,
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;
impl OperatorSession {
pub(crate) fn new(props: OperatorConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self {
pub(crate) fn new(props: OperatorConnection, credentials: Credentials, sender: Box<dyn Sender<OutOfBand>>) -> Self {
Self {
props,
credentials,
sender,
pending_client_approvals: HashMap::default(),
approved_client_ids: HashSet::default(),
}
}
}
@@ -91,7 +90,6 @@ impl OperatorSession {
actor = "operator",
event = "failed to announce new client connection"
);
let _ = controller.tell(ClientApprovalAnswer { approved: false }).await;
return;
}
@@ -110,7 +108,7 @@ impl Actor for OperatorSession {
type Error = Error;
async fn on_start(mut args: Self::Args, this: ActorRef<Self>) -> Result<Self, Self::Error> {
async fn on_start(args: Self::Args, this: ActorRef<Self>) -> Result<Self, Self::Error> {
args.props
.actors
.operator_registry
@@ -125,16 +123,6 @@ impl Actor for OperatorSession {
);
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)
}

View File

@@ -3,9 +3,12 @@ use crate::{
actors::{
GlobalActors,
vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events},
vault_coordinator::{self, ContributeBootstrap, ContributeUnseal, StartBootstrap},
vault_coordinator::{
ContributeBootstrap, ContributeRecoveryBootstrap, ContributeRecoveryUnseal,
ContributeUnseal, StartBootstrap,
},
},
crypto::{KeyCell, integrity},
crypto::{KeyCell, integrity::{self}},
db::DatabasePool,
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
@@ -18,6 +21,9 @@ use tokio::sync::oneshot;
use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret};
pub use VaultGateMessage as Inbound;
pub use VaultGateMessageReply as Outbound;
pub mod state;
#[derive(Debug, thiserror::Error)]
@@ -26,29 +32,17 @@ pub enum Error {
AlreadyBootstrapped,
#[error("Invalid key provided")]
InvalidKey,
#[error("Vault locked: too many failed unseal attempts")]
LockedOut,
#[error("State transition failed")]
State,
#[error("Vault ceremony failed: {0}")]
Ceremony(#[from] vault_coordinator::Error),
#[error("Internal error: {0}")]
Internal(String),
}
impl Error {
fn internal(message: impl Into<String>) -> Self {
Self::Internal(message.into())
}
/// Preserve the coordinator's own error so the operator learns why a
/// ceremony was refused instead of reading "internal error".
fn ceremony<M>(error: SendError<M, vault_coordinator::Error>) -> Self {
match error {
SendError::HandlerError(inner) => Self::Ceremony(inner),
_ => Self::internal("VaultCoordinator unavailable"),
}
}
}
pub struct HandshakeResponse {
@@ -82,6 +76,7 @@ impl VaultGate {
impl Actor for VaultGate {
type Args = Self;
type Error = ();
async fn on_start(
@@ -110,29 +105,28 @@ impl VaultGate {
nonce: &[u8],
ciphertext: &[u8],
associated_data: &[u8],
) -> Result<SafeCell<Vec<u8>>, ()> {
) -> Result<KeyCell, ()> {
let nonce = XNonce::from_slice(nonce);
let cipher = XChaCha20Poly1305::new(secret.as_bytes().into());
let mut key_buffer = SafeCell::new(ciphertext.to_vec());
let decryption_result = key_buffer.write_inline(|write_handle| {
cipher.decrypt_in_place(nonce, associated_data, write_handle)
});
match decryption_result {
Ok(()) => Ok(key_buffer),
Ok(()) => KeyCell::try_from(key_buffer).map_err(|()| {
error!("Decrypted key material has unexpected length");
}),
Err(err) => {
error!(?err, "Failed to decrypt encrypted key material");
Err(())
}
}
}
fn key_cell(buffer: SafeCell<Vec<u8>>) -> Result<KeyCell, Error> {
KeyCell::try_from(buffer).map_err(|()| Error::InvalidKey)
}
}
#[messages]
#[messages(enum)]
impl VaultGate {
#[message]
pub fn handle_handshake(
@@ -141,11 +135,14 @@ impl VaultGate {
) -> Result<HandshakeResponse, Error> {
let ephemeral_secret = EphemeralSecret::random();
let public_key = PublicKey::from(&ephemeral_secret);
let secret = ephemeral_secret.diffie_hellman(&client_pubkey);
self.state = State::ReadyForExchange {
server_key: public_key,
secret,
};
Ok(HandshakeResponse {
server_pubkey: public_key,
})
@@ -161,17 +158,22 @@ impl VaultGate {
let State::ReadyForExchange { secret, .. } = &self.state else {
return Err(Error::State);
};
let seal_key = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
.map_err(|()| Error::InvalidKey)
.and_then(Self::key_cell)?;
match self.actors.vault.ask(TryUnseal { seal_key }).await {
let Ok(seal_key) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) else {
return Err(Error::InvalidKey);
};
match self
.actors
.vault
.ask(TryUnseal { seal_key })
.await
{
Ok(()) => {
info!("Successfully unsealed key with client-provided key");
Ok(())
}
Err(SendError::HandlerError(vault::Error::InvalidKey)) => Err(Error::InvalidKey),
Err(SendError::HandlerError(vault::Error::LockedOut)) => Err(Error::LockedOut),
Err(SendError::HandlerError(err)) => {
error!(?err, "Vault failed to unseal key");
Err(Error::InvalidKey)
@@ -193,17 +195,15 @@ impl VaultGate {
let State::ReadyForExchange { secret, .. } = &self.state else {
return Err(Error::State);
};
let seal_key = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data)
.map_err(|()| Error::InvalidKey)
.and_then(Self::key_cell)?;
let Ok(seal_key) = Self::decrypt_key(secret, &nonce, &ciphertext, &associated_data) else {
return Err(Error::InvalidKey);
};
match self
.actors
.vault
.ask(Bootstrap {
seal_key,
custody: None,
})
.ask(Bootstrap { seal_key })
.await
{
Ok(()) => {
@@ -226,23 +226,31 @@ impl VaultGate {
#[message]
pub async fn handle_vault_state(&mut self) -> Result<VaultState, Error> {
self.actors
let answer = self
.actors
.vault
.ask(GetState {})
.await
.map_err(|_| Error::internal("failed to query vault"))
.map_err(|_| Error::internal("failed to query vault"))?;
Ok(answer)
}
#[message]
pub async fn handle_declare_committee(&mut self, count: usize) -> Result<(), Error> {
pub async fn handle_declare_committee(
&mut self,
count: usize,
recovery_count: usize,
) -> Result<(), Error> {
self.actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: self.auth_creds.id,
declared_count: count,
recovery_count,
})
.await
.map_err(Error::ceremony)
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
}
#[message]
@@ -250,14 +258,32 @@ impl VaultGate {
&mut self,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
let passphrase_cell = SafeCell::new(passphrase);
self.actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: self.auth_creds.id,
passphrase: SafeCell::new(passphrase),
passphrase: passphrase_cell,
})
.await
.map_err(Error::ceremony)
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
}
#[message]
pub async fn handle_contribute_recovery_bootstrap_passphrase(
&mut self,
recovery_operator_id: i32,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
let passphrase_cell = SafeCell::new(passphrase);
self.actors
.vault_coordinator
.ask(ContributeRecoveryBootstrap {
recovery_operator_id,
passphrase: passphrase_cell,
})
.await
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
}
#[message]
@@ -265,14 +291,32 @@ impl VaultGate {
&mut self,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
let passphrase_cell = SafeCell::new(passphrase);
self.actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: self.auth_creds.id,
passphrase: SafeCell::new(passphrase),
passphrase: passphrase_cell,
})
.await
.map_err(Error::ceremony)
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
}
#[message]
pub async fn handle_contribute_recovery_unseal_passphrase(
&mut self,
recovery_operator_id: i32,
passphrase: Vec<u8>,
) -> Result<bool, Error> {
let passphrase_cell = SafeCell::new(passphrase);
self.actors
.vault_coordinator
.ask(ContributeRecoveryUnseal {
recovery_operator_id,
passphrase: passphrase_cell,
})
.await
.map_err(|_| Error::internal("VaultCoordinator unavailable"))
}
}
@@ -326,75 +370,3 @@ impl Message<events::Unsealed> for VaultGate {
ctx.stop();
}
}
pub enum Inbound {
HandleHandshake(HandleHandshake),
HandleUnsealEncryptedKey(HandleUnsealEncryptedKey),
HandleBootstrapEncryptedKey(HandleBootstrapEncryptedKey),
HandleVaultState,
HandleDeclareCommittee(HandleDeclareCommittee),
HandleContributeBootstrapPassphrase(HandleContributeBootstrapPassphrase),
HandleContributeUnsealPassphrase(HandleContributeUnsealPassphrase),
}
pub enum Outbound {
HandleHandshake(Result<HandshakeResponse, Error>),
HandleUnsealEncryptedKey(Result<(), Error>),
HandleBootstrapEncryptedKey(Result<(), Error>),
HandleVaultState(Result<VaultState, Error>),
HandleDeclareCommittee(Result<(), Error>),
HandleContributeBootstrapPassphrase(Result<bool, Error>),
HandleContributeUnsealPassphrase(Result<bool, Error>),
}
impl Message<Inbound> for VaultGate {
type Reply = Result<Outbound, Error>;
async fn handle(
&mut self,
msg: Inbound,
_ctx: &mut kameo::prelude::Context<Self, Self::Reply>,
) -> Self::Reply {
match msg {
Inbound::HandleHandshake(message) => Ok(Outbound::HandleHandshake(
self.handle_handshake(message.client_pubkey),
)),
Inbound::HandleUnsealEncryptedKey(message) => Ok(Outbound::HandleUnsealEncryptedKey(
self.handle_unseal_encrypted_key(
message.nonce,
message.ciphertext,
message.associated_data,
)
.await,
)),
Inbound::HandleBootstrapEncryptedKey(message) => {
Ok(Outbound::HandleBootstrapEncryptedKey(
self.handle_bootstrap_encrypted_key(
message.nonce,
message.ciphertext,
message.associated_data,
)
.await,
))
}
Inbound::HandleVaultState => {
Ok(Outbound::HandleVaultState(self.handle_vault_state().await))
}
Inbound::HandleDeclareCommittee(message) => Ok(Outbound::HandleDeclareCommittee(
self.handle_declare_committee(message.count).await,
)),
Inbound::HandleContributeBootstrapPassphrase(message) => {
Ok(Outbound::HandleContributeBootstrapPassphrase(
self.handle_contribute_bootstrap_passphrase(message.passphrase)
.await,
))
}
Inbound::HandleContributeUnsealPassphrase(message) => {
Ok(Outbound::HandleContributeUnsealPassphrase(
self.handle_contribute_unseal_passphrase(message.passphrase)
.await,
))
}
}
}
}

View File

@@ -1,12 +1,12 @@
use super::common::ChannelTransport;
use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT};
use arbiter_crypto::authn::{self, AuthChallenge, SigningContext};
use arbiter_proto::{
ClientMetadata,
transport::{Receiver, Sender},
};
use arbiter_server::{
actors::{GlobalActors, vault::Bootstrap},
crypto::{KeyCell, integrity},
crypto::integrity,
db::{self, schema},
peers::client::{ClientConnection, ClientCredentials, auth, connect_client},
};
@@ -71,7 +71,7 @@ async fn insert_registered_client(
fn sign_client_challenge(key: &SigningKey<MlDsa87>, challenge: &AuthChallenge) -> authn::Signature {
let challenge = challenge.format();
key.signing_key()
.sign_deterministic(&challenge, CLIENT_CONTEXT)
.sign_deterministic(&challenge, SigningContext::Client.as_bytes())
.unwrap()
.into()
}
@@ -97,8 +97,7 @@ async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors {
actors
.vault
.ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]),
custody: None,
seal_key: arbiter_server::crypto::KeyCell::from([0u8; 32]),
})
.await
.unwrap();
@@ -264,7 +263,7 @@ pub async fn metadata_unchanged_does_not_append_history() {
#[tokio::test]
#[test_log::test]
pub async fn metadata_frozen_after_approval_ignores_reconnect_changes() {
pub async fn metadata_change_appends_history_and_repoints_binding() {
let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
let new_key = MlDsa87::key_gen(&mut rand::rng());
@@ -285,7 +284,6 @@ pub async fn metadata_frozen_after_approval_ignores_reconnect_changes() {
connect_client(props, &mut server_transport).await;
});
// Reconnect presenting different metadata — must be silently ignored.
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: verifying_key(&new_key).into(),
@@ -312,7 +310,6 @@ pub async fn metadata_frozen_after_approval_ignores_reconnect_changes() {
client_metadata, client_metadata_history, program_client,
};
let mut conn = db.get().await.unwrap();
// Metadata is frozen: no new row, no history entry.
let metadata_count: i64 = client_metadata::table
.count()
.get_result(&mut conn)
@@ -338,19 +335,15 @@ pub async fn metadata_frozen_after_approval_ignores_reconnect_changes() {
.first::<(String, Option<String>, Option<String>)>(&mut conn)
.await
.unwrap();
assert_eq!(
metadata_count, 1,
"frozen: no new metadata row on reconnect"
);
assert_eq!(history_count, 0, "frozen: no history entry on reconnect");
assert_eq!(metadata_count, 2);
assert_eq!(history_count, 1);
assert_eq!(
current,
(
"client".to_owned(),
Some("old".to_owned()),
Some("1.0.0".to_owned())
),
"frozen: original metadata must be preserved"
Some("new".to_owned()),
Some("2.0.0".to_owned())
)
);
}
}

View File

@@ -2,30 +2,23 @@
dead_code,
reason = "Common test utilities that may not be used in every test"
)]
use arbiter_proto::transport::{Bi, Error, Receiver, Sender};
use arbiter_server::{
actors::{GlobalActors, vault::Vault},
crypto::KeyCell,
db::{self, custody::DieselCustodyStore, schema},
db::{self, schema},
};
use async_trait::async_trait;
use diesel::QueryDsl;
use diesel_async::RunQueryDsl;
use std::sync::Arc;
use tokio::sync::mpsc;
pub(crate) async fn bootstrapped_vault(db: &db::DatabasePool) -> Vault {
let mut actor = Vault::new(
db.clone(),
GlobalActors::spawn_message_bus(),
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
actor
.bootstrap(KeyCell::from([0u8; 32]), None)
.bootstrap(arbiter_server::crypto::KeyCell::from([0u8; 32]))
.await
.unwrap();
actor

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -5,21 +5,15 @@ use arbiter_server::{
GlobalActors,
vault::{CreateNew, Error, Vault},
},
crypto::KeyCell,
db::{self, custody::DieselCustodyStore, models, schema},
db::{self, models, schema},
};
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper, dsl::sql_query};
use diesel_async::RunQueryDsl;
use kameo::actor::{ActorRef, Spawn as _};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use std::collections::{HashMap, HashSet};
use tokio::task::JoinSet;
const TEST_AAD: &[u8] = b"test-aad";
async fn write_concurrently(
actor: ActorRef<Vault>,
prefix: &'static str,
@@ -33,7 +27,6 @@ async fn write_concurrently(
let id = actor
.ask(CreateNew {
plaintext: SafeCell::new(plaintext.clone()),
aad: TEST_AAD.to_vec(),
})
.await
.unwrap();
@@ -127,7 +120,7 @@ async fn insert_failure_does_not_create_partial_row() {
drop(conn);
let err = actor
.create_new(SafeCell::new(b"should fail".to_vec()), TEST_AAD.to_vec())
.create_new(SafeCell::new(b"should fail".to_vec()))
.await
.unwrap_err();
assert!(matches!(err, Error::DatabaseTransaction(_)));
@@ -169,20 +162,16 @@ async fn decrypt_roundtrip_after_high_concurrency() {
let writes = write_concurrently(actor, "roundtrip", 40).await;
let expected: HashMap<i32, Vec<u8>> = writes.into_iter().collect();
let mut decryptor = Vault::new(
db.clone(),
GlobalActors::spawn_message_bus(),
Arc::new(DieselCustodyStore),
)
.await
.unwrap();
let mut decryptor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
decryptor
.try_unseal(KeyCell::from([0u8; 32]))
.try_unseal(arbiter_server::crypto::KeyCell::from([0u8; 32]))
.await
.unwrap();
for (id, plaintext) in expected {
let mut decrypted = decryptor.decrypt(id, TEST_AAD.to_vec()).await.unwrap();
let mut decrypted = decryptor.decrypt(id).await.unwrap();
assert_eq!(*decrypted.read(), plaintext);
}
}

View File

@@ -1,317 +0,0 @@
//! End-to-end coverage for the Shamir custody ceremonies.
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_server::{
actors::{
GlobalActors,
vault::{Bootstrap, GetState, Seal, VaultState},
vault_coordinator::{ContributeBootstrap, ContributeUnseal, StartBootstrap},
},
crypto::{KeyCell, shamir},
db::{self, models::OperatorId, schema},
};
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
/// Register `count` operator identities so committee members satisfy the
/// foreign key from `operator` to `operator_identity`.
async fn register_operators(db: &db::DatabasePool, count: usize) -> Vec<OperatorId> {
let mut conn = db.get().await.unwrap();
let mut ids = Vec::with_capacity(count);
for index in 0..count {
let pubkey = vec![u8::try_from(index).unwrap(); 32];
let id: OperatorId = diesel::insert_into(schema::operator_identity::table)
.values((schema::operator_identity::public_key.eq(pubkey),))
.returning(schema::operator_identity::id)
.get_result(&mut conn)
.await
.unwrap();
ids.push(id);
}
ids
}
async fn stored_share_count(db: &db::DatabasePool) -> i64 {
let mut conn = db.get().await.unwrap();
schema::operator::table
.count()
.get_result(&mut conn)
.await
.unwrap()
}
async fn stored_threshold(db: &db::DatabasePool) -> Option<i32> {
let mut conn = db.get().await.unwrap();
schema::arbiter_settings::table
.select(schema::arbiter_settings::shamir_threshold)
.first(&mut conn)
.await
.unwrap()
}
fn passphrase(seed: u8) -> SafeCell<Vec<u8>> {
SafeCell::new(vec![seed; 16])
}
/// The happy path: three operators bootstrap, and any two of them reopen the
/// vault after it is sealed.
#[tokio::test]
#[test_log::test]
async fn committee_of_three_unseals_with_two_passphrases() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let operators = register_operators(&db, 3).await;
actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: 3,
})
.await
.unwrap();
for (index, operator_id) in operators.iter().enumerate() {
let finished = actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: *operator_id,
passphrase: passphrase(u8::try_from(index).unwrap()),
})
.await
.unwrap();
assert_eq!(
finished,
index == 2,
"the ceremony finishes only on the last contribution"
);
}
assert_eq!(
actors.vault.ask(GetState {}).await.unwrap(),
VaultState::Unsealed
);
assert_eq!(stored_share_count(&db).await, 3);
assert_eq!(stored_threshold(&db).await, Some(2));
actors.vault.ask(Seal {}).await.unwrap();
let first = actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: operators[0],
passphrase: passphrase(0),
})
.await
.unwrap();
assert!(!first, "one of two shares must not unseal");
let second = actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: operators[2],
passphrase: passphrase(2),
})
.await
.unwrap();
assert!(second, "the threshold contribution should unseal the vault");
assert_eq!(
actors.vault.ask(GetState {}).await.unwrap(),
VaultState::Unsealed
);
}
/// Shares that describe a seal key the vault never adopted would make the vault
/// permanently un-unsealable, so a refused bootstrap must leave the table empty.
#[tokio::test]
#[test_log::test]
async fn refused_bootstrap_stores_no_shares() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let operators = register_operators(&db, 1).await;
// Another path bootstraps the vault first; the ceremony now has nowhere to go.
actors
.vault
.ask(Bootstrap {
seal_key: KeyCell::from([4u8; 32]),
custody: None,
})
.await
.unwrap();
actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: 1,
})
.await
.unwrap();
let error = actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: operators[0],
passphrase: passphrase(1),
})
.await
.expect_err("bootstrapping an already bootstrapped vault must fail");
assert!(
format!("{error:?}").contains("AlreadyBootstrapped"),
"expected AlreadyBootstrapped, got {error:?}"
);
assert_eq!(
stored_share_count(&db).await,
0,
"a refused bootstrap must not leave shares behind"
);
assert_eq!(
stored_threshold(&db).await,
None,
"a refused bootstrap must not leave a threshold behind"
);
}
#[tokio::test]
#[test_log::test]
async fn oversized_and_degenerate_committees_are_rejected() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let operators = register_operators(&db, 1).await;
for (count, expected) in [
(0_usize, "EmptyCommittee"),
(2, "UnsupportedCommittee"),
(shamir::MAX_COMMITTEE_SIZE + 1, "CommitteeTooLarge"),
(usize::MAX, "CommitteeTooLarge"),
] {
let error = actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: count,
})
.await
.expect_err("committee size must be rejected");
assert!(
format!("{error:?}").contains(expected),
"expected {expected} for count {count}, got {error:?}"
);
}
}
/// A committee whose members never all show up would otherwise wedge the
/// coordinator until restart. Only the operator that declared it may reset it.
#[tokio::test]
#[test_log::test]
async fn only_the_declarer_may_restart_a_stalled_committee() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let operators = register_operators(&db, 3).await;
actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: 3,
})
.await
.unwrap();
actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: operators[0],
passphrase: passphrase(0),
})
.await
.unwrap();
let error = actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[1],
declared_count: 3,
})
.await
.expect_err("a bystander must not reset someone else's ceremony");
assert!(
format!("{error:?}").contains("AlreadyBootstrapping"),
"expected AlreadyBootstrapping, got {error:?}"
);
actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: 1,
})
.await
.expect("the declarer may restart the ceremony");
let finished = actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: operators[0],
passphrase: passphrase(0),
})
.await
.unwrap();
assert!(
finished,
"the restarted one-operator ceremony should complete"
);
assert_eq!(stored_share_count(&db).await, 1);
}
/// A wrong passphrase must not unseal, and the operator must be able to retry.
#[tokio::test]
#[test_log::test]
async fn wrong_passphrase_is_rejected_and_retryable() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let operators = register_operators(&db, 1).await;
actors
.vault_coordinator
.ask(StartBootstrap {
operator_id: operators[0],
declared_count: 1,
})
.await
.unwrap();
actors
.vault_coordinator
.ask(ContributeBootstrap {
operator_id: operators[0],
passphrase: passphrase(7),
})
.await
.unwrap();
actors.vault.ask(Seal {}).await.unwrap();
let error = actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: operators[0],
passphrase: passphrase(8),
})
.await
.expect_err("a wrong passphrase must not unseal");
assert!(
format!("{error:?}").contains("InvalidPassphrase"),
"expected InvalidPassphrase, got {error:?}"
);
let unsealed = actors
.vault_coordinator
.ask(ContributeUnseal {
operator_id: operators[0],
passphrase: passphrase(7),
})
.await
.expect("the operator may retry with the correct passphrase");
assert!(unsealed, "the retry should unseal the vault");
}

View File

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

View File

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