Compare commits

..

48 Commits

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

View File

@@ -2,6 +2,8 @@ syntax = "proto3";
package arbiter.operator.governance;
import "google/protobuf/empty.proto";
message Request {
oneof payload {
CreateProposalRequest create = 1;
@@ -13,14 +15,13 @@ message Request {
message CreateProposalRequest {
oneof kind {
ApproveSdkClientPayload approve_sdk_client = 1;
GrantWalletAccessPayload grant_wallet_access = 3;
ApproveServerUpdatePayload approve_server_update = 4;
ReplaceOperatorPayload replace_operator = 5;
UpdateShamirParametersPayload update_shamir_parameters = 6;
ApprovePersistentGrantPayload approve_persistent_grant = 7;
ApproveOneOffTransactionPayload approve_one_off_transaction = 8;
GrantWalletAccessPayload grant_wallet_access = 2;
ReplaceOperatorPayload replace_operator = 3;
google.protobuf.Empty trigger_rekey = 4;
ApprovePersistentGrantPayload approve_persistent_grant = 5;
ApproveOneOffTransactionPayload approve_one_off_transaction = 6;
}
optional uint32 ttl_secs = 2;
optional uint32 ttl_secs = 7;
}
message ReplaceOperatorPayload {
@@ -28,12 +29,6 @@ message ReplaceOperatorPayload {
bytes new_pubkey = 2;
}
message UpdateShamirParametersPayload {
uint32 new_n = 1;
}
message ApproveServerUpdatePayload {}
message ApproveSdkClientPayload {
int32 client_id = 1;
}

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

View File

@@ -1,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,7 +1,8 @@
use crate::{
actors::{
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
operator_registry::OperatorRegistry, vault::Vault, vault_coordinator::VaultCoordinator,
operator_registry::OperatorRegistry, proposal_manager::ProposalManager, vault::Vault,
vault_coordinator::VaultCoordinator,
},
db,
};
@@ -14,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;
@@ -21,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>,
}
@@ -44,22 +47,30 @@ impl GlobalActors {
}
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
let events = Self::spawn_message_bus();
let vault = Vault::spawn(Vault::new(db.clone(), events.clone()).await?);
let bootstrapper = Bootstrapper::spawn(Bootstrapper::new(&db, events.clone()).await?);
let vault_coordinator =
VaultCoordinator::spawn(VaultCoordinator::new(db.clone(), vault.clone()));
let message_bus = Self::spawn_message_bus();
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
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,789 @@
use crate::{
actors::{
evm::EvmActor,
vault::Vault,
vault_coordinator::{StartRekey, VaultCoordinator},
},
crypto::governance,
db::{
self,
functions::unixepoch,
models::{
NewProposal, NewProposalVote, NewRecoveryProposalVote, NewRecoveryWakeupRequest,
OperatorIdentityId, Proposal, ProposalId, ProposalStatus, RecoveryOperatorIdentityId,
SqliteTimestamp,
},
proposal::{ProposalKind, ProposalKindTag, one_off_transaction, persistent_grant},
schema,
},
};
use chrono::Utc;
use diesel::{
ExpressionMethods as _, QueryDsl,
dsl::{exists, select},
};
use diesel_async::{AsyncConnection as _, RunQueryDsl};
use kameo::{Actor, actor::ActorRef, messages};
use std::collections::HashMap;
use strum::IntoDiscriminant as _;
use tracing::{error, warn};
pub const DEFAULT_TTL_SECS: u32 = 7 * 24 * 60 * 60; // 7 days
pub const MAX_TTL_SECS: u32 = DEFAULT_TTL_SECS;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VoteOutcome {
Pending,
Approved,
Rejected,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Proposal not found")]
ProposalNotFound,
#[error("Proposal is not pending")]
ProposalNotPending,
#[error("Proposal has expired")]
ProposalExpired,
#[error("Requested TTL exceeds the maximum of {} seconds", MAX_TTL_SECS)]
TtlTooLong,
#[error("Operator already voted on this proposal")]
AlreadyVoted,
#[error("Invalid vote signature")]
InvalidSignature,
#[error("Operator not found")]
OperatorNotFound,
#[error("Database connection error: {0}")]
DatabaseConnection(#[from] db::PoolError),
#[error("Database query error: {0}")]
DatabaseQuery(#[from] diesel::result::Error),
#[error("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: ProposalId,
pub kind: ProposalKindTag,
pub initiator_id: OperatorIdentityId,
pub expires_at: SqliteTimestamp,
pub approve_count: i64,
pub reject_count: i64,
}
#[derive(Actor)]
pub struct ProposalManager {
pub(crate) 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: OperatorIdentityId,
ttl_secs: Option<u32>,
) -> Result<ProposalId, Error> {
let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
if ttl > MAX_TTL_SECS {
return Err(Error::TtlTooLong);
}
let expires_at =
SqliteTimestamp::from(Utc::now() + chrono::Duration::seconds(i64::from(ttl)));
let id: ProposalId = self
.db
.get()
.await?
.transaction(async |conn| {
let id: ProposalId = diesel::insert_into(schema::proposal::table)
.values(&NewProposal {
kind: kind.discriminant(),
initiator_id,
expires_at,
})
.returning(schema::proposal::id)
.get_result(conn)
.await?;
db::proposal::insert_kind(conn, id, &kind).await?;
Ok::<_, diesel::result::Error>(id)
})
.await?;
Ok(id)
}
#[message]
pub async fn query_pending(&mut self, operator_id: OperatorIdentityId) -> 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<ProposalId> = 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 ids: Vec<ProposalId> = proposals.iter().map(|p| p.id).collect();
let tallies: Vec<(ProposalId, bool, i64)> = schema::proposal_vote::table
.filter(schema::proposal_vote::proposal_id.eq_any(&ids))
.group_by((
schema::proposal_vote::proposal_id,
schema::proposal_vote::approve,
))
.select((
schema::proposal_vote::proposal_id,
schema::proposal_vote::approve,
diesel::dsl::count_star(),
))
.load(&mut conn)
.await
.unwrap_or_default();
let mut by_proposal: HashMap<ProposalId, (i64, i64)> = HashMap::new();
for (proposal_id, approve, count) in tallies {
let entry = by_proposal.entry(proposal_id).or_insert((0, 0));
if approve {
entry.0 += count;
} else {
entry.1 += count;
}
}
proposals
.into_iter()
.map(|p| {
let (approve_count, reject_count) =
by_proposal.get(&p.id).copied().unwrap_or((0, 0));
ProposalSummary {
id: p.id,
kind: p.kind,
initiator_id: p.initiator_id,
expires_at: p.expires_at,
approve_count,
reject_count,
}
})
.collect()
}
#[message]
pub async fn cast_vote(
&mut self,
proposal_id: ProposalId,
operator_id: OperatorIdentityId,
approve: bool,
signature: Vec<u8>,
) -> Result<VoteOutcome, Error> {
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 already_voted: bool = select(exists(
schema::proposal_vote::table
.filter(schema::proposal_vote::proposal_id.eq(proposal_id))
.filter(schema::proposal_vote::operator_id.eq(operator_id)),
))
.get_result(&mut conn)
.await?;
if already_voted {
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),
})?;
governance::verify_vote(&pubkey_bytes, proposal_id, approve, &signature)
.map_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 proposal.kind.requires_full_quorum() {
// §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::Approved);
}
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::Rejected);
}
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: OperatorIdentityId,
) -> 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: OperatorIdentityId,
) -> 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: ProposalId,
recovery_operator_id: RecoveryOperatorIdentityId,
approve: bool,
signature: Vec<u8>,
) -> Result<VoteOutcome, Error> {
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 != ProposalKindTag::ReplaceOperator {
return Err(Error::NotAllowedForRecoveryOperator);
}
if !Self::is_recovery_active_conn(&mut conn).await? {
return Err(Error::RecoveryNotActive);
}
let already_voted: bool = select(exists(
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),
),
))
.get_result(&mut conn)
.await?;
if already_voted {
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),
})?;
governance::verify_vote(&pubkey_bytes, proposal_id, approve, &signature)
.map_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::Approved);
}
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::Rejected);
}
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> {
select(exists(
schema::recovery_wakeup_request::table
.filter(schema::recovery_wakeup_request::cancelled_at.is_null())
.filter(
schema::recovery_wakeup_request::requested_at
.le(unixepoch("now") - Self::WAKEUP_DELAY_SECS),
),
))
.get_result(conn)
.await
.map_err(Error::from)
}
/// Returns true when there is any uncancelled wakeup request (pending or active).
async fn has_uncancelled_wakeup(conn: &mut db::DatabaseConnection) -> Result<bool, Error> {
select(exists(schema::recovery_wakeup_request::table.filter(
schema::recovery_wakeup_request::cancelled_at.is_null(),
)))
.get_result(conn)
.await
.map_err(Error::from)
}
async fn execute_proposal(&self, proposal: &Proposal) -> Result<(), Error> {
let mut conn = self.db.get().await?;
let kind = db::proposal::load_kind(&mut conn, proposal.id, proposal.kind).await?;
drop(conn);
match kind {
ProposalKind::ApproveSdkClient(s) => self.execute_approve_sdk_client(s.client_id).await,
ProposalKind::GrantWalletAccess(s) => {
self.execute_grant_wallet_access(s.wallet_id, s.client_id)
.await
}
ProposalKind::ReplaceOperator(s) => {
self.execute_replace_operator(s.old_operator_id, s.new_pubkey)
.await
}
ProposalKind::TriggerRekey => self.execute_trigger_rekey().await,
ProposalKind::ApprovePersistentGrant(grant) => {
self.execute_approve_persistent_grant(*grant).await
}
ProposalKind::ApproveOneOffTransaction(tx) => {
self.execute_approve_one_off_transaction(proposal.id, *tx)
.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: OperatorIdentityId,
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_trigger_rekey(&self) -> 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: ProposalId,
tx: one_off_transaction::Settings,
) -> Result<(), Error> {
use crate::actors::evm::ClientSignTransaction;
use alloy::{
consensus::TxEip1559,
eips::eip2930::AccessList,
primitives::{Address, Bytes, TxKind, U256},
};
let transaction = TxEip1559 {
chain_id: tx.chain_id,
nonce: tx.nonce,
gas_limit: tx.gas_limit,
max_fee_per_gas: tx.max_fee_per_gas,
max_priority_fee_per_gas: tx.max_priority_fee_per_gas,
to: TxKind::Call(Address::from(tx.to)),
value: U256::from_be_bytes(tx.value),
input: Bytes::from(tx.input),
access_list: AccessList::default(),
};
let sig = self
.evm
.ask(ClientSignTransaction {
client_id: tx.client_id,
wallet_address: Address::from(tx.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)?;
one_off_transaction::store_signature(proposal_id, &sig, &mut conn)
.await
.map_err(|e| Error::ExecutionFailed(format!("store proposal result: {e}")))?;
Ok(())
}
async fn execute_approve_persistent_grant(
&self,
grant: persistent_grant::Settings,
) -> Result<(), Error> {
use crate::{
actors::evm::OperatorCreateGrant,
evm::policies::{
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit,
ether_transfer, token_transfers,
},
};
use alloy::primitives::{Address, U256};
use chrono::Duration;
let volume = |limit: persistent_grant::VolumeLimit| VolumeRateLimit {
max_volume: U256::from_be_bytes(limit.max_volume),
window: Duration::seconds(limit.window_secs),
};
let basic = SharedGrantSettings {
wallet_access_id: grant.wallet_access_id,
chain: grant.chain_id,
valid_from: grant
.valid_from_secs
.and_then(|s| chrono::DateTime::from_timestamp(s, 0)),
valid_until: grant
.valid_until_secs
.and_then(|s| chrono::DateTime::from_timestamp(s, 0)),
max_gas_fee_per_gas: grant.max_gas_fee_per_gas.map(U256::from_be_bytes),
max_priority_fee_per_gas: grant.max_priority_fee_per_gas.map(U256::from_be_bytes),
rate_limit: grant.rate_limit.map(|r| TransactionRateLimit {
count: r.count,
window: Duration::seconds(r.window_secs),
}),
};
let grant = match grant.specific {
persistent_grant::Specific::EtherTransfer { targets, limit } => {
SpecificGrant::EtherTransfer(ether_transfer::Settings {
target: targets.into_iter().map(Address::from).collect(),
limit: volume(limit),
})
}
persistent_grant::Specific::TokenTransfer {
token_contract,
receiver,
volume_limits,
} => SpecificGrant::TokenTransfer(token_transfers::Settings {
token_contract: Address::from(token_contract),
target: receiver.map(Address::from),
volume_limits: volume_limits.into_iter().map(volume).collect(),
}),
};
self.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,12 +6,10 @@ use crate::{
},
db::{
self,
custody::CustodyRecord,
models::{self, RootKeyHistory, RootKeyHistoryId},
schema::{self},
schema,
},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use chrono::Utc;
@@ -24,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)]
@@ -47,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,
@@ -62,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 {
@@ -89,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.
@@ -100,10 +88,8 @@ pub struct Vault {
db: db::DatabasePool,
state: State,
events: ActorRef<MessageBus>,
unseal_failures: u32,
}
#[messages]
impl Vault {
pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
let state = {
@@ -123,15 +109,10 @@ impl Vault {
}
};
Ok(Self {
db,
state,
events,
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,
@@ -176,36 +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 root_key_history_id = conn
.transaction(async |conn| {
let root_key_history_id = insert_into(schema::root_key_history::table)
@@ -215,7 +197,7 @@ impl Vault {
root_key_encryption_nonce: root_key_nonce.to_vec(),
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)
@@ -226,11 +208,9 @@ impl Vault {
.execute(&mut *conn)
.await?;
if let Some(record) = custody.as_ref() {
db::custody::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?;
@@ -247,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");
@@ -311,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 = {
@@ -336,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,
@@ -356,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);
@@ -416,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| {
@@ -431,25 +459,12 @@ 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;
use arbiter_crypto::safecell::SafeCellHandle as _;
use super::*;
@@ -457,8 +472,7 @@ mod tests {
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
let seal_key = KeyCell::from([0u8; 32]);
actor.bootstrap(seal_key, None).await.unwrap();
actor.bootstrap(KeyCell::from([0u8; 32])).await.unwrap();
actor
}
@@ -467,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)
@@ -493,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,107 +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 the [`db::custody`] functions.
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, 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 `trigger_rekey` is approved (§3.3).
/// Collects new passphrases from all current operators, then generates a fresh seal key,
/// re-splits it, and re-encrypts the vault root key.
Rekeying {
ordinary_count: usize,
recovery_count: usize,
passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
},
}
@@ -122,257 +90,632 @@ impl VaultCoordinator {
}
}
/// 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,
vault: &ActorRef<Vault>,
threshold: usize,
contributions: &mut Contributions,
) -> Result<(), Error> {
let stored = {
let mut conn = db.get().await?;
db::custody::shares(&mut conn, &contributions.operators()).await?
// 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 `trigger_rekey` is approved and all contributors submit.
async fn finalize_rekey(
db: db::DatabasePool,
vault: ActorRef<Vault>,
ordinary_passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
) -> Result<(), Error> {
let ordinary_count = ordinary_passphrases.len();
let recovery_count = recovery_passphrases.len();
let total = ordinary_count + recovery_count;
let threshold = shamir_threshold(ordinary_count);
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?;
db::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.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

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

View File

@@ -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),
}
}
@@ -215,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 {
@@ -237,8 +233,7 @@ mod tests {
);
actor
.ask(Bootstrap {
seal_key: KeyCell::from([0u8; 32]),
custody: None,
seal_key: crate::crypto::KeyCell::from([0u8; 32]),
})
.await
.unwrap();
@@ -334,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

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

View File

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

View File

@@ -8,8 +8,9 @@ use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use thiserror::Error;
use tracing::info;
pub mod custody;
pub mod functions;
pub mod models;
pub mod proposal;
pub mod schema;
pub type DatabaseConnection = SyncConnectionWrapper<SqliteConnection>;
@@ -155,39 +156,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

@@ -9,16 +9,18 @@ use crate::db::schema::{
integrity_envelope, root_key_history, tls_history,
};
use crate::db::proposal::ProposalKindTag;
use diesel::{prelude::*, sqlite::Sqlite};
use restructed::Models;
pub mod types {
use chrono::{DateTime, Utc};
use diesel::{
backend::Backend,
deserialize::{FromSql, FromSqlRow},
expression::AsExpression,
serialize::{IsNull, ToSql},
sql_types::Integer,
sql_types::{Integer, Text},
sqlite::{Sqlite, SqliteType},
};
@@ -61,7 +63,7 @@ pub mod types {
impl FromSql<Integer, Sqlite> for SqliteTimestamp {
fn from_sql(
mut bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
mut bytes: <Sqlite as Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
let Some(SqliteType::Long) = bytes.value_type() else {
return Err(format!(
@@ -110,18 +112,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 +143,42 @@ pub mod types {
declare_id!(TlsHistoryId);
declare_id!(EvmWalletId);
declare_id!(ClientId);
declare_id!(ProposalId);
declare_id!(RecoveryOperatorIdentityId);
#[derive(Debug, Clone, PartialEq, Eq, AsExpression, FromSqlRow)]
#[diesel(sql_type = Text)]
pub enum ProposalStatus {
Pending,
Approved,
Rejected,
}
impl ToSql<Text, Sqlite> for ProposalStatus {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
let s: &str = match self {
Self::Pending => "pending",
Self::Approved => "approved",
Self::Rejected => "rejected",
};
<str as ToSql<Text, Sqlite>>::to_sql(s, out)
}
}
impl FromSql<Text, Sqlite> for ProposalStatus {
fn from_sql(bytes: <Sqlite as Backend>::RawValue<'_>) -> diesel::deserialize::Result<Self> {
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
match s.as_str() {
"pending" => Ok(Self::Pending),
"approved" => Ok(Self::Approved),
"rejected" => Ok(Self::Rejected),
other => Err(format!("Unknown proposal status: {other}").into()),
}
}
}
}
pub use types::*;
@@ -215,7 +241,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 +476,58 @@ 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: ProposalId,
pub kind: ProposalKindTag,
pub initiator_id: OperatorIdentityId,
pub created_at: SqliteTimestamp,
pub expires_at: SqliteTimestamp,
pub status: ProposalStatus,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
pub struct NewProposal {
pub kind: ProposalKindTag,
pub initiator_id: OperatorIdentityId,
// status defaults to 'pending' at the DB layer
pub expires_at: SqliteTimestamp,
}
#[derive(Debug, Queryable, Selectable, Identifiable)]
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
pub struct ProposalVote {
pub id: i32,
pub proposal_id: ProposalId,
pub operator_id: OperatorIdentityId,
pub approve: bool,
pub signature: Vec<u8>,
pub voted_at: SqliteTimestamp,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
pub struct NewProposalVote {
pub proposal_id: ProposalId,
pub operator_id: OperatorIdentityId,
pub approve: bool,
pub signature: Vec<u8>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::recovery_proposal_vote, check_for_backend(Sqlite))]
pub struct NewRecoveryProposalVote {
pub proposal_id: ProposalId,
pub recovery_operator_id: RecoveryOperatorIdentityId,
pub approve: bool,
pub signature: Vec<u8>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::recovery_wakeup_request, check_for_backend(Sqlite))]
pub struct NewRecoveryWakeupRequest {
pub requested_by: OperatorIdentityId,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,6 @@ diesel::table! {
id -> Integer,
root_key_id -> Nullable<Integer>,
tls_id -> Nullable<Integer>,
shamir_threshold -> Nullable<Integer>,
}
}
@@ -173,6 +172,165 @@ diesel::table! {
}
}
diesel::table! {
proposal (id) {
id -> Integer,
kind -> Text,
initiator_id -> Integer,
created_at -> Integer,
expires_at -> Integer,
status -> Text,
}
}
diesel::table! {
proposal_approve_sdk_client (proposal_id) {
proposal_id -> Integer,
client_id -> Integer,
}
}
diesel::table! {
proposal_grant_wallet_access (proposal_id) {
proposal_id -> Integer,
wallet_id -> Integer,
client_id -> Integer,
}
}
diesel::table! {
proposal_replace_operator (proposal_id) {
proposal_id -> Integer,
old_operator_id -> Integer,
new_pubkey -> Binary,
}
}
diesel::table! {
proposal_one_off_transaction (proposal_id) {
proposal_id -> Integer,
client_id -> Integer,
wallet_address -> Binary,
chain_id -> BigInt,
nonce -> BigInt,
gas_limit -> BigInt,
max_fee_per_gas -> Binary,
max_priority_fee_per_gas -> Binary,
to_address -> Binary,
value -> Binary,
input -> Binary,
}
}
diesel::table! {
proposal_persistent_grant (proposal_id) {
proposal_id -> Integer,
wallet_access_id -> Integer,
chain_id -> BigInt,
valid_from -> Nullable<BigInt>,
valid_until -> Nullable<BigInt>,
max_gas_fee_per_gas -> Nullable<Binary>,
max_priority_fee_per_gas -> Nullable<Binary>,
rate_limit_count -> Nullable<Integer>,
rate_limit_window_secs -> Nullable<BigInt>,
}
}
diesel::table! {
proposal_persistent_grant_ether (proposal_id) {
proposal_id -> Integer,
window_secs -> BigInt,
max_volume -> Binary,
}
}
diesel::table! {
proposal_persistent_grant_ether_target (id) {
id -> Integer,
proposal_id -> Integer,
address -> Binary,
}
}
diesel::table! {
proposal_persistent_grant_token (proposal_id) {
proposal_id -> Integer,
token_contract -> Binary,
receiver -> Nullable<Binary>,
}
}
diesel::table! {
proposal_persistent_grant_token_limit (id) {
id -> Integer,
proposal_id -> Integer,
window_secs -> BigInt,
max_volume -> Binary,
}
}
diesel::table! {
proposal_one_off_transaction_result (proposal_id) {
proposal_id -> Integer,
r -> Binary,
s -> Binary,
y_parity -> Integer,
created_at -> Integer,
}
}
diesel::table! {
recovery_operator (id) {
id -> Integer,
share -> Binary,
share_nonce -> Binary,
share_salt -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
recovery_operator_identity (id) {
id -> Integer,
public_key -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
recovery_wakeup_request (id) {
id -> Integer,
requested_by -> Integer,
requested_at -> Integer,
cancelled_by -> Nullable<Integer>,
cancelled_at -> Nullable<Integer>,
}
}
diesel::table! {
recovery_proposal_vote (id) {
id -> Integer,
proposal_id -> Integer,
recovery_operator_id -> Integer,
approve -> Bool,
signature -> Binary,
voted_at -> Integer,
}
}
diesel::table! {
proposal_vote (id) {
id -> Integer,
proposal_id -> Integer,
operator_id -> Integer,
approve -> Bool,
signature -> Binary,
voted_at -> Integer,
}
}
diesel::table! {
program_client (id) {
id -> Integer,
@@ -226,9 +384,38 @@ diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id));
diesel::joinable!(evm_wallet_access -> program_client (client_id));
diesel::joinable!(operator -> operator_identity (id));
diesel::joinable!(program_client -> client_metadata (metadata_id));
diesel::joinable!(proposal -> operator_identity (initiator_id));
diesel::joinable!(proposal_one_off_transaction_result -> proposal_one_off_transaction (proposal_id));
diesel::joinable!(proposal_approve_sdk_client -> proposal (proposal_id));
diesel::joinable!(proposal_grant_wallet_access -> proposal (proposal_id));
diesel::joinable!(proposal_replace_operator -> proposal (proposal_id));
diesel::joinable!(proposal_one_off_transaction -> proposal (proposal_id));
diesel::joinable!(proposal_persistent_grant -> proposal (proposal_id));
diesel::joinable!(proposal_persistent_grant_ether -> proposal_persistent_grant (proposal_id));
diesel::joinable!(proposal_persistent_grant_token -> proposal_persistent_grant (proposal_id));
diesel::joinable!(proposal_vote -> proposal (proposal_id));
diesel::joinable!(proposal_vote -> operator_identity (operator_id));
diesel::joinable!(recovery_operator -> recovery_operator_identity (id));
diesel::joinable!(recovery_proposal_vote -> proposal (proposal_id));
diesel::joinable!(recovery_proposal_vote -> recovery_operator_identity (recovery_operator_id));
diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by));
diesel::allow_tables_to_appear_in_same_query!(
aead_encrypted,
proposal_one_off_transaction_result,
proposal_approve_sdk_client,
proposal_grant_wallet_access,
proposal_replace_operator,
proposal_one_off_transaction,
proposal_persistent_grant,
proposal_persistent_grant_ether,
proposal_persistent_grant_ether_target,
proposal_persistent_grant_token,
proposal_persistent_grant_token_limit,
recovery_operator,
recovery_operator_identity,
recovery_wakeup_request,
recovery_proposal_vote,
arbiter_settings,
client_metadata,
client_metadata_history,
@@ -246,6 +433,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>,
@@ -505,12 +354,8 @@ mod tests {
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::{
@@ -519,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;
@@ -554,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,
@@ -763,116 +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())
.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,253 @@
use crate::{
actors::proposal_manager::{Error as ProposalError, VoteOutcome},
db::models::{OperatorIdentityId, ProposalId},
db::proposal::{
ProposalKind, approve_sdk_client, grant_wallet_access, one_off_transaction,
persistent_grant, replace_operator,
},
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(approve_sdk_client::Settings {
client_id: p.client_id,
})
}
Some(ProtoKind::GrantWalletAccess(p)) => {
ProposalKind::GrantWalletAccess(grant_wallet_access::Settings {
wallet_id: p.wallet_id,
client_id: p.client_id,
})
}
Some(ProtoKind::ReplaceOperator(p)) => {
ProposalKind::ReplaceOperator(replace_operator::Settings {
old_operator_id: OperatorIdentityId::from_raw(p.old_operator_id),
new_pubkey: p.new_pubkey,
})
}
Some(ProtoKind::TriggerRekey(())) => ProposalKind::TriggerRekey,
Some(ProtoKind::ApprovePersistentGrant(p)) => {
ProposalKind::ApprovePersistentGrant(Box::new(parse_persistent_grant(p)?))
}
Some(ProtoKind::ApproveOneOffTransaction(p)) => {
ProposalKind::ApproveOneOffTransaction(Box::new(parse_one_off_transaction(p)?))
}
None => return Err(Status::invalid_argument("Missing proposal kind")),
};
let proposal_id = actor
.ask(HandleCreateProposal {
kind,
ttl_secs: req.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: proposal_id.to_raw(),
},
))))
}
/// Validates the grant where the request enters, so a malformed one is refused before
/// any operator votes on it instead of failing after quorum.
fn parse_persistent_grant(
p: proto_gov::ApprovePersistentGrantPayload,
) -> Result<persistent_grant::Settings, Status> {
use proto_gov::approve_persistent_grant_payload::Specific;
let volume =
|l: proto_gov::VolumeLimitProto| -> Result<persistent_grant::VolumeLimit, Status> {
Ok(persistent_grant::VolumeLimit {
max_volume: fixed(&l.max_volume, "max_volume must be 32 bytes")?,
window_secs: l.window_secs,
})
};
let specific = match p.specific {
Some(Specific::EtherTransfer(spec)) => {
let targets = spec
.targets
.iter()
.map(|target| fixed(target, "ether transfer target must be 20 bytes"))
.collect::<Result<Vec<_>, _>>()?;
let limit = spec
.limit
.ok_or_else(|| Status::invalid_argument("missing ether transfer limit"))?;
persistent_grant::Specific::EtherTransfer {
targets,
limit: volume(limit)?,
}
}
Some(Specific::TokenTransfer(spec)) => {
let volume_limits = spec
.volume_limits
.into_iter()
.map(volume)
.collect::<Result<Vec<_>, _>>()?;
persistent_grant::Specific::TokenTransfer {
token_contract: fixed(&spec.token_contract, "token_contract must be 20 bytes")?,
receiver: spec
.target
.map(|t| fixed(&t, "token transfer target must be 20 bytes"))
.transpose()?,
volume_limits,
}
}
None => return Err(Status::invalid_argument("missing grant specific")),
};
Ok(persistent_grant::Settings {
wallet_access_id: p.wallet_access_id,
chain_id: p.chain_id,
valid_from_secs: p.valid_from_secs,
valid_until_secs: p.valid_until_secs,
max_gas_fee_per_gas: p
.max_gas_fee_per_gas
.map(|v| fixed(&v, "max_gas_fee_per_gas must be 32 bytes"))
.transpose()?,
max_priority_fee_per_gas: p
.max_priority_fee_per_gas
.map(|v| fixed(&v, "max_priority_fee_per_gas must be 32 bytes"))
.transpose()?,
rate_limit: p.rate_limit.map(|r| persistent_grant::RateLimit {
count: r.count,
window_secs: r.window_secs,
}),
specific,
})
}
fn fixed<const N: usize>(bytes: &[u8], message: &'static str) -> Result<[u8; N], Status> {
<[u8; N]>::try_from(bytes).map_err(|_| Status::invalid_argument(message))
}
/// Validates the transaction where the request enters, so a malformed one is refused
/// before any operator votes on it instead of failing after quorum.
fn parse_one_off_transaction(
p: proto_gov::ApproveOneOffTransactionPayload,
) -> Result<one_off_transaction::Settings, Status> {
Ok(one_off_transaction::Settings {
client_id: p.client_id,
wallet_address: fixed(&p.wallet_address, "wallet_address must be 20 bytes")?,
chain_id: p.chain_id,
nonce: p.nonce,
gas_limit: p.gas_limit,
max_fee_per_gas: u128::from_be_bytes(fixed(
&p.max_fee_per_gas,
"max_fee_per_gas must be 16 bytes",
)?),
max_priority_fee_per_gas: u128::from_be_bytes(fixed(
&p.max_priority_fee_per_gas,
"max_priority_fee_per_gas must be 16 bytes",
)?),
to: fixed(&p.to, "to must be 20 bytes")?,
value: fixed(&p.value, "value must be 32 bytes")?,
input: p.input,
})
}
async fn handle_vote(
actor: &ActorRef<OperatorSession>,
req: proto_gov::CastVoteRequest,
) -> Result<Option<OperatorResponsePayload>, Status> {
let result = actor
.ask(HandleCastVote {
proposal_id: ProposalId::from_raw(req.proposal_id),
approve: req.approve,
signature: req.signature,
})
.await;
let outcome = match result {
Ok(VoteOutcome::Pending) => ProtoVoteOutcome::Pending,
Ok(VoteOutcome::Approved) => ProtoVoteOutcome::Approved,
Ok(VoteOutcome::Rejected) => 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.to_raw(),
kind: <&'static str>::from(s.kind).to_owned(),
initiator_id: s.initiator_id.to_raw(),
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

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

View File

@@ -3,30 +3,30 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use arbiter_server::{
actors::{
GlobalActors,
vault::{Error, Vault},
},
crypto::{
KeyCell,
encryption::v1::{Nonce, ROOT_KEY_TAG},
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, models, schema},
};
use diesel::{QueryDsl, SelectableHelper};
use diesel::{ExpressionMethods, QueryDsl, SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
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())
.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
@@ -44,25 +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())
.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));
@@ -70,19 +70,19 @@ 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())
.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);
@@ -90,19 +90,19 @@ async fn new_restores_sealed_state() {
let mut actor2 = Vault::new(db, GlobalActors::spawn_message_bus())
.await
.unwrap();
let err = actor2.decrypt(1, TEST_AAD.to_vec()).await.unwrap_err();
let err = actor2.decrypt(1).await.unwrap_err();
assert!(matches!(err, Error::Sealed));
}
#[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);
@@ -113,19 +113,19 @@ async fn unseal_correct_password() {
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);
@@ -141,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));
}