Compare commits

..

1 Commits

Author SHA1 Message Date
CleverWild
efb11d2271 refactor(arbiter-client): rewrite errors to terrros 2026-03-24 17:25:45 +01:00
383 changed files with 12744 additions and 48588 deletions

View File

@@ -1,11 +0,0 @@
---
name: Widget decomposition and provider subscriptions
description: Prefer splitting screens into multiple focused files/widgets; each widget subscribes to its own relevant providers
type: feedback
---
Split screens into multiple smaller widgets across multiple files. Each widget should subscribe only to the providers it needs (`ref.watch` at lowest possible level), rather than having one large screen widget that watches everything and passes data down as parameters.
**Why:** Reduces unnecessary rebuilds; improves readability; each file has one clear responsibility.
**How to apply:** When building a new screen, identify which sub-widgets need their own provider subscriptions and extract them into separate files (e.g., `widgets/grant_card.dart` watches enrichment providers itself, rather than the screen doing it and passing resolved strings down).

1
.gitattributes vendored
View File

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

1
.gitignore vendored
View File

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

View File

@@ -24,4 +24,4 @@ steps:
- mise install rust
- mise install protoc
- mise install cargo:cargo-nextest
- mise exec cargo:cargo-nextest -- cargo nextest run --no-fail-fast --all-features
- mise exec cargo:cargo-nextest -- cargo nextest run --no-fail-fast

View File

@@ -6,7 +6,7 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t
Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of:
- **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies
- **`operator/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
- **`useragent/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
- **`protobufs/`** — Protocol Buffer definitions shared between server and client
The vault never exposes key material; it only produces signatures when requests satisfy configured policies.
@@ -28,7 +28,7 @@ Key versions: Rust 1.93.0 (with clippy), Flutter 3.38.9-stable, protoc 29.6, die
|---|---|
| `arbiter-proto` | Generated gRPC stubs + protobuf types; compiled from `protobufs/*.proto` via `tonic-prost-build` |
| `arbiter-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation |
| `arbiter-operator` | Rust client library for the operator side of the gRPC protocol |
| `arbiter-useragent` | Rust client library for the user agent side of the gRPC protocol |
| `arbiter-client` | Rust client library for SDK clients |
### Common Commands
@@ -66,11 +66,11 @@ cargo insta review
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
- **`Vault`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
- **`FlowCoordinator`** — Coordinates cross-connection flow between operators and SDK clients.
- **`KeyHolder`** — 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.
- **`MessageRouter`** — Coordinates streaming messages between user agents and SDK clients.
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
Per-connection actors live under `actors/operator/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
Per-connection actors live under `actors/user_agent/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
**Database:** SQLite via `diesel-async` + `bb8` connection pool. Schema managed by embedded Diesel migrations in `crates/arbiter-server/migrations/`. DB file lives at `~/.arbiter/arbiter.sqlite`. Tests use a temp-file DB via `db::create_test_pool()`.
@@ -100,41 +100,20 @@ diesel migration generate <name> --migration-dir crates/arbiter-server/migration
diesel migration run --migration-dir crates/arbiter-server/migrations
```
### Code Conventions
## User Agent (Flutter + Rinf at `useragent/`)
**`#[must_use]` Attribute:**
Apply the `#[must_use]` attribute to return types of functions where the return value is critical and should not be accidentally ignored. This is commonly used for:
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `useragent/native/hub/` as a separate crate that uses `arbiter-useragent` for the gRPC client.
- Methods that return `bool` indicating success/failure or validation state
- Any function where ignoring the return value indicates a logic error
Do not apply `#[must_use]` redundantly to items (types or functions) that are already annotated with `#[must_use]`.
Example:
```rust
#[must_use]
pub fn verify(&self, nonce: i32, context: &[u8], signature: &Signature) -> bool {
// verification logic
}
```
This forces callers to either use the return value or explicitly ignore it with `let _ = ...;`, preventing silent failures.
## Operator (Flutter + Rinf at `operator/`)
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `operator/native/hub/` as a separate crate that uses `arbiter-operator` for the gRPC client.
Communication between Dart and Rust uses typed **signals** defined in `operator/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
Communication between Dart and Rust uses typed **signals** defined in `useragent/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
```sh
cd operator && rinf gen
cd useragent && rinf gen
```
### Common Commands
```sh
cd operator
cd useragent
# Run the app (macOS or Windows)
flutter run
@@ -146,4 +125,4 @@ rinf gen
flutter analyze
```
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.
The Rinf Rust entry point is `useragent/native/hub/src/lib.rs`. It spawns actors defined in `useragent/native/hub/src/actors/` which handle Dart↔server communication via signals.

155
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,155 @@
# Arbiter
Arbiter is a permissioned signing service for cryptocurrency wallets. It runs as a background service on the user's machine with an optional client application for vault management.
**Core principle:** The vault NEVER exposes key material. It only produces signatures when a request satisfies the configured policies.
---
## 1. Peer Types
Arbiter distinguishes two kinds of peers:
- **User Agent** — A client application used by the owner to manage the vault (create wallets, approve SDK clients, configure policies).
- **SDK Client** — A consumer of signing capabilities, typically an automation tool. In the future, this could include a browser-based wallet.
---
## 2. Authentication
### 2.1 Challenge-Response
All peers authenticate via public-key cryptography using a challenge-response protocol:
1. The peer sends its public key and requests a challenge.
2. The server looks up the key in its database. If found, it increments the nonce and returns a challenge (replay-attack protection).
3. The peer signs the challenge with its private key and sends the signature back.
4. The server verifies the signature:
- **Pass:** The connection is considered authenticated.
- **Fail:** The server closes the connection.
### 2.2 User Agent Bootstrap
On first run — when no User Agents are registered — the server generates a one-time bootstrap token. It is made available in two ways:
- **Local setup:** Written to `~/.arbiter/bootstrap_token` for automatic discovery by a co-located User Agent.
- **Remote setup:** Printed to the server's console output.
The first User Agent must present this token alongside the standard challenge-response to complete registration.
### 2.3 SDK Client Registration
There is no bootstrap mechanism for SDK clients. They must be explicitly approved by an already-registered User Agent.
---
## 3. Server Identity
The server proves its identity using TLS with a self-signed certificate. The TLS private key is generated on first run and is long-term; no rotation mechanism exists yet due to the complexity of multi-peer coordination.
Peers verify the server by its **public key fingerprint**:
- **User Agent (local):** Receives the fingerprint automatically through the bootstrap token.
- **User Agent (remote) / SDK Client:** Must receive the fingerprint out-of-band.
> A streamlined setup mechanism using a single connection string is planned but not yet implemented.
---
## 4. Key Management
### 4.1 Key Hierarchy
There are three layers of keys:
| Key | Encrypts | Encrypted by |
|---|---|---|
| **User key** (password) | Root key | — (derived from user input) |
| **Root key** | Wallet keys | User key |
| **Wallet keys** | — (used for signing) | Root key |
This layered design enables:
- **Password rotation** without re-encrypting every wallet key (only the root key is re-encrypted).
- **Root key rotation** without requiring the user to change their password.
### 4.2 Encryption at Rest
The database stores everything in encrypted form using symmetric AEAD. The encryption scheme is versioned to support transparent migration — when the vault unseals, Arbiter automatically re-encrypts any entries that are behind the current scheme version. See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the specific scheme and versioning mechanism.
---
## 5. Vault Lifecycle
### 5.1 Sealed State
On boot, the root key is encrypted and the server cannot perform any signing operations. This state is called **Sealed**.
### 5.2 Unseal Flow
To transition to the **Unsealed** state, a User Agent must provide the password:
1. The User Agent initiates an unseal request.
2. The server generates a one-time key pair and returns the public key.
3. The User Agent encrypts the user's password with this one-time public key and sends the ciphertext to the server.
4. The server decrypts and verifies the password:
- **Success:** The root key is decrypted and placed into a hardened memory cell. The server transitions to `Unsealed`. Any entries pending encryption scheme migration are re-encrypted.
- **Failure:** The server returns an error indicating the password is incorrect.
### 5.3 Memory Protection
Once unsealed, the root key must be protected in memory against:
- Memory dumps
- Page swaps to disk
- Hibernation files
See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory protection approaches.
---
## 6. Permission Engine
### 6.1 Fundamental Rules
- SDK clients have **no access by default**.
- Access is granted **explicitly** by a User Agent.
- Grants are scoped to **specific wallets** and governed by **policies**.
Each blockchain requires its own policy system due to differences in static transaction analysis. Currently, only EVM is supported; Solana support is planned.
Arbiter is also responsible for ensuring that **transaction nonces are never reused**.
### 6.2 EVM Policies
Every EVM grant is scoped to a specific **wallet** and **chain ID**.
#### 6.2.1 Transaction Sub-Grants
Arbiter maintains an ever-expanding database of known contracts and their ABIs. Based on contract knowledge, transaction requests fall into three categories:
**1. Known contract (ABI available)**
The transaction can be decoded and presented with semantic meaning. For example: *"Client X wants to transfer Y USDT to address Z."*
Available restrictions:
- Volume limits (e.g., "no more than 10,000 tokens ever")
- Rate limits (e.g., "no more than 100 tokens per hour")
**2. Unknown contract (no ABI)**
The transaction cannot be decoded, so its effects are opaque — it could do anything, including draining all tokens. The user is warned, and if approved, access is granted to all interactions with the contract (matched by the `to` field).
Available restrictions:
- Transaction count limits (e.g., "no more than 100 transactions ever")
- Rate limits (e.g., "no more than 5 transactions per hour")
**3. Plain ether transfer (no calldata)**
These transactions have no `calldata` and therefore cannot interact with contracts. They can be subject to the same volume and rate restrictions as above.
#### 6.2.2 Global Limits
In addition to sub-grant-specific restrictions, the following limits can be applied across all grant types:
- **Gas limit** — Maximum gas per transaction.
- **Time-window restrictions** — e.g., signing allowed only 08:0020:00 on Mondays and Thursdays.

129
CLAUDE.md
View File

@@ -1 +1,128 @@
Refer to @AGENTS.md for instructions.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of:
- **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies
- **`useragent/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
- **`protobufs/`** — Protocol Buffer definitions shared between server and client
The vault never exposes key material; it only produces signatures when requests satisfy configured policies.
## Toolchain Setup
Tools are managed via [mise](https://mise.jdx.dev/). Install all required tools:
```sh
mise install
```
Key versions: Rust 1.93.0 (with clippy), Flutter 3.38.9-stable, protoc 29.6, diesel_cli 2.3.6 (sqlite).
## Server (Rust workspace at `server/`)
### Crates
| Crate | Purpose |
|---|---|
| `arbiter-proto` | Generated gRPC stubs + protobuf types; compiled from `protobufs/*.proto` via `tonic-prost-build` |
| `arbiter-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation |
| `arbiter-useragent` | Rust client library for the user agent side of the gRPC protocol |
| `arbiter-client` | Rust client library for SDK clients |
### Common Commands
```sh
cd server
# Build
cargo build
# Run the server daemon
cargo run -p arbiter-server
# Run all tests (preferred over cargo test)
cargo nextest run
# Run a single test
cargo nextest run <test_name>
# Lint
cargo clippy
# Security audit
cargo audit
# Check unused dependencies
cargo shear
# Run snapshot tests and update snapshots
cargo insta review
```
### Architecture
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
- **`KeyHolder`** — 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.
- **`MessageRouter`** — Coordinates streaming messages between user agents and SDK clients.
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
Per-connection actors live under `actors/user_agent/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
**Database:** SQLite via `diesel-async` + `bb8` connection pool. Schema managed by embedded Diesel migrations in `crates/arbiter-server/migrations/`. DB file lives at `~/.arbiter/arbiter.sqlite`. Tests use a temp-file DB via `db::create_test_pool()`.
**Cryptography:**
- Authentication: ed25519 (challenge-response, nonce-tracked per peer)
- Encryption at rest: XChaCha20-Poly1305 (versioned via `scheme` field for transparent migration on unseal)
- Password KDF: Argon2
- Unseal transport: X25519 ephemeral key exchange
- TLS: self-signed certificate (aws-lc-rs backend), fingerprint distributed via `ArbiterUrl`
**Protocol:** gRPC with Protocol Buffers. The `ArbiterUrl` type encodes host, port, CA cert, and bootstrap token into a single shareable string (printed to console on first run).
### Proto Regeneration
When `.proto` files in `protobufs/` change, rebuild to regenerate:
```sh
cd server && cargo build -p arbiter-proto
```
### Database Migrations
```sh
# Create a new migration
diesel migration generate <name> --migration-dir crates/arbiter-server/migrations
# Run migrations manually (server also runs them on startup)
diesel migration run --migration-dir crates/arbiter-server/migrations
```
## User Agent (Flutter + Rinf at `useragent/`)
The Flutter app uses [Rinf](https://rinf.cunarist.org) to call Rust code. The Rust logic lives in `useragent/native/hub/` as a separate crate that uses `arbiter-useragent` for the gRPC client.
Communication between Dart and Rust uses typed **signals** defined in `useragent/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
```sh
cd useragent && rinf gen
```
### Common Commands
```sh
cd useragent
# Run the app (macOS or Windows)
flutter run
# Regenerate Rust↔Dart signal bindings
rinf gen
# Analyze Dart code
flutter analyze
```
The Rinf Rust entry point is `useragent/native/hub/src/lib.rs`. It spawns actors defined in `useragent/native/hub/src/actors/` which handle Dart↔server communication via signals.

View File

@@ -8,10 +8,10 @@ This document covers concrete technology choices and dependencies. For the archi
### Authentication Result Semantics
Authentication no longer uses an implicit success-only response shape. Both `client` and `operator` return explicit auth status enums over the wire.
Authentication no longer uses an implicit success-only response shape. Both `client` and `user-agent` return explicit auth status enums over the wire.
- **Client:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `APPROVAL_DENIED`, `NO_OPERATORS_ONLINE`, or `INTERNAL`
- **Operator:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `BOOTSTRAP_REQUIRED`, `TOKEN_INVALID`, or `INTERNAL`
- **Client:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `APPROVAL_DENIED`, `NO_USER_AGENTS_ONLINE`, or `INTERNAL`
- **User-agent:** `AuthResult` may return `SUCCESS`, `INVALID_KEY`, `INVALID_SIGNATURE`, `BOOTSTRAP_REQUIRED`, `TOKEN_INVALID`, or `INTERNAL`
This makes transport-level failures and actor/domain-level auth failures distinct:
@@ -22,50 +22,52 @@ Clients are expected to handle these status codes directly and present the concr
### New Client Approval
When a client whose public key is not yet in the database connects, all connected operators are asked to approve the connection. The first operator to respond determines the outcome; remaining requests are cancelled via a watch channel.
When a client whose public key is not yet in the database connects, all connected user agents are asked to approve the connection. The first agent to respond determines the outcome; remaining requests are cancelled via a watch channel.
```mermaid
flowchart TD
A([Client connects]) --> B[Receive AuthChallengeRequest]
B --> C{pubkey in DB?}
C -- yes --> G[Generate AuthChallenge]
C -- yes --> D[Read nonce\nIncrement nonce in DB]
D --> G
C -- no --> E[Ask all Operators:\nClientConnectionRequest]
C -- no --> E[Ask all UserAgents:\nClientConnectionRequest]
E --> F{First response}
F -- denied --> Z([Reject connection])
F -- approved --> F2[Cancel remaining\nOperator requests]
F2 --> F3[INSERT client]
F3 --> G
F -- approved --> F2[Cancel remaining\nUserAgent requests]
F2 --> F3[INSERT client\nnonce = 1]
F3 --> G[Send AuthChallenge\nwith nonce]
G --> H[Send AuthChallenge\ntimestamp + random bytes]
H --> I[Receive AuthChallengeSolution]
I --> K{Signature valid?}
K -- no --> Z
K -- yes --> J([Session started])
G --> H[Receive AuthChallengeSolution]
H --> I{Signature valid?}
I -- no --> Z
I -- yes --> J([Session started])
```
Auth challenges are generated from fresh random bytes plus a nanosecond timestamp. The server keeps the issued challenge only in the in-flight authentication state for that connection, then verifies the signature against the same canonical challenge payload.
### Known Issue: Concurrent Registration Race (TOCTOU)
The authentication schema stores peer identity, not replay counters:
Two connections presenting the same previously-unknown public key can race through the approval flow simultaneously:
- `program_client` stores the SDK client's public key, metadata binding, and timestamps.
- `operator_client` stores the Operator public key and timestamps.
- Neither table stores an authentication nonce, and challenge generation does not update either table.
1. Both check the DB → neither is registered.
2. Both request approval from user agents → both receive approval.
3. Both `INSERT` the client record → the second insert silently overwrites the first, resetting the nonce.
This means the first connection's nonce is invalidated by the second, causing its challenge verification to fail. A fix requires either serialising new-client registration (e.g. an in-memory lock keyed on pubkey) or replacing the separate check + insert with an `INSERT OR IGNORE` / upsert guarded by a unique constraint on `public_key`.
### Nonce Semantics
The `program_client.nonce` column stores the **next usable nonce** — i.e. it is always one ahead of the nonce last issued in a challenge.
- **New client:** inserted with `nonce = 1`; the first challenge is issued with `nonce = 0`.
- **Existing client:** the current DB value is read and used as the challenge nonce, then immediately incremented within the same exclusive transaction, preventing replay.
---
## Cryptography
### Authentication
- **Client protocol:** ML-DSA
### User-Agent Authentication
Operator authentication supports multiple signature schemes because platform-provided "hardware-bound" keys do not expose a uniform algorithm across operating systems and hardware.
- **Supported schemes:** ML-DSA
- **Why:** Secure Enclave (MacOS) support them natively, on other platforms we could emulate while they roll-out
- **Signature scheme:** ed25519
### Encryption at Rest
- **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305**
@@ -86,7 +88,7 @@ Operator authentication supports multiple signature schemes because platform-pro
### Request Multiplexing
Both `client` and `operator` connections support multiple in-flight requests over one gRPC bidi stream.
Both `client` and `user-agent` connections support multiple in-flight requests over one gRPC bidi stream.
- Every request carries a monotonically increasing request ID
- Every normal response echoes the request ID it corresponds to
@@ -115,52 +117,6 @@ The central abstraction is the `Policy` trait. Each implementation handles one s
4. **Evaluate**`Policy::evaluate` checks the decoded meaning against the grant's policy-specific constraints and returns any violations.
5. **Record** — If `RunKind::Execution` and there are no violations, the engine writes to `evm_transaction_log` and calls `Policy::record_transaction` for any policy-specific logging (e.g., token transfer volume).
The detailed branch structure is shown below:
```mermaid
flowchart TD
A[SDK Client sends sign transaction request] --> B[Server resolves wallet]
B --> C{Wallet exists?}
C -- No --> Z1[Return wallet not found error]
C -- Yes --> D[Check SDK client wallet visibility]
D --> E{Wallet visible to SDK client?}
E -- No --> F[Start wallet visibility voting flow]
F --> G{Vote approved?}
G -- No --> Z2[Return wallet access denied error]
G -- Yes --> H[Persist wallet visibility]
E -- Yes --> I[Classify transaction meaning]
H --> I
I --> J{Meaning supported?}
J -- No --> Z3[Return unsupported transaction error]
J -- Yes --> K[Find matching grant]
K --> L{Grant exists?}
L -- Yes --> M[Check grant limits]
L -- No --> N[Start execution or grant voting flow]
N --> O{Operator decision}
O -- Reject --> Z4[Return no matching grant error]
O -- Allow once --> M
O -- Create grant --> P[Create grant with user-selected limits]
P --> Q[Persist grant]
Q --> M
M --> R{Limits exceeded?}
R -- Yes --> Z5[Return evaluation error]
R -- No --> S[Record transaction in logs]
S --> T[Produce signature]
T --> U[Return signature to SDK client]
note1[Limit checks include volume, count, and gas constraints.]
note2[Grant lookup depends on classified meaning, such as ether transfer or token transfer.]
K -. uses .-> note2
M -. checks .-> note1
```
### Policy Trait
| Method | Purpose |
@@ -192,7 +148,7 @@ flowchart TD
Every grant has two layers:
- **Shared (`evm_basic_grant`)** — wallet, chain, validity period, gas fee caps, transaction count rate limit. One row per grant regardless of type.
- **Specific** — policy-owned tables (`evm_ether_transfer_grant`, `evm_token_transfer_grant`) holding type-specific configuration.
- **Specific** — policy-owned tables (`evm_ether_transfer_grant`, `evm_token_transfer_grant`, etc.) holding type-specific configuration.
`find_all_grants` uses a `#[diesel::auto_type]` base join between the specific and shared tables, then batch-loads related rows (targets, volume limits) in two additional queries to avoid N+1.
@@ -215,6 +171,7 @@ These are checked centrally in `check_shared_constraints` before policy evaluati
- **Only EIP-1559 transactions are supported.** Legacy and EIP-2930 types are rejected outright.
- **No opaque-calldata (unknown contract) grant type.** The architecture describes a category for unrecognised contracts, but no policy implements it yet. Any transaction that is not a plain ETH transfer or a known ERC-20 transfer is unconditionally rejected.
- **Token registry is static.** Tokens are recognised only if they appear in the hard-coded `arbiter_tokens_registry` crate. There is no mechanism to register additional contracts at runtime.
- **Nonce management is not implemented.** The architecture lists nonce deduplication as a core responsibility, but no nonce tracking or enforcement exists yet.
---
@@ -222,5 +179,5 @@ These are checked centrally in `check_shared_constraints` before policy evaluati
The unsealed root key must be held in a hardened memory cell resistant to dumps, page swaps, and hibernation.
- **Current:** A dedicated memory-protection abstraction is in place, with `memsafe` used behind that abstraction today
- **Planned:** Additional backends can be introduced behind the same abstraction, including a custom implementation based on `mlock` (Unix) and `VirtualProtect` (Windows)
- **Current:** Using the `memsafe` crate as an interim solution
- **Planned:** Custom implementation based on `mlock` (Unix) and `VirtualProtect` (Windows)

View File

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

View File

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

View File

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

View File

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

View File

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

1
app/.dart_tool/version Normal file
View File

@@ -0,0 +1 @@
3.38.9

View File

@@ -1,334 +0,0 @@
# Arbiter
Arbiter is a permissioned signing service for cryptocurrency wallets. It runs as a background service on the user's machine with an optional client application for vault management.
**Core principle:** The vault NEVER exposes key material. It only produces signatures when a request satisfies the configured policies.
---
## 1. Peer Types
Arbiter distinguishes two kinds of peers:
- **Operator** — A client application used by the owner to manage the vault (create wallets, approve SDK clients, configure policies).
- **SDK Client** — A consumer of signing capabilities, typically an automation tool. In the future, this could include a browser-based wallet.
- **Recovery Operator** — A dormant recovery participant with narrowly scoped authority used only for custody recovery and operator replacement.
---
## 2. Authentication
### 2.1 Challenge-Response
All peers authenticate via public-key cryptography using a challenge-response protocol:
1. The peer sends its public key and requests a challenge.
2. The server looks up the key in its database. If found, it generates a fresh challenge from random bytes plus the current timestamp.
3. The peer signs the canonical challenge payload with its private key and sends the signature back.
4. The server verifies the signature:
- **Pass:** The connection is considered authenticated.
- **Fail:** The server closes the connection.
Authentication challenges are per-connection, ephemeral values. They are not persisted in the peer tables, and peer records store no challenge state.
### 2.2 Operator Bootstrap
On first run — when no Operators are registered — the server generates a one-time bootstrap token. It is made available in two ways:
- **Local setup:** Written to `~/.arbiter/bootstrap_token` for automatic discovery by a co-located Operator.
- **Remote setup:** Printed to the server's console output.
The first Operator must present this token alongside the standard challenge-response to complete registration.
### 2.3 SDK Client Registration
There is no bootstrap mechanism for SDK clients. They must be explicitly approved by an already-registered Operator.
---
## 3. Multi-Operator Governance
When more than one Operator is registered, the vault is treated as having multiple operators. In that mode, sensitive actions are governed by voting rather than by a single operator decision.
### 3.1 Voting Rules
Voting is based on the total number of registered operators:
- **1 operator:** no vote is needed; the single operator decides directly.
- **2 operators:** full consensus is required; both operators must approve.
- **3 or more operators:** quorum is `floor(N / 2) + 1`.
For a decision to count, the operator's approval or rejection must be signed by that operator's associated key. Unsigned votes, or votes that fail signature verification, are ignored.
Examples:
- **3 operators:** 2 approvals required
- **4 operators:** 3 approvals required
### 3.2 Actions Requiring a Vote
In multi-operator mode, a successful vote is required for:
- approving new SDK clients
- granting an SDK client visibility to a wallet
- approving a one-off transaction
- approving creation of a persistent grant
- approving operator replacement
- approving server updates
- updating Shamir secret-sharing parameters
### 3.3 Special Rule for Key Rotation
Key rotation always requires full quorum, regardless of the normal voting threshold.
This is stricter than ordinary governance actions because rotating the root key requires every operator to participate in coordinated share refresh/update steps. The root key itself is not redistributed directly, but each operator's share material must be changed consistently.
### 3.4 Root Key Custody
When the vault has multiple operators, the vault root key is protected using Shamir secret sharing.
The vault root key is encrypted in a way that requires reconstruction from user-held shares rather than from a single shared password.
For ordinary operators, the Shamir threshold matches the ordinary governance quorum. For example:
- **2 operators:** `2-of-2`
- **3 operators:** `2-of-3`
- **4 operators:** `3-of-4`
In practice, the Shamir share set also includes Recovery Operator shares. This means the effective Shamir parameters are computed over the combined share pool while keeping the same threshold. For example:
- **3 ordinary operators + 2 recovery shares:** `2-of-5`
This ensures that the normal custody threshold follows the ordinary operator quorum, while still allowing dormant recovery shares to exist for break-glass recovery flows.
### 3.5 Recovery Operators
Recovery Operators are a separate peer type from ordinary vault operators.
Their role is intentionally narrow. They can only:
- participate in unsealing the vault
- vote for operator replacement
Recovery Operators do not participate in routine governance such as approving SDK clients, granting wallet visibility, approving transactions, creating grants, approving server updates, or changing Shamir parameters.
### 3.6 Sleeping and Waking Recovery Operators
By default, Recovery Operators are **sleeping** and do not participate in any active flow.
Any ordinary operator may request that Recovery Operators **wake up**.
Any ordinary operator may also cancel a pending wake-up request.
This creates a dispute window before recovery powers become active. The default wake-up delay is **14 days**.
Recovery Operators are therefore part of the break-glass recovery path rather than the normal operating quorum.
The high-level recovery flow is:
```mermaid
sequenceDiagram
autonumber
actor Op as Ordinary Operator
participant Server
actor Other as Other Operator
actor Rec as Recovery Operator
Op->>Server: Request recovery wake-up
Server-->>Op: Wake-up pending
Note over Server: Default dispute window: 14 days
alt Wake-up cancelled during dispute window
Other->>Server: Cancel wake-up
Server-->>Op: Recovery cancelled
Server-->>Rec: Stay sleeping
else No cancellation for 14 days
Server-->>Rec: Wake up
Rec->>Server: Join recovery flow
critical Recovery authority
Rec->>Server: Participate in unseal
Rec->>Server: Vote on operator replacement
end
Server-->>Op: Recovery mode active
end
```
### 3.7 Committee Formation
There are two ways to form a multi-operator committee:
- convert an existing single-operator vault by adding new operators
- bootstrap an unbootstrapped vault directly into multi-operator mode
In both cases, committee formation is a coordinated process. Arbiter does not allow multi-operator custody to emerge implicitly from unrelated registrations.
### 3.8 Bootstrapping an Unbootstrapped Vault into Multi-Operator Mode
When an unbootstrapped vault is initialized as a multi-operator vault, the setup proceeds as follows:
1. An operator connects to the unbootstrapped vault using an Operator and the bootstrap token.
2. During bootstrap setup, that operator declares:
- the total number of ordinary operators
- the total number of Recovery Operators
3. The vault enters **multi-bootstrap mode**.
4. While in multi-bootstrap mode:
- every ordinary operator must connect with an Operator using the bootstrap token
- every Recovery Operator must also connect using the bootstrap token
- each participant is registered individually
- each participant's share is created and protected with that participant's credentials
5. The vault is considered fully bootstrapped only after all declared operator and recovery-share registrations have completed successfully.
This means the operator and recovery set is fixed at bootstrap completion time, based on the counts declared when multi-bootstrap mode was entered.
### 3.9 Special Bootstrap Constraint for Two-Operator Vaults
If a vault is declared with exactly **2 ordinary operators**, Arbiter requires at least **1 Recovery Operator** to be configured during bootstrap.
This prevents the worst-case custody failure in which a `2-of-2` operator set becomes permanently unrecoverable after loss of a single operator.
---
## 4. Server Identity
The server proves its identity using TLS with a self-signed certificate. The TLS private key is generated on first run and is long-term; no rotation mechanism exists yet due to the complexity of multi-peer coordination.
Peers verify the server by its **public key fingerprint**:
- **Operator (local):** Receives the fingerprint automatically through the bootstrap token.
- **Operator (remote) / SDK Client:** Must receive the fingerprint out-of-band.
> A streamlined setup mechanism using a single connection string is planned but not yet implemented.
---
## 5. Key Management
### 5.1 Key Hierarchy
There are three layers of keys:
| Key | Encrypts | Encrypted by |
|---|---|---|
| **User key** (password) | Root key | — (derived from user input) |
| **Root key** | Wallet keys | User key |
| **Wallet keys** | — (used for signing) | Root key |
This layered design enables:
- **Password rotation** without re-encrypting every wallet key (only the root key is re-encrypted).
- **Root key rotation** without requiring the user to change their password.
### 5.2 Encryption at Rest
The database stores everything in encrypted form using symmetric AEAD. The encryption scheme is versioned to support transparent migration — when the vault unseals, Arbiter automatically re-encrypts any entries that are behind the current scheme version. See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the specific scheme and versioning mechanism.
---
## 6. Vault Lifecycle
### 6.1 Sealed State
On boot, the root key is encrypted and the server cannot perform any signing operations. This state is called **Sealed**.
### 6.2 Unseal Flow
To transition to the **Unsealed** state, an Operator must provide the password:
1. The Operator initiates an unseal request.
2. The server generates a one-time key pair and returns the public key.
3. The Operator encrypts the user's password with this one-time public key and sends the ciphertext to the server.
4. The server decrypts and verifies the password:
- **Success:** The root key is decrypted and placed into a hardened memory cell. The server transitions to `Unsealed`. Any entries pending encryption scheme migration are re-encrypted.
- **Failure:** The server returns an error indicating the password is incorrect.
### 6.3 Memory Protection
Once unsealed, the root key must be protected in memory against:
- Memory dumps
- Page swaps to disk
- Hibernation files
See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory protection approaches.
---
## 7. Permission Engine
### 7.1 Fundamental Rules
- SDK clients have **no access by default**.
- Access is granted **explicitly** by an Operator.
- Grants are scoped to **specific wallets** and governed by **policies**.
Each blockchain requires its own policy system due to differences in static transaction analysis. Currently, only EVM is supported; Solana support is planned.
Arbiter is also responsible for ensuring that **transaction nonces are never reused**.
### 7.2 EVM Policies
Every EVM grant is scoped to a specific **wallet** and **chain ID**.
#### 7.2.0 Transaction Signing Sequence
The high-level interaction order is:
```mermaid
sequenceDiagram
autonumber
actor SDK as SDK Client
participant Server
participant operator as Operator
SDK->>Server: SignTransactionRequest
Server->>Server: Resolve wallet and wallet visibility
alt Visibility approval required
Server->>operator: Ask for wallet visibility approval
operator-->>Server: Vote result
end
Server->>Server: Evaluate transaction
Server->>Server: Load grant and limits context
alt Grant approval required
Server->>operator: Ask for execution / grant approval
operator-->>Server: Vote result
opt Create persistent grant
Server->>Server: Create and store grant
end
Server->>Server: Retry evaluation
end
critical Final authorization path
Server->>Server: Check limits and record execution
Server-->>Server: Signature or evaluation error
end
Server-->>SDK: Signature or error
```
#### 7.2.1 Transaction Sub-Grants
Arbiter maintains an ever-expanding database of known contracts and their ABIs. Based on contract knowledge, transaction requests fall into three categories:
**1. Known contract (ABI available)**
The transaction can be decoded and presented with semantic meaning. For example: *"Client X wants to transfer Y USDT to address Z."*
Available restrictions:
- Volume limits (e.g., "no more than 10,000 tokens ever")
- Rate limits (e.g., "no more than 100 tokens per hour")
**2. Unknown contract (no ABI)**
The transaction cannot be decoded, so its effects are opaque — it could do anything, including draining all tokens. The user is warned, and if approved, access is granted to all interactions with the contract (matched by the `to` field).
Available restrictions:
- Transaction count limits (e.g., "no more than 100 transactions ever")
- Rate limits (e.g., "no more than 5 transactions per hour")
**3. Plain ether transfer (no calldata)**
These transactions have no `calldata` and therefore cannot interact with contracts. They can be subject to the same volume and rate restrictions as above.
#### 7.2.2 Global Limits
In addition to sub-grant-specific restrictions, the following limits can be applied across all grant types:
- **Gas limit** — Maximum gas per transaction.
- **Time-window restrictions** — e.g., signing allowed only 08:0020:00 on Mondays and Thursdays.

150
mise.lock
View File

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

View File

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

View File

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

View File

@@ -2,24 +2,56 @@ syntax = "proto3";
package arbiter.client;
import "client/auth.proto";
import "client/evm.proto";
import "client/vault.proto";
import "evm.proto";
import "google/protobuf/empty.proto";
message AuthChallengeRequest {
bytes pubkey = 1;
}
message AuthChallenge {
bytes pubkey = 1;
int32 nonce = 2;
}
message AuthChallengeSolution {
bytes signature = 1;
}
enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_APPROVAL_DENIED = 4;
AUTH_RESULT_NO_USER_AGENTS_ONLINE = 5;
AUTH_RESULT_INTERNAL = 6;
}
enum VaultState {
VAULT_STATE_UNSPECIFIED = 0;
VAULT_STATE_UNBOOTSTRAPPED = 1;
VAULT_STATE_SEALED = 2;
VAULT_STATE_UNSEALED = 3;
VAULT_STATE_ERROR = 4;
}
message ClientRequest {
int32 request_id = 4;
oneof payload {
auth.Request auth = 1;
vault.Request vault = 2;
evm.Request evm = 3;
AuthChallengeRequest auth_challenge_request = 1;
AuthChallengeSolution auth_challenge_solution = 2;
google.protobuf.Empty query_vault_state = 3;
}
}
message ClientResponse {
optional int32 request_id = 7;
oneof payload {
auth.Response auth = 1;
vault.Response vault = 2;
evm.Response evm = 3;
AuthChallenge auth_challenge = 1;
AuthResult auth_result = 2;
arbiter.evm.EvmSignTransactionResponse evm_sign_transaction = 3;
arbiter.evm.EvmAnalyzeTransactionResponse evm_analyze_transaction = 4;
VaultState vault_state = 6;
}
}

View File

@@ -1,43 +0,0 @@
syntax = "proto3";
package arbiter.client.auth;
import "shared/client.proto";
message AuthChallengeRequest {
bytes pubkey = 1;
arbiter.shared.ClientInfo client_info = 2;
}
message AuthChallenge {
uint64 timestamp_nanos = 1;
bytes random = 2;
}
message AuthChallengeSolution {
bytes signature = 1;
}
enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_APPROVAL_DENIED = 4;
AUTH_RESULT_NO_OPERATORS_ONLINE = 5;
AUTH_RESULT_INTERNAL = 6;
}
message Request {
oneof payload {
AuthChallengeRequest challenge_request = 1;
AuthChallengeSolution challenge_solution = 2;
}
}
message Response {
oneof payload {
AuthChallenge challenge = 1;
AuthResult result = 2;
}
}

View File

@@ -1,19 +0,0 @@
syntax = "proto3";
package arbiter.client.evm;
import "evm.proto";
message Request {
oneof payload {
arbiter.evm.EvmSignTransactionRequest sign_transaction = 1;
arbiter.evm.EvmAnalyzeTransactionRequest analyze_transaction = 2;
}
}
message Response {
oneof payload {
arbiter.evm.EvmSignTransactionResponse sign_transaction = 1;
arbiter.evm.EvmAnalyzeTransactionResponse analyze_transaction = 2;
}
}

View File

@@ -1,18 +0,0 @@
syntax = "proto3";
package arbiter.client.vault;
import "google/protobuf/empty.proto";
import "shared/vault.proto";
message Request {
oneof payload {
google.protobuf.Empty query_state = 1;
}
}
message Response {
oneof payload {
arbiter.shared.VaultState state = 1;
}
}

View File

@@ -4,7 +4,6 @@ package arbiter.evm;
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
import "shared/evm.proto";
enum EvmError {
EVM_ERROR_UNSPECIFIED = 0;
@@ -13,8 +12,7 @@ enum EvmError {
}
message WalletEntry {
int32 id = 1;
bytes address = 2; // 20-byte Ethereum address
bytes address = 1; // 20-byte Ethereum address
}
message WalletList {
@@ -48,7 +46,7 @@ message VolumeRateLimit {
}
message SharedSettings {
int32 wallet_access_id = 1;
int32 wallet_id = 1;
uint64 chain_id = 2;
optional google.protobuf.Timestamp valid_from = 3;
optional google.protobuf.Timestamp valid_until = 4;
@@ -75,10 +73,75 @@ message SpecificGrant {
}
}
// --- Operator grant management ---
message EtherTransferMeaning {
bytes to = 1; // 20-byte Ethereum address
bytes value = 2; // U256 as big-endian bytes
}
message TokenInfo {
string symbol = 1;
bytes address = 2; // 20-byte Ethereum address
uint64 chain_id = 3;
}
// Mirror of token_transfers::Meaning
message TokenTransferMeaning {
TokenInfo token = 1;
bytes to = 2; // 20-byte Ethereum address
bytes value = 3; // U256 as big-endian bytes
}
// Mirror of policies::SpecificMeaning
message SpecificMeaning {
oneof meaning {
EtherTransferMeaning ether_transfer = 1;
TokenTransferMeaning token_transfer = 2;
}
}
// --- Eval error types ---
message GasLimitExceededViolation {
optional bytes max_gas_fee_per_gas = 1; // U256 as big-endian bytes
optional bytes max_priority_fee_per_gas = 2; // U256 as big-endian bytes
}
message EvalViolation {
oneof kind {
bytes invalid_target = 1; // 20-byte Ethereum address
GasLimitExceededViolation gas_limit_exceeded = 2;
google.protobuf.Empty rate_limit_exceeded = 3;
google.protobuf.Empty volumetric_limit_exceeded = 4;
google.protobuf.Empty invalid_time = 5;
google.protobuf.Empty invalid_transaction_type = 6;
}
}
// Transaction was classified but no grant covers it
message NoMatchingGrantError {
SpecificMeaning meaning = 1;
}
// Transaction was classified and a grant was found, but constraints were violated
message PolicyViolationsError {
SpecificMeaning meaning = 1;
repeated EvalViolation violations = 2;
}
// top-level error returned when transaction evaluation fails
message TransactionEvalError {
oneof kind {
google.protobuf.Empty contract_creation_not_supported = 1;
google.protobuf.Empty unsupported_transaction_type = 2;
NoMatchingGrantError no_matching_grant = 3;
PolicyViolationsError policy_violations = 4;
}
}
// --- UserAgent grant management ---
message EvmGrantCreateRequest {
SharedSettings shared = 1;
SpecificGrant specific = 2;
int32 client_id = 1;
SharedSettings shared = 2;
SpecificGrant specific = 3;
}
message EvmGrantCreateResponse {
@@ -102,13 +165,13 @@ message EvmGrantDeleteResponse {
// Basic grant info returned in grant listings
message GrantEntry {
int32 id = 1;
int32 wallet_access_id = 2;
int32 client_id = 2;
SharedSettings shared = 3;
SpecificGrant specific = 4;
}
message EvmGrantListRequest {
optional int32 wallet_access_id = 1;
optional int32 wallet_id = 1;
}
message EvmGrantListResponse {
@@ -134,7 +197,7 @@ message EvmSignTransactionRequest {
message EvmSignTransactionResponse {
oneof result {
bytes signature = 1; // 65-byte signature: r[32] || s[32] || v[1]
arbiter.shared.evm.TransactionEvalError eval_error = 2;
TransactionEvalError eval_error = 2;
EvmError error = 3;
}
}
@@ -146,8 +209,8 @@ message EvmAnalyzeTransactionRequest {
message EvmAnalyzeTransactionResponse {
oneof result {
arbiter.shared.evm.SpecificMeaning meaning = 1;
arbiter.shared.evm.TransactionEvalError eval_error = 2;
SpecificMeaning meaning = 1;
TransactionEvalError eval_error = 2;
EvmError error = 3;
}
}

View File

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

View File

@@ -1,41 +0,0 @@
syntax = "proto3";
package arbiter.operator.auth;
message AuthChallengeRequest {
bytes pubkey = 1;
optional string bootstrap_token = 2;
}
message AuthChallenge {
uint64 timestamp_nanos = 1;
bytes random = 2;
}
message AuthChallengeSolution {
bytes signature = 1;
}
enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_BOOTSTRAP_REQUIRED = 4;
AUTH_RESULT_TOKEN_INVALID = 5;
AUTH_RESULT_INTERNAL = 6;
}
message Request {
oneof payload {
AuthChallengeRequest challenge_request = 1;
AuthChallengeSolution challenge_solution = 2;
}
}
message Response {
oneof payload {
AuthChallenge challenge = 1;
AuthResult result = 2;
}
}

View File

@@ -1,33 +0,0 @@
syntax = "proto3";
package arbiter.operator.evm;
import "evm.proto";
import "google/protobuf/empty.proto";
message SignTransactionRequest {
int32 client_id = 1;
arbiter.evm.EvmSignTransactionRequest request = 2;
}
message Request {
oneof payload {
google.protobuf.Empty wallet_create = 1;
google.protobuf.Empty wallet_list = 2;
arbiter.evm.EvmGrantCreateRequest grant_create = 3;
arbiter.evm.EvmGrantDeleteRequest grant_delete = 4;
arbiter.evm.EvmGrantListRequest grant_list = 5;
SignTransactionRequest sign_transaction = 6;
}
}
message Response {
oneof payload {
arbiter.evm.WalletCreateResponse wallet_create = 1;
arbiter.evm.WalletListResponse wallet_list = 2;
arbiter.evm.EvmGrantCreateResponse grant_create = 3;
arbiter.evm.EvmGrantDeleteResponse grant_delete = 4;
arbiter.evm.EvmGrantListResponse grant_list = 5;
arbiter.evm.EvmSignTransactionResponse sign_transaction = 6;
}
}

View File

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

View File

@@ -1,100 +0,0 @@
syntax = "proto3";
package arbiter.operator.sdk_client;
import "shared/client.proto";
import "google/protobuf/empty.proto";
enum Error {
ERROR_UNSPECIFIED = 0;
ERROR_ALREADY_EXISTS = 1;
ERROR_NOT_FOUND = 2;
ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs)
ERROR_INTERNAL = 4;
}
message RevokeRequest {
int32 client_id = 1;
}
message Entry {
int32 id = 1;
bytes pubkey = 2;
arbiter.shared.ClientInfo info = 3;
int32 created_at = 4;
}
message List {
repeated Entry clients = 1;
}
message RevokeResponse {
oneof result {
google.protobuf.Empty ok = 1;
Error error = 2;
}
}
message ListResponse {
oneof result {
List clients = 1;
Error error = 2;
}
}
message ConnectionRequest {
bytes pubkey = 1;
arbiter.shared.ClientInfo info = 2;
}
message ConnectionResponse {
bool approved = 1;
bytes pubkey = 2;
}
message ConnectionCancel {
bytes pubkey = 1;
}
message WalletAccess {
int32 wallet_id = 1;
int32 sdk_client_id = 2;
}
message WalletAccessEntry {
int32 id = 1;
WalletAccess access = 2;
}
message GrantWalletAccess {
repeated WalletAccess accesses = 1;
}
message RevokeWalletAccess {
repeated int32 accesses = 1;
}
message ListWalletAccessResponse {
repeated WalletAccessEntry accesses = 1;
}
message Request {
oneof payload {
ConnectionResponse connection_response = 1;
RevokeRequest revoke = 2;
google.protobuf.Empty list = 3;
GrantWalletAccess grant_wallet_access = 4;
RevokeWalletAccess revoke_wallet_access = 5;
google.protobuf.Empty list_wallet_access = 6;
}
}
message Response {
oneof payload {
ConnectionRequest connection_request = 1;
ConnectionCancel connection_cancel = 2;
RevokeResponse revoke = 3;
ListResponse list = 4;
ListWalletAccessResponse list_wallet_access = 5;
}
}

View File

@@ -1,43 +0,0 @@
syntax = "proto3";
package arbiter.operator.vault.bootstrap;
message BootstrapEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
message DeclareCommittee {
uint32 count = 1;
uint32 recovery_count = 2;
}
message ContributePassphrase {
bytes passphrase = 1;
}
message ContributeRecoveryPassphrase {
bytes passphrase = 1;
}
enum BootstrapResult {
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
BOOTSTRAP_RESULT_SUCCESS = 1;
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
BOOTSTRAP_RESULT_INVALID_KEY = 3;
BOOTSTRAP_RESULT_AWAITING_CONTRIBUTIONS = 4;
}
message Request {
oneof payload {
BootstrapEncryptedKey encrypted_key = 2;
DeclareCommittee declare_committee = 3;
ContributePassphrase contribute_passphrase = 4;
ContributeRecoveryPassphrase contribute_recovery_passphrase = 5;
}
}
message Response {
BootstrapResult result = 1;
}

View File

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

View File

@@ -1,48 +0,0 @@
syntax = "proto3";
package arbiter.operator.vault.unseal;
message UnsealStart {
bytes client_pubkey = 1;
}
message UnsealStartResponse {
bytes server_pubkey = 1;
}
message UnsealEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
message ContributePassphrase {
bytes passphrase = 1;
}
message ContributeRecoveryPassphrase {
bytes passphrase = 1;
}
enum UnsealResult {
UNSEAL_RESULT_UNSPECIFIED = 0;
UNSEAL_RESULT_SUCCESS = 1;
UNSEAL_RESULT_INVALID_KEY = 2;
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
UNSEAL_RESULT_AWAITING_CONTRIBUTIONS = 4;
}
message Request {
oneof payload {
UnsealStart start = 1;
UnsealEncryptedKey encrypted_key = 2;
ContributePassphrase contribute_passphrase = 3;
ContributeRecoveryPassphrase contribute_recovery_passphrase = 4;
}
}
message Response {
oneof payload {
UnsealStartResponse start = 1;
UnsealResult result = 2;
}
}

View File

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

View File

@@ -1,9 +0,0 @@
syntax = "proto3";
package arbiter.shared;
message ClientInfo {
string name = 1;
optional string description = 2;
optional string version = 3;
}

View File

@@ -1,74 +0,0 @@
syntax = "proto3";
package arbiter.shared.evm;
import "google/protobuf/empty.proto";
message EtherTransferMeaning {
bytes to = 1; // 20-byte Ethereum address
bytes value = 2; // U256 as big-endian bytes
}
message TokenInfo {
string symbol = 1;
bytes address = 2; // 20-byte Ethereum address
uint64 chain_id = 3;
}
// Mirror of token_transfers::Meaning
message TokenTransferMeaning {
TokenInfo token = 1;
bytes to = 2; // 20-byte Ethereum address
bytes value = 3; // U256 as big-endian bytes
}
// Mirror of policies::SpecificMeaning
message SpecificMeaning {
oneof meaning {
EtherTransferMeaning ether_transfer = 1;
TokenTransferMeaning token_transfer = 2;
}
}
message GasLimitExceededViolation {
optional bytes max_gas_fee_per_gas = 1; // U256 as big-endian bytes
optional bytes max_priority_fee_per_gas = 2; // U256 as big-endian bytes
}
message EvalViolation {
message ChainIdMismatch {
uint64 expected = 1;
uint64 actual = 2;
}
oneof kind {
bytes invalid_target = 1; // 20-byte Ethereum address
GasLimitExceededViolation gas_limit_exceeded = 2;
google.protobuf.Empty rate_limit_exceeded = 3;
google.protobuf.Empty volumetric_limit_exceeded = 4;
google.protobuf.Empty invalid_time = 5;
google.protobuf.Empty invalid_transaction_type = 6;
ChainIdMismatch chain_id_mismatch = 7;
}
}
// Transaction was classified but no grant covers it
message NoMatchingGrantError {
SpecificMeaning meaning = 1;
}
// Transaction was classified and a grant was found, but constraints were violated
message PolicyViolationsError {
SpecificMeaning meaning = 1;
repeated EvalViolation violations = 2;
}
// top-level error returned when transaction evaluation fails
message TransactionEvalError {
oneof kind {
google.protobuf.Empty contract_creation_not_supported = 1;
google.protobuf.Empty unsupported_transaction_type = 2;
NoMatchingGrantError no_matching_grant = 3;
PolicyViolationsError policy_violations = 4;
}
}

View File

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

179
protobufs/user_agent.proto Normal file
View File

@@ -0,0 +1,179 @@
syntax = "proto3";
package arbiter.user_agent;
import "evm.proto";
import "google/protobuf/empty.proto";
enum KeyType {
KEY_TYPE_UNSPECIFIED = 0;
KEY_TYPE_ED25519 = 1;
KEY_TYPE_ECDSA_SECP256K1 = 2;
KEY_TYPE_RSA = 3;
}
// --- SDK client management ---
enum SdkClientError {
SDK_CLIENT_ERROR_UNSPECIFIED = 0;
SDK_CLIENT_ERROR_ALREADY_EXISTS = 1;
SDK_CLIENT_ERROR_NOT_FOUND = 2;
SDK_CLIENT_ERROR_HAS_RELATED_DATA = 3; // hard-delete blocked by FK (client has grants or transaction logs)
SDK_CLIENT_ERROR_INTERNAL = 4;
}
message SdkClientApproveRequest {
bytes pubkey = 1; // 32-byte ed25519 public key
}
message SdkClientRevokeRequest {
int32 client_id = 1;
}
message SdkClientEntry {
int32 id = 1;
bytes pubkey = 2;
int32 created_at = 3;
}
message SdkClientList {
repeated SdkClientEntry clients = 1;
}
message SdkClientApproveResponse {
oneof result {
SdkClientEntry client = 1;
SdkClientError error = 2;
}
}
message SdkClientRevokeResponse {
oneof result {
google.protobuf.Empty ok = 1;
SdkClientError error = 2;
}
}
message SdkClientListResponse {
oneof result {
SdkClientList clients = 1;
SdkClientError error = 2;
}
}
message AuthChallengeRequest {
bytes pubkey = 1;
optional string bootstrap_token = 2;
KeyType key_type = 3;
}
message AuthChallenge {
int32 nonce = 2;
reserved 1;
}
message AuthChallengeSolution {
bytes signature = 1;
}
enum AuthResult {
AUTH_RESULT_UNSPECIFIED = 0;
AUTH_RESULT_SUCCESS = 1;
AUTH_RESULT_INVALID_KEY = 2;
AUTH_RESULT_INVALID_SIGNATURE = 3;
AUTH_RESULT_BOOTSTRAP_REQUIRED = 4;
AUTH_RESULT_TOKEN_INVALID = 5;
AUTH_RESULT_INTERNAL = 6;
}
message UnsealStart {
bytes client_pubkey = 1;
}
message UnsealStartResponse {
bytes server_pubkey = 1;
}
message UnsealEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
message BootstrapEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
enum UnsealResult {
UNSEAL_RESULT_UNSPECIFIED = 0;
UNSEAL_RESULT_SUCCESS = 1;
UNSEAL_RESULT_INVALID_KEY = 2;
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
}
enum BootstrapResult {
BOOTSTRAP_RESULT_UNSPECIFIED = 0;
BOOTSTRAP_RESULT_SUCCESS = 1;
BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2;
BOOTSTRAP_RESULT_INVALID_KEY = 3;
}
enum VaultState {
VAULT_STATE_UNSPECIFIED = 0;
VAULT_STATE_UNBOOTSTRAPPED = 1;
VAULT_STATE_SEALED = 2;
VAULT_STATE_UNSEALED = 3;
VAULT_STATE_ERROR = 4;
}
message SdkClientConnectionRequest {
bytes pubkey = 1;
}
message SdkClientConnectionResponse {
bool approved = 1;
}
message SdkClientConnectionCancel {}
message UserAgentRequest {
int32 id = 16;
oneof payload {
AuthChallengeRequest auth_challenge_request = 1;
AuthChallengeSolution auth_challenge_solution = 2;
UnsealStart unseal_start = 3;
UnsealEncryptedKey unseal_encrypted_key = 4;
google.protobuf.Empty query_vault_state = 5;
google.protobuf.Empty evm_wallet_create = 6;
google.protobuf.Empty evm_wallet_list = 7;
arbiter.evm.EvmGrantCreateRequest evm_grant_create = 8;
arbiter.evm.EvmGrantDeleteRequest evm_grant_delete = 9;
arbiter.evm.EvmGrantListRequest evm_grant_list = 10;
SdkClientConnectionResponse sdk_client_connection_response = 11;
SdkClientApproveRequest sdk_client_approve = 12;
SdkClientRevokeRequest sdk_client_revoke = 13;
google.protobuf.Empty sdk_client_list = 14;
BootstrapEncryptedKey bootstrap_encrypted_key = 15;
}
}
message UserAgentResponse {
optional int32 id = 16;
oneof payload {
AuthChallenge auth_challenge = 1;
AuthResult auth_result = 2;
UnsealStartResponse unseal_start_response = 3;
UnsealResult unseal_result = 4;
VaultState vault_state = 5;
arbiter.evm.WalletCreateResponse evm_wallet_create = 6;
arbiter.evm.WalletListResponse evm_wallet_list = 7;
arbiter.evm.EvmGrantCreateResponse evm_grant_create = 8;
arbiter.evm.EvmGrantDeleteResponse evm_grant_delete = 9;
arbiter.evm.EvmGrantListResponse evm_grant_list = 10;
SdkClientConnectionResponse sdk_client_connection_response = 11;
SdkClientApproveResponse sdk_client_approve_response = 12;
SdkClientRevokeResponse sdk_client_revoke_response = 13;
SdkClientListResponse sdk_client_list_response = 14;
BootstrapResult bootstrap_result = 15;
}
}

Binary file not shown.

View File

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

View File

@@ -1 +0,0 @@
test_tool = "nextest"

2
server/.gitignore vendored
View File

@@ -1,2 +0,0 @@
mutants.out/
mutants.out.old/

1674
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -13,17 +13,15 @@ evm = ["dep:alloy"]
[dependencies]
arbiter-proto.path = "../arbiter-proto"
arbiter-crypto.path = "../arbiter-crypto"
alloy = { workspace = true, optional = true }
tonic.workspace = true
tonic.features = ["tls-aws-lc"]
tokio.workspace = true
tokio-stream.workspace = true
ed25519-dalek.workspace = true
thiserror.workspace = true
http = "1.4.0"
rustls-webpki = { version = "0.103.13", features = ["aws-lc-rs"] }
rustls-webpki = { version = "0.103.10", features = ["aws-lc-rs"] }
async-trait.workspace = true
chrono.workspace = true
[lib]
doctest = false
rand.workspace = true
terrors.workspace = true

View File

@@ -1,159 +1,102 @@
use crate::{
storage::StorageError,
transport::{ClientTransport, next_request_id},
};
use arbiter_crypto::authn::{self, SigningContext, SigningKey};
use arbiter_proto::{
ClientMetadata,
proto::{
client::{
ClientRequest,
auth::{
self as proto_auth, AuthChallenge, AuthChallengeRequest, AuthChallengeSolution,
AuthResult, request::Payload as AuthRequestPayload,
response::Payload as AuthResponsePayload,
},
format_challenge,
proto::client::{
AuthChallengeRequest, AuthChallengeSolution, AuthResult, ClientRequest,
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
shared::ClientInfo as ProtoClientInfo,
},
};
use ed25519_dalek::Signer as _;
use terrors::OneOf;
use chrono::DateTime;
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Server sent invalid auth challenge")]
InvalidChallenge,
#[error("Client approval denied by Operator")]
ApprovalDenied,
#[error("Auth challenge was not returned by server")]
MissingAuthChallenge,
#[error("No Operators online to approve client")]
NoOperatorsOnline,
#[error("Signing key storage error")]
Storage(#[from] StorageError),
#[error("Unexpected auth response payload")]
UnexpectedAuthResponse,
}
fn map_auth_result(code: i32) -> AuthError {
match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) {
AuthResult::ApprovalDenied => AuthError::ApprovalDenied,
AuthResult::NoOperatorsOnline => AuthError::NoOperatorsOnline,
AuthResult::Unspecified
| AuthResult::Success
| AuthResult::InvalidKey
| AuthResult::InvalidSignature
| AuthResult::Internal => AuthError::UnexpectedAuthResponse,
}
}
use crate::{
errors::{
ConnectError, MissingAuthChallengeError, UnexpectedAuthResponseError, map_auth_code_error,
},
transport::{ClientTransport, next_request_id},
};
async fn send_auth_challenge_request(
transport: &mut ClientTransport,
metadata: ClientMetadata,
key: &SigningKey,
) -> Result<(), AuthError> {
key: &ed25519_dalek::SigningKey,
) -> std::result::Result<(), ConnectError> {
transport
.send(ClientRequest {
request_id: next_request_id(),
payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
payload: Some(AuthRequestPayload::ChallengeRequest(AuthChallengeRequest {
pubkey: key.public_key().to_bytes(),
client_info: Some(ProtoClientInfo {
name: metadata.name,
description: metadata.description,
version: metadata.version,
}),
})),
})),
payload: Some(ClientRequestPayload::AuthChallengeRequest(
AuthChallengeRequest {
pubkey: key.verifying_key().to_bytes().to_vec(),
},
)),
})
.await
.map_err(|_| AuthError::UnexpectedAuthResponse)
.map_err(|_| OneOf::new(UnexpectedAuthResponseError))
}
async fn receive_auth_challenge(
transport: &mut ClientTransport,
) -> Result<AuthChallenge, AuthError> {
) -> std::result::Result<arbiter_proto::proto::client::AuthChallenge, ConnectError> {
let response = transport
.recv()
.await
.map_err(|_| AuthError::MissingAuthChallenge)?;
.map_err(|_| OneOf::new(MissingAuthChallengeError))?;
let payload = response.payload.ok_or(AuthError::MissingAuthChallenge)?;
let payload = response
.payload
.ok_or_else(|| OneOf::new(MissingAuthChallengeError))?;
match payload {
ClientResponsePayload::Auth(response) => match response.payload {
Some(AuthResponsePayload::Challenge(challenge)) => Ok(challenge),
Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
None => Err(AuthError::MissingAuthChallenge),
},
_ => Err(AuthError::UnexpectedAuthResponse),
ClientResponsePayload::AuthChallenge(challenge) => Ok(challenge),
ClientResponsePayload::AuthResult(result) => Err(map_auth_code_error(result)),
_ => Err(OneOf::new(UnexpectedAuthResponseError)),
}
}
async fn send_auth_challenge_solution(
transport: &mut ClientTransport,
key: &SigningKey,
challenge: AuthChallenge,
) -> Result<(), AuthError> {
let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos as i64);
let challenge = authn::AuthChallenge {
nonce: *challenge
.random
.as_array()
.ok_or(AuthError::InvalidChallenge)?,
timestamp,
};
let challenge_payload: Vec<u8> = challenge.format();
let signature = key
.sign_message(&challenge_payload, SigningContext::Client)
.map_err(|_| AuthError::UnexpectedAuthResponse)?
.to_bytes();
key: &ed25519_dalek::SigningKey,
challenge: arbiter_proto::proto::client::AuthChallenge,
) -> std::result::Result<(), ConnectError> {
let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey);
let signature = key.sign(&challenge_payload).to_bytes().to_vec();
transport
.send(ClientRequest {
request_id: next_request_id(),
payload: Some(ClientRequestPayload::Auth(proto_auth::Request {
payload: Some(AuthRequestPayload::ChallengeSolution(
payload: Some(ClientRequestPayload::AuthChallengeSolution(
AuthChallengeSolution { signature },
)),
})),
})
.await
.map_err(|_| AuthError::UnexpectedAuthResponse)
.map_err(|_| OneOf::new(UnexpectedAuthResponseError))
}
async fn receive_auth_confirmation(transport: &mut ClientTransport) -> Result<(), AuthError> {
async fn receive_auth_confirmation(
transport: &mut ClientTransport,
) -> std::result::Result<(), ConnectError> {
let response = transport
.recv()
.await
.map_err(|_| AuthError::UnexpectedAuthResponse)?;
.map_err(|_| OneOf::new(UnexpectedAuthResponseError))?;
let payload = response.payload.ok_or(AuthError::UnexpectedAuthResponse)?;
let payload = response
.payload
.ok_or_else(|| OneOf::new(UnexpectedAuthResponseError))?;
match payload {
ClientResponsePayload::Auth(response) => match response.payload {
Some(AuthResponsePayload::Result(result))
ClientResponsePayload::AuthResult(result)
if AuthResult::try_from(result).ok() == Some(AuthResult::Success) =>
{
Ok(())
}
Some(AuthResponsePayload::Result(result)) => Err(map_auth_result(result)),
_ => Err(AuthError::UnexpectedAuthResponse),
},
_ => Err(AuthError::UnexpectedAuthResponse),
ClientResponsePayload::AuthResult(result) => Err(map_auth_code_error(result)),
_ => Err(OneOf::new(UnexpectedAuthResponseError)),
}
}
pub async fn authenticate(
pub(crate) async fn authenticate(
transport: &mut ClientTransport,
metadata: ClientMetadata,
key: &SigningKey,
) -> Result<(), AuthError> {
send_auth_challenge_request(transport, metadata, key).await?;
key: &ed25519_dalek::SigningKey,
) -> std::result::Result<(), ConnectError> {
send_auth_challenge_request(transport, key).await?;
let challenge = receive_auth_challenge(transport).await?;
send_auth_challenge_solution(transport, key, challenge).await?;
receive_auth_confirmation(transport).await

View File

@@ -1,44 +0,0 @@
use arbiter_client::ArbiterClient;
use arbiter_proto::{ClientMetadata, url::ArbiterUrl};
use std::io::{self, Write};
#[tokio::main]
async fn main() {
println!("Testing connection to Arbiter server...");
print!("Enter ArbiterUrl: ");
let _ = io::stdout().flush();
let mut input = String::new();
if let Err(err) = io::stdin().read_line(&mut input) {
eprintln!("Failed to read input: {err}");
return;
}
let input = input.trim();
if input.is_empty() {
eprintln!("ArbiterUrl cannot be empty");
return;
}
let url = match ArbiterUrl::try_from(input) {
Ok(url) => url,
Err(err) => {
eprintln!("Invalid ArbiterUrl: {err}");
return;
}
};
println!("{url:#?}");
let metadata = ClientMetadata {
name: "arbiter-client test_connect".to_owned(),
description: Some("Manual connection smoke test".to_owned()),
version: Some(env!("CARGO_PKG_VERSION").to_owned()),
};
match ArbiterClient::connect(url, metadata).await {
Ok(_) => println!("Connected and authenticated successfully."),
Err(err) => eprintln!("Failed to connect: {err:#?}"),
}
}

View File

@@ -1,92 +1,73 @@
#[cfg(feature = "evm")]
use crate::wallets::evm::ArbiterEvmWallet;
use crate::{
StorageError,
auth::{AuthError, authenticate},
storage::{FileSigningKeyStorage, SigningKeyStorage},
transport::{BUFFER_LENGTH, ClientTransport},
};
use arbiter_crypto::authn::SigningKey;
use arbiter_proto::{
ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl,
};
use arbiter_proto::{proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl};
use std::sync::Arc;
use terrors::{Broaden as _, OneOf};
use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::ClientTlsConfig;
#[derive(Debug, thiserror::Error)]
pub enum ArbiterClientError {
#[error("Authentication error")]
Authentication(#[from] AuthError),
use crate::{
auth::authenticate,
errors::ConnectError,
storage::{FileSigningKeyStorage, SigningKeyStorage},
transport::{BUFFER_LENGTH, ClientTransport},
};
#[error("Could not establish connection")]
Connection(#[from] tonic::transport::Error),
#[cfg(feature = "evm")]
use crate::errors::{ClientConnectionClosedError, ClientError};
#[error("gRPC error")]
Grpc(#[from] tonic::Status),
#[error("Invalid CA certificate")]
InvalidCaCert(#[from] webpki::Error),
#[error("Invalid server URI")]
InvalidUri(#[from] http::uri::InvalidUri),
#[error("Storage error")]
Storage(#[from] StorageError),
}
#[cfg(feature = "evm")]
use crate::wallets::evm::ArbiterEvmWallet;
pub struct ArbiterClient {
#[expect(
dead_code,
reason = "transport will be used in future methods for sending requests and receiving responses"
)]
#[allow(dead_code)]
transport: Arc<Mutex<ClientTransport>>,
}
impl ArbiterClient {
pub async fn connect(
url: ArbiterUrl,
metadata: ClientMetadata,
) -> Result<Self, ArbiterClientError> {
let storage = FileSigningKeyStorage::from_default_location()?;
Self::connect_with_storage(url, metadata, &storage).await
pub async fn connect(url: ArbiterUrl) -> Result<Self, ConnectError> {
let storage = FileSigningKeyStorage::from_default_location().broaden()?;
Self::connect_with_storage(url, &storage).await
}
pub async fn connect_with_storage<S: SigningKeyStorage>(
url: ArbiterUrl,
metadata: ClientMetadata,
storage: &S,
) -> Result<Self, ArbiterClientError> {
let key = storage.load_or_create()?;
Self::connect_with_key(url, metadata, key).await
) -> Result<Self, ConnectError> {
let key = storage.load_or_create().broaden()?;
Self::connect_with_key(url, key).await
}
pub async fn connect_with_key(
url: ArbiterUrl,
metadata: ClientMetadata,
key: SigningKey,
) -> Result<Self, ArbiterClientError> {
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
key: ed25519_dalek::SigningKey,
) -> Result<Self, ConnectError> {
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)
.map_err(OneOf::new)?
.to_owned();
let tls = ClientTlsConfig::new().trust_anchor(anchor);
let channel =
tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))?
.tls_config(tls)?
let channel = tonic::transport::Channel::from_shared(format!("{}:{}", url.host, url.port))
.map_err(OneOf::new)?
.tls_config(tls)
.map_err(OneOf::new)?
.connect()
.await?;
.await
.map_err(OneOf::new)?;
let mut client = ArbiterServiceClient::new(channel);
let (tx, rx) = mpsc::channel(BUFFER_LENGTH);
let response_stream = client.client(ReceiverStream::new(rx)).await?.into_inner();
let response_stream = client
.client(ReceiverStream::new(rx))
.await
.map_err(OneOf::new)?
.into_inner();
let mut transport = ClientTransport {
sender: tx,
receiver: response_stream,
};
authenticate(&mut transport, metadata, &key).await?;
authenticate(&mut transport, &key).await?;
Ok(Self {
transport: Arc::new(Mutex::new(transport)),
@@ -94,8 +75,8 @@ 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")
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, ClientError> {
let _ = &self.transport;
Err(OneOf::new(ClientConnectionClosedError))
}
}

View File

@@ -0,0 +1,127 @@
use terrors::OneOf;
use thiserror::Error;
#[cfg(feature = "evm")]
use alloy::{primitives::ChainId, signers::Error as AlloySignerError};
pub type StorageError = OneOf<(std::io::Error, InvalidKeyLengthError)>;
pub type ConnectError = OneOf<(
tonic::transport::Error,
http::uri::InvalidUri,
webpki::Error,
tonic::Status,
MissingAuthChallengeError,
ApprovalDeniedError,
NoUserAgentsOnlineError,
UnexpectedAuthResponseError,
std::io::Error,
InvalidKeyLengthError,
)>;
pub type ClientError = OneOf<(tonic::Status, ClientConnectionClosedError)>;
pub(crate) type ClientTransportError =
OneOf<(TransportChannelClosedError, TransportConnectionClosedError)>;
#[cfg(feature = "evm")]
pub(crate) type EvmWalletError = OneOf<(
EvmChainIdMismatchError,
EvmHashSigningUnsupportedError,
EvmTransactionSigningUnsupportedError,
)>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")]
pub struct InvalidKeyLengthError {
pub expected: usize,
pub actual: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Auth challenge was not returned by server")]
pub struct MissingAuthChallengeError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Client approval denied by User Agent")]
pub struct ApprovalDeniedError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("No User Agents online to approve client")]
pub struct NoUserAgentsOnlineError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Unexpected auth response payload")]
pub struct UnexpectedAuthResponseError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Connection closed by server")]
pub struct ClientConnectionClosedError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Transport channel closed")]
pub struct TransportChannelClosedError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Connection closed by server")]
pub struct TransportConnectionClosedError;
#[cfg(feature = "evm")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("Transaction chain id mismatch: signer {signer}, tx {tx}")]
pub struct EvmChainIdMismatchError {
pub signer: ChainId,
pub tx: ChainId,
}
#[cfg(feature = "evm")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("hash-only signing is not supported for ArbiterEvmWallet; use transaction signing")]
pub struct EvmHashSigningUnsupportedError;
#[cfg(feature = "evm")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("transaction signing is not supported by current arbiter.client protocol")]
pub struct EvmTransactionSigningUnsupportedError;
pub(crate) fn map_auth_code_error(code: i32) -> ConnectError {
use arbiter_proto::proto::client::AuthResult;
match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) {
AuthResult::ApprovalDenied => OneOf::new(ApprovalDeniedError),
AuthResult::NoUserAgentsOnline => OneOf::new(NoUserAgentsOnlineError),
AuthResult::Unspecified
| AuthResult::Success
| AuthResult::InvalidKey
| AuthResult::InvalidSignature
| AuthResult::Internal => OneOf::new(UnexpectedAuthResponseError),
}
}
#[cfg(feature = "evm")]
impl From<EvmChainIdMismatchError> for AlloySignerError {
fn from(value: EvmChainIdMismatchError) -> Self {
AlloySignerError::TransactionChainIdMismatch {
signer: value.signer,
tx: value.tx,
}
}
}
#[cfg(feature = "evm")]
impl From<EvmHashSigningUnsupportedError> for AlloySignerError {
fn from(_value: EvmHashSigningUnsupportedError) -> Self {
AlloySignerError::other(
"hash-only signing is not supported for ArbiterEvmWallet; use transaction signing",
)
}
}
#[cfg(feature = "evm")]
impl From<EvmTransactionSigningUnsupportedError> for AlloySignerError {
fn from(_value: EvmTransactionSigningUnsupportedError) -> Self {
AlloySignerError::other(
"transaction signing is not supported by current arbiter.client protocol",
)
}
}

View File

@@ -1,12 +1,13 @@
mod auth;
mod client;
mod errors;
mod storage;
mod transport;
pub mod wallets;
pub use auth::AuthError;
pub use client::{ArbiterClient, ArbiterClientError};
pub use storage::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
pub use client::ArbiterClient;
pub use errors::{ClientError, ConnectError, StorageError};
pub use storage::{FileSigningKeyStorage, SigningKeyStorage};
#[cfg(feature = "evm")]
pub use wallets::evm::{ArbiterEvmSignTransactionError, ArbiterEvmWallet};
pub use wallets::evm::ArbiterEvmWallet;

View File

@@ -1,19 +1,11 @@
use arbiter_crypto::authn::SigningKey;
use arbiter_proto::home_path;
use std::path::{Path, PathBuf};
use terrors::OneOf;
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")]
InvalidKeyLength { expected: usize, actual: usize },
#[error("I/O error")]
Io(#[from] std::io::Error),
}
use crate::errors::{InvalidKeyLengthError, StorageError};
pub trait SigningKeyStorage {
fn load_or_create(&self) -> Result<SigningKey, StorageError>;
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError>;
}
#[derive(Debug, Clone)]
@@ -22,41 +14,44 @@ pub struct FileSigningKeyStorage {
}
impl FileSigningKeyStorage {
pub const DEFAULT_FILE_NAME: &str = "sdk_client_ml_dsa.key";
pub const DEFAULT_FILE_NAME: &str = "sdk_client_ed25519.key";
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn from_default_location() -> Result<Self, StorageError> {
Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME)))
pub fn from_default_location() -> std::result::Result<Self, StorageError> {
Ok(Self::new(
home_path()
.map_err(OneOf::new)?
.join(Self::DEFAULT_FILE_NAME),
))
}
fn read_key(path: &Path) -> Result<SigningKey, StorageError> {
let bytes = std::fs::read(path)?;
let raw: [u8; 32] =
bytes
.try_into()
.map_err(|v: Vec<u8>| StorageError::InvalidKeyLength {
fn read_key(path: &Path) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> {
let bytes = std::fs::read(path).map_err(OneOf::new)?;
let raw: [u8; 32] = bytes.try_into().map_err(|v: Vec<u8>| {
OneOf::new(InvalidKeyLengthError {
expected: 32,
actual: v.len(),
})
})?;
Ok(SigningKey::from_seed(raw))
Ok(ed25519_dalek::SigningKey::from_bytes(&raw))
}
}
impl SigningKeyStorage for FileSigningKeyStorage {
fn load_or_create(&self) -> Result<SigningKey, StorageError> {
fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
std::fs::create_dir_all(parent).map_err(OneOf::new)?;
}
if self.path.exists() {
return Self::read_key(&self.path);
}
let key = SigningKey::generate();
let raw_key = key.to_seed();
let key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let raw_key = key.to_bytes();
// Use create_new to prevent accidental overwrite if another process creates the key first.
match std::fs::OpenOptions::new()
@@ -66,20 +61,21 @@ impl SigningKeyStorage for FileSigningKeyStorage {
{
Ok(mut file) => {
use std::io::Write as _;
file.write_all(&raw_key)?;
file.write_all(&raw_key).map_err(OneOf::new)?;
Ok(key)
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
Self::read_key(&self.path)
}
Err(err) => Err(StorageError::Io(err)),
Err(err) => Err(OneOf::new(err)),
}
}
}
#[cfg(test)]
mod tests {
use super::{FileSigningKeyStorage, SigningKeyStorage, StorageError};
use super::{FileSigningKeyStorage, SigningKeyStorage};
use crate::errors::InvalidKeyLengthError;
fn unique_temp_key_path() -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
@@ -105,7 +101,7 @@ mod tests {
.load_or_create()
.expect("second load_or_create should read same key");
assert_eq!(key_a.to_seed(), key_b.to_seed());
assert_eq!(key_a.to_bytes(), key_b.to_bytes());
assert!(path.exists());
std::fs::remove_file(path).expect("temp key file should be removable");
@@ -121,12 +117,12 @@ mod tests {
.load_or_create()
.expect_err("storage should reject non-32-byte key file");
match err {
StorageError::InvalidKeyLength { expected, actual } => {
assert_eq!(expected, 32);
assert_eq!(actual, 31);
match err.narrow::<InvalidKeyLengthError, _>() {
Ok(invalid_len) => {
assert_eq!(invalid_len.expected, 32);
assert_eq!(invalid_len.actual, 31);
}
other @ StorageError::Io(_) => panic!("unexpected error: {other:?}"),
Err(other) => panic!("unexpected io error: {other:?}"),
}
std::fs::remove_file(path).expect("temp key file should be removable");

View File

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

View File

@@ -1,55 +1,21 @@
use crate::transport::{ClientTransport, next_request_id};
use arbiter_proto::proto::{
client::{
ClientRequest,
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
evm::{
self as proto_evm, request::Payload as EvmRequestPayload,
response::Payload as EvmResponsePayload,
},
},
evm::{
EvmSignTransactionRequest,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
},
shared::evm::TransactionEvalError,
};
use alloy::{
consensus::SignableTransaction,
network::TxSigner,
primitives::{Address, B256, ChainId, Signature},
signers::{Error, Result, Signer},
signers::{Result, Signer},
};
use async_trait::async_trait;
use std::sync::Arc;
use terrors::OneOf;
use tokio::sync::Mutex;
/// A typed error payload returned by [`ArbiterEvmWallet`] transaction signing.
///
/// This is wrapped into `alloy::signers::Error::Other`, so consumers can downcast by [`TryFrom`] and
/// interpret the concrete policy evaluation failure instead of parsing strings.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ArbiterEvmSignTransactionError {
#[error("transaction rejected by policy: {0:?}")]
PolicyEval(TransactionEvalError),
}
impl<'a> TryFrom<&'a Error> for &'a ArbiterEvmSignTransactionError {
type Error = ();
fn try_from(value: &'a Error) -> Result<Self, Self::Error> {
if let Error::Other(inner) = value
&& let Some(eval_error) = inner.downcast_ref()
{
Ok(eval_error)
} else {
Err(())
}
}
}
use crate::{
errors::{
EvmChainIdMismatchError, EvmHashSigningUnsupportedError,
EvmTransactionSigningUnsupportedError, EvmWalletError,
},
transport::ClientTransport,
};
pub struct ArbiterEvmWallet {
transport: Arc<Mutex<ClientTransport>>,
@@ -58,11 +24,8 @@ pub struct ArbiterEvmWallet {
}
impl ArbiterEvmWallet {
#[expect(
dead_code,
reason = "new will be used in future methods for creating wallets with different parameters"
)]
pub(crate) const fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
#[allow(dead_code)]
pub(crate) fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
Self {
transport,
address,
@@ -70,24 +33,26 @@ impl ArbiterEvmWallet {
}
}
pub const fn address(&self) -> Address {
pub fn address(&self) -> Address {
self.address
}
#[must_use]
pub const fn with_chain_id(mut self, chain_id: ChainId) -> Self {
pub fn with_chain_id(mut self, chain_id: ChainId) -> Self {
self.chain_id = Some(chain_id);
self
}
fn validate_chain_id(&self, tx: &mut dyn SignableTransaction<Signature>) -> Result<()> {
fn validate_chain_id(
&self,
tx: &mut dyn SignableTransaction<Signature>,
) -> std::result::Result<(), EvmWalletError> {
if let Some(chain_id) = self.chain_id
&& !tx.set_chain_id_checked(chain_id)
{
return Err(Error::TransactionChainIdMismatch {
return Err(OneOf::new(EvmChainIdMismatchError {
signer: chain_id,
tx: tx.chain_id().unwrap(),
});
}));
}
Ok(())
@@ -97,9 +62,7 @@ impl ArbiterEvmWallet {
#[async_trait]
impl Signer for ArbiterEvmWallet {
async fn sign_hash(&self, _hash: &B256) -> Result<Signature> {
Err(Error::other(
"hash-only signing is not supported for ArbiterEvmWallet; use transaction signing",
))
Err(EvmWalletError::new(EvmHashSigningUnsupportedError).into())
}
fn address(&self) -> Address {
@@ -125,73 +88,10 @@ impl TxSigner<Signature> for ArbiterEvmWallet {
&self,
tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> {
self.validate_chain_id(tx)?;
let _transport = self.transport.lock().await;
self.validate_chain_id(tx)
.map_err(OneOf::into::<alloy::signers::Error>)?;
let mut transport = self.transport.lock().await;
let request_id = next_request_id();
let rlp_transaction = tx.encoded_for_signing();
transport
.send(ClientRequest {
request_id,
payload: Some(ClientRequestPayload::Evm(proto_evm::Request {
payload: Some(EvmRequestPayload::SignTransaction(
EvmSignTransactionRequest {
wallet_address: self.address.to_vec(),
rlp_transaction,
},
)),
})),
})
.await
.map_err(|_| Error::other("failed to send evm sign transaction request"))?;
let response = transport
.recv()
.await
.map_err(|_| Error::other("failed to receive evm sign transaction response"))?;
drop(transport);
if response.request_id != Some(request_id) {
return Err(Error::other(
"received mismatched response id for evm sign transaction",
));
}
let payload = response
.payload
.ok_or_else(|| Error::other("missing evm sign transaction response payload"))?;
let ClientResponsePayload::Evm(proto_evm::Response {
payload: Some(payload),
}) = payload
else {
return Err(Error::other(
"unexpected response payload for evm sign transaction request",
));
};
let EvmResponsePayload::SignTransaction(response) = payload else {
return Err(Error::other(
"unexpected evm response payload for sign transaction request",
));
};
let result = response
.result
.ok_or_else(|| Error::other("missing evm sign transaction result"))?;
match result {
EvmSignTransactionResult::Signature(signature) => {
Signature::try_from(signature.as_slice())
.map_err(|_| Error::other("invalid signature returned by server"))
}
EvmSignTransactionResult::EvalError(eval_error) => Err(Error::other(
ArbiterEvmSignTransactionError::PolicyEval(eval_error),
)),
EvmSignTransactionResult::Error(code) => Err(Error::other(format!(
"server failed to sign transaction with error code {code}"
))),
}
Err(EvmWalletError::new(EvmTransactionSigningUnsupportedError).into())
}
}

View File

@@ -1 +0,0 @@
/target

View File

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

View File

@@ -1,2 +0,0 @@
pub mod v1;
pub use v1::*;

View File

@@ -1,288 +0,0 @@
use chrono::{DateTime, Utc};
use hmac::digest::Digest;
use ml_dsa::{
EncodedVerifyingKey, Error, KeyGen, MlDsa87, Seed, Signature as MlDsaSignature,
SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, signature::Keypair as _,
};
use rand::RngExt;
use strum::IntoStaticStr;
/// Domain separation tag mixed into every ML-DSA signature.
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
pub enum SigningContext {
#[strum(serialize = "arbiter_client")]
Client,
#[strum(serialize = "arbiter_operator")]
Operator,
#[strum(serialize = "arbiter_governance_vote")]
GovernanceVote,
}
impl SigningContext {
#[must_use]
pub fn as_bytes(self) -> &'static [u8] {
<&'static str>::from(self).as_bytes()
}
}
const NONCE_SIZE: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("invalid length: expected {expected} bytes, got {actual} bytes")]
pub struct InvalidLength {
pub expected: usize,
pub actual: usize,
}
#[derive(Debug, Clone)]
pub struct AuthChallenge {
pub nonce: [u8; NONCE_SIZE],
pub timestamp: DateTime<Utc>,
}
impl AuthChallenge {
pub fn generate(rng: &mut impl rand::CryptoRng) -> Self {
let timestamp = Utc::now();
let nonce = {
let mut array = [0; NONCE_SIZE];
rng.fill(&mut array);
array
};
Self { nonce, timestamp }
}
pub fn format(&self) -> Vec<u8> {
{
let mut buffer = Vec::from(self.nonce);
let stamp = self
.timestamp
.timestamp_nanos_opt()
.expect("We would be long dead by the time this triggers :)");
buffer.extend_from_slice(stamp.to_be_bytes().as_slice());
buffer
}
}
pub fn from_parts(nonce: &[u8], timestamp: i64) -> Result<Self, InvalidLength> {
let random_nonce = nonce.as_array().ok_or(InvalidLength {
expected: NONCE_SIZE,
actual: nonce.len(),
})?;
Ok(Self {
nonce: *random_nonce,
timestamp: DateTime::from_timestamp_nanos(timestamp),
})
}
}
pub type KeyParams = MlDsa87;
#[derive(Clone, Debug, PartialEq)]
pub struct PublicKey(Box<MlDsaVerifyingKey<KeyParams>>);
impl crate::hashing::Hashable for PublicKey {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.to_bytes());
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Signature(Box<MlDsaSignature<KeyParams>>);
#[derive(Debug)]
pub struct SigningKey(Box<MlDsaSigningKey<KeyParams>>);
impl PublicKey {
pub fn to_bytes(&self) -> Vec<u8> {
self.0.encode().0.to_vec()
}
#[must_use]
pub fn verify(
&self,
challenge: &AuthChallenge,
context: SigningContext,
signature: &Signature,
) -> bool {
let challenge = challenge.format();
self.0
.verify_with_context(&challenge, context.as_bytes(), &signature.0)
}
#[must_use]
pub fn verify_message(
&self,
message: &[u8],
context: SigningContext,
signature: &Signature,
) -> bool {
self.0
.verify_with_context(message, context.as_bytes(), &signature.0)
}
}
impl Signature {
pub fn to_bytes(&self) -> Vec<u8> {
self.0.encode().0.to_vec()
}
}
impl SigningKey {
pub fn generate() -> Self {
Self(Box::new(KeyParams::key_gen(&mut rand::rng())))
}
pub fn from_seed(seed: [u8; 32]) -> Self {
Self(Box::new(KeyParams::from_seed(&Seed::from(seed))))
}
pub fn to_seed(&self) -> [u8; 32] {
self.0.to_seed().into()
}
pub fn public_key(&self) -> PublicKey {
self.0.verifying_key().into()
}
pub fn sign_message(
&self,
message: &[u8],
context: SigningContext,
) -> Result<Signature, Error> {
self.0
.signing_key()
.sign_deterministic(message, context.as_bytes())
.map(Into::into)
}
pub fn sign_challenge(
&self,
challenge: &AuthChallenge,
context: SigningContext,
) -> Result<Signature, Error> {
let challenge = challenge.format();
self.sign_message(&challenge, context)
}
}
impl From<MlDsaVerifyingKey<KeyParams>> for PublicKey {
fn from(value: MlDsaVerifyingKey<KeyParams>) -> Self {
Self(Box::new(value))
}
}
impl From<MlDsaSignature<KeyParams>> for Signature {
fn from(value: MlDsaSignature<KeyParams>) -> Self {
Self(Box::new(value))
}
}
impl From<MlDsaSigningKey<KeyParams>> for SigningKey {
fn from(value: MlDsaSigningKey<KeyParams>) -> Self {
Self(Box::new(value))
}
}
impl TryFrom<Vec<u8>> for PublicKey {
type Error = ();
fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
Self::try_from(value.as_slice())
}
}
impl TryFrom<&'_ [u8]> for PublicKey {
type Error = ();
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
let encoded = EncodedVerifyingKey::<KeyParams>::try_from(value).map_err(|_| ())?;
Ok(Self(Box::new(MlDsaVerifyingKey::decode(&encoded))))
}
}
impl TryFrom<Vec<u8>> for Signature {
type Error = ();
fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
Self::try_from(value.as_slice())
}
}
impl TryFrom<&'_ [u8]> for Signature {
type Error = ();
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
MlDsaSignature::try_from(value)
.map(|sig| Self(Box::new(sig)))
.map_err(|_| ())
}
}
#[cfg(test)]
mod tests {
use ml_dsa::{KeyGen, MlDsa87, signature::Keypair as _};
use crate::authn::AuthChallenge;
use super::{PublicKey, Signature, SigningContext, SigningKey};
#[test]
fn public_key_round_trip_decodes() {
let key = MlDsa87::key_gen(&mut rand::rng());
let encoded = PublicKey::from(key.verifying_key()).to_bytes();
let decoded = PublicKey::try_from(encoded.as_slice()).expect("public key should decode");
assert_eq!(decoded, PublicKey::from(key.verifying_key()));
}
#[test]
fn signature_round_trip_decodes() {
let key = SigningKey::generate();
let signature = key
.sign_message(b"challenge", SigningContext::Client)
.expect("signature should be created");
let decoded =
Signature::try_from(signature.to_bytes().as_slice()).expect("signature should decode");
assert_eq!(decoded, signature);
}
#[test]
fn challenge_verification_uses_context_and_canonical_key_bytes() {
let key = SigningKey::generate();
let public_key = key.public_key();
let challenge = AuthChallenge::generate(&mut rand::rng());
let signature = key
.sign_challenge(&challenge, SigningContext::Client)
.expect("signature should be created");
assert!(public_key.verify(&challenge, SigningContext::Client, &signature));
assert!(!public_key.verify(&challenge, SigningContext::Operator, &signature));
}
#[test]
fn signing_key_round_trip_seed_preserves_public_key_and_signing() {
let original = SigningKey::generate();
let restored = SigningKey::from_seed(original.to_seed());
assert_eq!(restored.public_key(), original.public_key());
let challenge = AuthChallenge::generate(&mut rand::rng());
let signature = restored
.sign_challenge(&challenge, SigningContext::Client)
.expect("signature should be created");
assert!(
restored
.public_key()
.verify(&challenge, SigningContext::Client, &signature)
);
}
}

View File

@@ -1,112 +0,0 @@
use std::collections::HashSet;
pub use hmac::digest::Digest;
/// Deterministically hash a value by feeding its fields into the hasher in a consistent order.
#[diagnostic::on_unimplemented(
note = "for local types consider adding `#[derive(arbiter_macros::Hashable)]` to your `{Self}` type",
note = "for types from other crates check whether the crate offers a `Hashable` implementation"
)]
pub trait Hashable {
fn hash<H: Digest>(&self, hasher: &mut H);
}
macro_rules! impl_numeric {
($($t:ty),*) => {
$(
impl Hashable for $t {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(&self.to_be_bytes());
}
}
)*
};
}
impl_numeric!(u8, u16, u32, u64, i8, i16, i32, i64);
impl Hashable for &[u8] {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self);
}
}
impl Hashable for String {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.as_bytes());
}
}
impl<T: Hashable + PartialOrd> Hashable for Vec<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
let ref_sorted = {
let mut sorted = self.iter().collect::<Vec<_>>();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted
};
for item in ref_sorted {
item.hash(hasher);
}
}
}
impl<T: Hashable + PartialOrd, S: std::hash::BuildHasher> Hashable for HashSet<T, S> {
fn hash<H: Digest>(&self, hasher: &mut H) {
let ref_sorted = {
let mut sorted = self.iter().collect::<Vec<_>>();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted
};
for item in ref_sorted {
item.hash(hasher);
}
}
}
impl<T: Hashable> Hashable for Option<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
match self {
Some(value) => {
hasher.update([1]);
value.hash(hasher);
}
None => hasher.update([0]),
}
}
}
impl<T: Hashable> Hashable for Box<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
self.as_ref().hash(hasher);
}
}
impl<T: Hashable> Hashable for &T {
fn hash<H: Digest>(&self, hasher: &mut H) {
(*self).hash(hasher);
}
}
impl Hashable for alloy::primitives::Address {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.as_slice());
}
}
impl Hashable for alloy::primitives::U256 {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.to_be_bytes::<32>());
}
}
impl Hashable for chrono::Duration {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.num_seconds().to_be_bytes());
}
}
impl Hashable for chrono::DateTime<chrono::Utc> {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.timestamp_millis().to_be_bytes());
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,32 @@
use std::path::PathBuf;
use tonic_prost_build::configure;
static PROTOBUF_DIR: &str = "../../../protobufs";
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo::rerun-if-changed={PROTOBUF_DIR}");
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
let protobuf_dir = manifest_dir.join(PROTOBUF_DIR);
let protoc_include = protoc_bin_vendored::include_path()?;
let protoc_path = protoc_bin_vendored::protoc_bin_path()?;
unsafe {
std::env::set_var("PROTOC", &protoc_path);
std::env::set_var("PROTOC_INCLUDE", &protoc_include);
}
println!("cargo::rerun-if-changed={}", protobuf_dir.display());
configure()
.message_attribute(".", "#[derive(::kameo::Reply)]")
.compile_well_known_types(true)
.compile_protos(
&[
format!("{}/arbiter.proto", PROTOBUF_DIR),
format!("{}/operator.proto", PROTOBUF_DIR),
format!("{}/client.proto", PROTOBUF_DIR),
format!("{}/evm.proto", PROTOBUF_DIR),
protobuf_dir.join("arbiter.proto"),
protobuf_dir.join("user_agent.proto"),
protobuf_dir.join("client.proto"),
protobuf_dir.join("evm.proto"),
],
&[PROTOBUF_DIR.to_string()],
)
.unwrap();
&[protobuf_dir],
)?;
Ok(())
}

View File

@@ -1,67 +1,23 @@
pub mod transport;
pub mod url;
use base64::{Engine, prelude::BASE64_STANDARD};
pub mod google {
pub mod protobuf {
tonic::include_proto!("google.protobuf");
}
}
pub mod proto {
tonic::include_proto!("arbiter");
pub mod shared {
tonic::include_proto!("arbiter.shared");
pub mod evm {
tonic::include_proto!("arbiter.shared.evm");
}
}
pub mod operator {
tonic::include_proto!("arbiter.operator");
pub mod auth {
tonic::include_proto!("arbiter.operator.auth");
}
pub mod evm {
tonic::include_proto!("arbiter.operator.evm");
}
pub mod governance {
tonic::include_proto!("arbiter.operator.governance");
}
pub mod sdk_client {
tonic::include_proto!("arbiter.operator.sdk_client");
}
pub mod vault {
tonic::include_proto!("arbiter.operator.vault");
pub mod bootstrap {
tonic::include_proto!("arbiter.operator.vault.bootstrap");
}
pub mod rekey {
tonic::include_proto!("arbiter.operator.vault.rekey");
}
pub mod unseal {
tonic::include_proto!("arbiter.operator.vault.unseal");
}
}
pub mod user_agent {
tonic::include_proto!("arbiter.user_agent");
}
pub mod client {
tonic::include_proto!("arbiter.client");
pub mod auth {
tonic::include_proto!("arbiter.client.auth");
}
pub mod evm {
tonic::include_proto!("arbiter.client.evm");
}
pub mod vault {
tonic::include_proto!("arbiter.client.vault");
}
}
pub mod evm {
@@ -69,13 +25,6 @@ pub mod proto {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientMetadata {
pub name: String,
pub description: Option<String>,
pub version: Option<String>,
}
pub static BOOTSTRAP_PATH: &str = "bootstrap_token";
pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> {
@@ -90,3 +39,8 @@ pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> {
Ok(arbiter_home)
}
pub fn format_challenge(nonce: i32, pubkey: &[u8]) -> Vec<u8> {
let concat_form = format!("{}:{}", nonce, BASE64_STANDARD.encode(pubkey));
concat_form.into_bytes()
}

View File

@@ -54,10 +54,11 @@
//! as a closed outbound channel.
//! - [`Bi::recv`] returns `None` when the underlying transport closes.
//! - Message translation is intentionally out of scope for this module.
use async_trait::async_trait;
use kameo::{error::Infallible, prelude::*};
use std::marker::PhantomData;
use async_trait::async_trait;
/// Errors returned by transport adapters implementing [`Bi`].
#[derive(thiserror::Error, Debug)]
pub enum Error {
@@ -105,36 +106,6 @@ pub trait Receiver<Inbound>: Send + Sync {
/// any built-in correlation mechanism between inbound and outbound items.
pub trait Bi<Inbound, Outbound>: Sender<Outbound> + Receiver<Inbound> + Send + Sync {}
#[async_trait]
impl<T, Outbound> Sender<Outbound> for &mut T
where
T: Sender<Outbound> + ?Sized,
Outbound: Send + 'static,
{
async fn send(&mut self, item: Outbound) -> Result<(), Error> {
(**self).send(item).await
}
}
#[async_trait]
impl<T, Inbound> Receiver<Inbound> for &mut T
where
T: Receiver<Inbound> + ?Sized,
Inbound: Send + 'static,
{
async fn recv(&mut self) -> Option<Inbound> {
(**self).recv().await
}
}
impl<T, Inbound, Outbound> Bi<Inbound, Outbound> for &mut T
where
T: Bi<Inbound, Outbound> + ?Sized,
Inbound: Send + 'static,
Outbound: Send + 'static,
{
}
pub trait SplittableBi<Inbound, Outbound>: Bi<Inbound, Outbound> {
type Sender: Sender<Outbound>;
type Receiver: Receiver<Inbound>;
@@ -190,29 +161,3 @@ where
}
pub mod grpc;
#[derive(thiserror::Error, Debug)]
pub enum ForwardError<I> {
#[error("Transport error: {0}")]
Transport(#[from] Error),
#[error("Actor delivery error: {0}")]
Actor(SendError<I>),
}
pub async fn forward_to_actor<Transport, Inbound, Outbound, Handler>(
transport: &mut Transport,
actor: &ActorRef<Handler>,
) -> Result<(), ForwardError<Inbound>>
where
Transport: Bi<Inbound, <Outbound as Reply>::Ok>,
Handler: Actor + Message<Inbound, Reply = Outbound>,
Inbound: Send + 'static,
Outbound: Send + 'static + Reply<Error = Infallible>, // `Infallible` to enforce contract that `Outbound` carries handler-level error
{
while let Some(request) = transport.recv().await {
let resp = actor.ask(request).await.map_err(ForwardError::Actor)?;
transport.send(resp).await?
}
Err(Error::ChannelClosed.into())
}

View File

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

View File

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

View File

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

View File

@@ -37,67 +37,28 @@ 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 of the split that produced the stored shares. Null before bootstrap.
-- Recorded rather than recomputed: an aborted operator replacement leaves fewer share
-- rows than the split has shares, and a recomputed threshold would then be wrong.
shamir_threshold integer
tls_id integer references tls_history (id) on delete RESTRICT
) STRICT;
insert into arbiter_settings (id) values (1) on conflict do nothing;
-- ensure singleton row exists
insert into arbiter_settings (id) values (1) on conflict do nothing; -- ensure singleton row exists
create table if not exists operator_identity (
create table if not exists useragent_client (
id integer not null primary key,
nonce integer not null default(1), -- used for auth challenge
public_key blob not null,
key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1)
created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now'))
) STRICT;
create unique index if not exists uniq_operator_identity_public_key on operator_identity (public_key);
create table if not exists operator (
id integer primary key references operator_identity(id) on delete restrict, -- same id as operator_identity
share blob not null,
share_nonce blob not null,
share_salt blob not null,
created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now'))
) STRICT;
create table if not exists client_metadata (
id integer not null primary key,
name text not null, -- human-readable name for the client
description text, -- optional description for the client
version text, -- client version for tracking and debugging
created_at integer not null default(unixepoch ('now'))
) STRICT;
-- created to track history of changes
create table if not exists client_metadata_history (
id integer not null primary key,
metadata_id integer not null references client_metadata (id) on delete cascade,
client_id integer not null references program_client (id) on delete cascade,
created_at integer not null default(unixepoch ('now'))
) STRICT;
create unique index if not exists uniq_metadata_binding_client on client_metadata_history (client_id);
create table if not exists program_client (
id integer not null primary key,
nonce integer not null default(1), -- used for auth challenge
public_key blob not null,
metadata_id integer not null references client_metadata (id) on delete cascade,
created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now'))
) STRICT;
create unique index if not exists program_client_public_key_unique
on program_client (public_key);
create unique index if not exists uniq_program_client_public_key on program_client (public_key);
create table if not exists evm_wallet (
id integer not null primary key,
address blob not null, -- 20-byte Ethereum address
@@ -106,19 +67,8 @@ create table if not exists evm_wallet (
) STRICT;
create unique index if not exists uniq_evm_wallet_address on evm_wallet (address);
create unique index if not exists uniq_evm_wallet_aead on evm_wallet (aead_encrypted_id);
create table if not exists evm_wallet_access (
id integer not null primary key,
wallet_id integer not null references evm_wallet (id) on delete cascade,
client_id integer not null references program_client (id) on delete cascade,
revoked_at integer, -- unix timestamp when revoked, null = still active
created_at integer not null default(unixepoch ('now'))
) STRICT;
create unique index if not exists uniq_wallet_access on evm_wallet_access (wallet_id, client_id);
create table if not exists evm_ether_transfer_limit (
id integer not null primary key,
window_secs integer not null, -- window duration in seconds
@@ -128,7 +78,8 @@ create table if not exists evm_ether_transfer_limit (
-- Shared grant properties: client scope, timeframe, fee caps, and rate limit
create table if not exists evm_basic_grant (
id integer not null primary key,
wallet_access_id integer not null references evm_wallet_access (id) on delete restrict,
wallet_id integer not null references evm_wallet(id) on delete restrict,
client_id integer not null references program_client(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
@@ -137,27 +88,28 @@ create table if not exists evm_basic_grant (
rate_limit_count integer, -- max transactions in window, null = unlimited
rate_limit_window_secs integer, -- window duration in seconds, null = unlimited
revoked_at integer, -- unix timestamp when revoked, null = still active
created_at integer not null default(unixepoch ('now'))
created_at integer not null default(unixepoch('now'))
) STRICT;
-- Shared transaction log for all EVM grants, used for rate limit tracking and auditing
create table if not exists evm_transaction_log (
id integer not null primary key,
wallet_access_id integer not null references evm_wallet_access (id) on delete restrict,
grant_id integer not null references evm_basic_grant (id) on delete restrict,
grant_id integer not null references evm_basic_grant(id) on delete restrict,
client_id integer not null references program_client(id) on delete restrict,
wallet_id integer not null references evm_wallet(id) on delete restrict,
chain_id integer not null,
eth_value blob not null, -- always present on any EVM tx
signed_at integer not null default(unixepoch ('now'))
signed_at integer not null default(unixepoch('now'))
) STRICT;
create index if not exists idx_evm_basic_grant_access_chain on evm_basic_grant (wallet_access_id, chain_id);
create index if not exists idx_evm_basic_grant_wallet_chain on evm_basic_grant(client_id, wallet_id, chain_id);
-- ===============================
-- ERC20 token transfer grant
-- ===============================
create table if not exists evm_token_transfer_grant (
id integer not null primary key,
basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade,
basic_grant_id integer not null unique references evm_basic_grant(id) on delete cascade,
token_contract blob not null, -- 20-byte ERC20 contract address
receiver blob -- 20-byte recipient address or null if every recipient allowed
) STRICT;
@@ -165,7 +117,7 @@ create table if not exists evm_token_transfer_grant (
-- Per-window volume limits for token transfer grants
create table if not exists evm_token_transfer_volume_limit (
id integer not null primary key,
grant_id integer not null references evm_token_transfer_grant (id) on delete cascade,
grant_id integer not null references evm_token_transfer_grant(id) on delete cascade,
window_secs integer not null, -- window duration in seconds
max_volume blob not null -- big-endian 32-byte U256
) STRICT;
@@ -173,204 +125,37 @@ create table if not exists evm_token_transfer_volume_limit (
-- Log table for token transfer grant usage
create table if not exists evm_token_transfer_log (
id integer not null primary key,
grant_id integer not null references evm_token_transfer_grant (id) on delete restrict,
log_id integer not null references evm_transaction_log (id) on delete restrict,
grant_id integer not null references evm_token_transfer_grant(id) on delete restrict,
log_id integer not null references evm_transaction_log(id) on delete restrict,
chain_id integer not null, -- EIP-155 chain ID
token_contract blob not null, -- 20-byte ERC20 contract address
recipient_address blob not null, -- 20-byte recipient address
value blob not null, -- big-endian 32-byte U256
created_at integer not null default(unixepoch ('now'))
created_at integer not null default(unixepoch('now'))
) STRICT;
create index if not exists idx_token_transfer_log_grant on evm_token_transfer_log (grant_id);
create index if not exists idx_token_transfer_log_grant on evm_token_transfer_log(grant_id);
create index if not exists idx_token_transfer_log_log_id on evm_token_transfer_log(log_id);
create index if not exists idx_token_transfer_log_chain on evm_token_transfer_log(chain_id);
create index if not exists idx_token_transfer_log_log_id on evm_token_transfer_log (log_id);
create index if not exists idx_token_transfer_log_chain on evm_token_transfer_log (chain_id);
-- ===============================
-- Ether transfer grant (uses base log)
-- ===============================
create table if not exists evm_ether_transfer_grant (
id integer not null primary key,
basic_grant_id integer not null unique references evm_basic_grant (id) on delete cascade,
limit_id integer not null references evm_ether_transfer_limit (id) on delete restrict
basic_grant_id integer not null unique references evm_basic_grant(id) on delete cascade,
limit_id integer not null references evm_ether_transfer_limit(id) on delete restrict
) STRICT;
-- Specific recipient addresses for an ether transfer grant
create table if not exists evm_ether_transfer_grant_target (
id integer not null primary key,
grant_id integer not null references evm_ether_transfer_grant (id) on delete cascade,
grant_id integer not null references evm_ether_transfer_grant(id) on delete cascade,
address blob not null -- 20-byte recipient address
) STRICT;
create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address);
create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target(grant_id, address);
-- ===============================
-- Integrity Envelopes
-- ===============================
create table if not exists integrity_envelope (
id integer not null primary key,
entity_kind text not null,
entity_id blob not null,
payload_version integer not null,
key_version integer not null,
mac blob not null, -- 20-byte recipient address
signed_at integer not null default(unixepoch ('now')),
created_at integer not null default(unixepoch ('now'))
) 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;
CREATE UNIQUE INDEX program_client_public_key_unique
ON program_client (public_key);

View File

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

View File

@@ -0,0 +1,249 @@
use arbiter_proto::{
format_challenge,
transport::{Bi, expect_message},
};
use diesel::{
ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, dsl::insert_into, update,
};
use diesel_async::RunQueryDsl as _;
use ed25519_dalek::{Signature, VerifyingKey};
use kameo::error::SendError;
use tracing::error;
use crate::{
actors::{
client::ClientConnection,
router::{self, RequestClientApproval},
},
db::{self, schema::program_client},
};
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum Error {
#[error("Database pool unavailable")]
DatabasePoolUnavailable,
#[error("Database operation failed")]
DatabaseOperationFailed,
#[error("Invalid challenge solution")]
InvalidChallengeSolution,
#[error("Client approval request failed")]
ApproveError(#[from] ApproveError),
#[error("Transport error")]
Transport,
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum ApproveError {
#[error("Internal error")]
Internal,
#[error("Client connection denied by user agents")]
Denied,
#[error("Upstream error: {0}")]
Upstream(router::ApprovalError),
}
#[derive(Debug, Clone)]
pub enum Inbound {
AuthChallengeRequest { pubkey: VerifyingKey },
AuthChallengeSolution { signature: Signature },
}
#[derive(Debug, Clone)]
pub enum Outbound {
AuthChallenge { pubkey: VerifyingKey, nonce: i32 },
AuthSuccess,
}
/// Atomically reads and increments the nonce for a known client.
/// Returns `None` if the pubkey is not registered.
async fn get_nonce(db: &db::DatabasePool, pubkey: &VerifyingKey) -> Result<Option<i32>, Error> {
let pubkey_bytes = pubkey.as_bytes().to_vec();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
conn.exclusive_transaction(|conn| {
let pubkey_bytes = pubkey_bytes.clone();
Box::pin(async move {
let Some((client_id, current_nonce)) = program_client::table
.filter(program_client::public_key.eq(&pubkey_bytes))
.select((program_client::id, program_client::nonce))
.first::<(i32, i32)>(conn)
.await
.optional()?
else {
return Result::<_, diesel::result::Error>::Ok(None);
};
update(program_client::table)
.filter(program_client::public_key.eq(&pubkey_bytes))
.set(program_client::nonce.eq(current_nonce + 1))
.execute(conn)
.await?;
let _ = client_id;
Ok(Some(current_nonce))
})
})
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})
}
async fn approve_new_client(
actors: &crate::actors::GlobalActors,
pubkey: VerifyingKey,
) -> Result<(), Error> {
let result = actors
.router
.ask(RequestClientApproval {
client_pubkey: pubkey,
})
.await;
match result {
Ok(true) => Ok(()),
Ok(false) => Err(Error::ApproveError(ApproveError::Denied)),
Err(SendError::HandlerError(e)) => {
error!(error = ?e, "Approval upstream error");
Err(Error::ApproveError(ApproveError::Upstream(e)))
}
Err(e) => {
error!(error = ?e, "Approval request to router failed");
Err(Error::ApproveError(ApproveError::Internal))
}
}
}
enum InsertClientResult {
Inserted,
AlreadyExists,
}
async fn insert_client(
db: &db::DatabasePool,
pubkey: &VerifyingKey,
) -> Result<InsertClientResult, Error> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i32;
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
match insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.as_bytes().to_vec()),
program_client::nonce.eq(1), // pre-incremented; challenge uses 0
program_client::created_at.eq(now),
program_client::updated_at.eq(now),
))
.execute(&mut conn)
.await
{
Ok(_) => {}
Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_,
)) => return Ok(InsertClientResult::AlreadyExists),
Err(e) => {
error!(error = ?e, "Failed to insert new client");
return Err(Error::DatabaseOperationFailed);
}
}
let client_id = program_client::table
.filter(program_client::public_key.eq(pubkey.as_bytes().to_vec()))
.order(program_client::id.desc())
.select(program_client::id)
.first::<i32>(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Failed to load inserted client id");
Error::DatabaseOperationFailed
})?;
let _ = client_id;
Ok(InsertClientResult::Inserted)
}
async fn challenge_client<T>(
transport: &mut T,
pubkey: VerifyingKey,
nonce: i32,
) -> Result<(), Error>
where
T: Bi<Inbound, Result<Outbound, Error>> + ?Sized,
{
transport
.send(Ok(Outbound::AuthChallenge { pubkey, nonce }))
.await
.map_err(|e| {
error!(error = ?e, "Failed to send auth challenge");
Error::Transport
})?;
let signature = expect_message(transport, |req: Inbound| match req {
Inbound::AuthChallengeSolution { signature } => Some(signature),
_ => None,
})
.await
.map_err(|e| {
error!(error = ?e, "Failed to receive challenge solution");
Error::Transport
})?;
let formatted = format_challenge(nonce, pubkey.as_bytes());
pubkey.verify_strict(&formatted, &signature).map_err(|_| {
error!("Challenge solution verification failed");
Error::InvalidChallengeSolution
})?;
Ok(())
}
pub async fn authenticate<T>(
props: &mut ClientConnection,
transport: &mut T,
) -> Result<VerifyingKey, Error>
where
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
{
let Some(Inbound::AuthChallengeRequest { pubkey }) = transport.recv().await
else {
return Err(Error::Transport);
};
let nonce = match get_nonce(&props.db, &pubkey).await? {
Some(nonce) => nonce,
None => {
approve_new_client(&props.actors, pubkey).await?;
match insert_client(&props.db, &pubkey).await? {
InsertClientResult::Inserted => 0,
InsertClientResult::AlreadyExists => match get_nonce(&props.db, &pubkey).await? {
Some(nonce) => nonce,
None => return Err(Error::DatabaseOperationFailed),
},
}
}
};
challenge_client(transport, pubkey, nonce).await?;
transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|e| {
error!(error = ?e, "Failed to send auth success");
Error::Transport
})?;
Ok(pubkey)
}

View File

@@ -0,0 +1,38 @@
use arbiter_proto::transport::Bi;
use kameo::actor::Spawn;
use tracing::{error, info};
use crate::{
actors::{GlobalActors, client::session::ClientSession},
db,
};
pub struct ClientConnection {
pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors,
}
impl ClientConnection {
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors }
}
}
pub mod auth;
pub mod session;
pub async fn connect_client<T>(mut props: ClientConnection, transport: &mut T)
where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized,
{
match auth::authenticate(&mut props, transport).await {
Ok(_pubkey) => {
ClientSession::spawn(ClientSession::new(props));
info!("Client authenticated, session started");
}
Err(err) => {
let _ = transport.send(Err(err.clone())).await;
error!(?err, "Authentication failed, closing connection");
}
}
}

View File

@@ -0,0 +1,71 @@
use kameo::{Actor, messages};
use tracing::error;
use crate::{
actors::{
GlobalActors, client::ClientConnection, keyholder::KeyHolderState, router::RegisterClient,
},
db,
};
pub struct ClientSession {
props: ClientConnection,
}
impl ClientSession {
pub(crate) fn new(props: ClientConnection) -> Self {
Self { props }
}
}
#[messages]
impl ClientSession {
#[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<KeyHolderState, Error> {
use crate::actors::keyholder::GetState;
let vault_state = match self.props.actors.key_holder.ask(GetState {}).await {
Ok(state) => state,
Err(err) => {
error!(?err, actor = "client", "keyholder.query.failed");
return Err(Error::Internal);
}
};
Ok(vault_state)
}
}
impl Actor for ClientSession {
type Args = Self;
type Error = Error;
async fn on_start(
args: Self::Args,
this: kameo::prelude::ActorRef<Self>,
) -> Result<Self, Self::Error> {
args.props
.actors
.router
.ask(RegisterClient { actor: this })
.await
.map_err(|_| Error::ConnectionRegistrationFailed)?;
Ok(args)
}
}
impl ClientSession {
pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
let props = ClientConnection::new(db, actors);
Self { props }
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Connection registration failed")]
ConnectionRegistrationFailed,
#[error("Internal error")]
Internal,
}

View File

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

View File

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

View File

@@ -1,114 +0,0 @@
use crate::{
actors::{
flow_coordinator::client_connect_approval::ClientApprovalController,
operator_registry::{GetConnected, OperatorRegistry},
},
peers::client::{ClientProfile, session::ClientSession},
};
use kameo::{
Actor,
actor::{ActorId, ActorRef, Spawn},
messages,
prelude::{ActorStopReason, Context, WeakActorRef},
reply::DelegatedReply,
};
use std::{collections::HashMap, ops::ControlFlow};
use tracing::info;
pub mod client_connect_approval;
pub struct FlowCoordinator {
pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
operator_registry: ActorRef<OperatorRegistry>,
}
impl FlowCoordinator {
pub fn new(operator_registry: ActorRef<OperatorRegistry>) -> Self {
Self {
clients: HashMap::default(),
operator_registry,
}
}
}
impl Actor for FlowCoordinator {
type Args = Self;
type Error = ();
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
Ok(args)
}
async fn on_link_died(
&mut self,
_: WeakActorRef<Self>,
id: ActorId,
_: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
if self.clients.remove(&id).is_some() {
info!(
?id,
actor = "FlowCoordinator",
event = "client.disconnected"
);
} else {
info!(
?id,
actor = "FlowCoordinator",
event = "unknown.actor.disconnected"
);
}
Ok(ControlFlow::Continue(()))
}
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)]
pub enum ApprovalError {
#[error("No operators connected")]
NoOperatorsConnected,
}
#[messages]
impl FlowCoordinator {
#[message(ctx)]
pub async fn register_client(
&mut self,
actor: ActorRef<ClientSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), actor = "FlowCoordinator", event = "client.connected");
ctx.actor_ref().link(&actor).await;
self.clients.insert(actor.id(), actor);
}
#[message(ctx)]
pub async fn request_client_approval(
&mut self,
client: ClientProfile,
ctx: &mut Context<Self, DelegatedReply<Result<bool, ApprovalError>>>,
) -> DelegatedReply<Result<bool, ApprovalError>> {
let (reply, Some(reply_sender)) = ctx.reply_sender() else {
unreachable!("Expected `request_client_approval` to have callback channel");
};
let Ok(refs) = self.operator_registry.ask(GetConnected).await else {
reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
return reply;
};
if refs.is_empty() {
reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
return reply;
}
ClientApprovalController::spawn(client_connect_approval::Args {
client,
operators: refs,
reply: reply_sender,
});
reply
}
}

View File

@@ -0,0 +1 @@
pub mod v1;

View File

@@ -0,0 +1,243 @@
use std::ops::Deref as _;
use argon2::{Algorithm, Argon2, password_hash::Salt as ArgonSalt};
use chacha20poly1305::{
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
aead::{AeadMut, Error, Payload},
};
use rand::{
Rng as _, SeedableRng,
rngs::{StdRng, SysRng},
};
use crate::safe_cell::{SafeCell, SafeCellHandle as _};
pub const ROOT_KEY_TAG: &[u8] = "arbiter/seal/v1".as_bytes();
pub const TAG: &[u8] = "arbiter/private-key/v1".as_bytes();
pub const NONCE_LENGTH: usize = 24;
#[derive(Default)]
pub struct Nonce([u8; NONCE_LENGTH]);
impl Nonce {
pub fn increment(&mut self) {
for i in (0..self.0.len()).rev() {
if self.0[i] == 0xFF {
self.0[i] = 0;
} else {
self.0[i] += 1;
break;
}
}
}
pub fn to_vec(&self) -> Vec<u8> {
self.0.to_vec()
}
}
impl<'a> TryFrom<&'a [u8]> for Nonce {
type Error = ();
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
if value.len() != NONCE_LENGTH {
return Err(());
}
let mut nonce = [0u8; NONCE_LENGTH];
nonce.copy_from_slice(value);
Ok(Self(nonce))
}
}
pub struct KeyCell(pub SafeCell<Key>);
impl From<SafeCell<Key>> for KeyCell {
fn from(value: SafeCell<Key>) -> Self {
Self(value)
}
}
impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
type Error = ();
fn try_from(mut value: SafeCell<Vec<u8>>) -> Result<Self, Self::Error> {
let value = value.read();
if value.len() != size_of::<Key>() {
return Err(());
}
let cell = SafeCell::new_inline(|cell_write: &mut Key| {
cell_write.copy_from_slice(&value);
});
Ok(Self(cell))
}
}
impl KeyCell {
pub fn new_secure_random() -> Self {
let key = SafeCell::new_inline(|key_buffer: &mut Key| {
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
rng.fill_bytes(key_buffer);
});
key.into()
}
pub fn encrypt_in_place(
&mut self,
nonce: &Nonce,
associated_data: &[u8],
mut buffer: impl AsMut<Vec<u8>>,
) -> Result<(), Error> {
let key_reader = self.0.read();
let key_ref = key_reader.deref();
let cipher = XChaCha20Poly1305::new(key_ref);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let buffer = buffer.as_mut();
cipher.encrypt_in_place(nonce, associated_data, buffer)
}
pub fn decrypt_in_place(
&mut self,
nonce: &Nonce,
associated_data: &[u8],
buffer: &mut SafeCell<Vec<u8>>,
) -> Result<(), Error> {
let key_reader = self.0.read();
let key_ref = key_reader.deref();
let cipher = XChaCha20Poly1305::new(key_ref);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let mut buffer = buffer.write();
let buffer: &mut Vec<u8> = buffer.as_mut();
cipher.decrypt_in_place(nonce, associated_data, buffer)
}
pub fn encrypt(
&mut self,
nonce: &Nonce,
associated_data: &[u8],
plaintext: impl AsRef<[u8]>,
) -> Result<Vec<u8>, Error> {
let key_reader = self.0.read();
let key_ref = key_reader.deref();
let mut cipher = XChaCha20Poly1305::new(key_ref);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let ciphertext = cipher.encrypt(
nonce,
Payload {
msg: plaintext.as_ref(),
aad: associated_data,
},
)?;
Ok(ciphertext)
}
}
pub type Salt = [u8; ArgonSalt::RECOMMENDED_LENGTH];
pub fn generate_salt() -> Salt {
let mut salt = Salt::default();
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
rng.fill_bytes(&mut salt);
salt
}
/// User password might be of different length, have not enough entropy, etc...
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_seal_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
#[allow(clippy::unwrap_used)]
let params = argon2::Params::new(262_144, 3, 4, None).unwrap();
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = SafeCell::new(Key::default());
password.read_inline(|password_source| {
let mut key_buffer = key.write();
let key_buffer: &mut [u8] = key_buffer.as_mut();
#[allow(
clippy::unwrap_used,
reason = "Better fail completely than return a weak key"
)]
hasher
.hash_password_into(password_source.deref(), salt, key_buffer)
.unwrap();
});
key.into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::safe_cell::SafeCell;
#[test]
pub fn derive_seal_key_deterministic() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let password2 = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key1 = derive_seal_key(password, &salt);
let mut key2 = derive_seal_key(password2, &salt);
let key1_reader = key1.0.read();
let key2_reader = key2.0.read();
assert_eq!(key1_reader.deref(), key2_reader.deref());
}
#[test]
pub fn successful_derive() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_seal_key(password, &salt);
let key_reader = key.0.read();
let key_ref = key_reader.deref();
assert_ne!(key_ref.as_slice(), &[0u8; 32][..]);
}
#[test]
pub fn encrypt_decrypt() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_seal_key(password, &salt);
let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
let associated_data = b"associated data";
let mut buffer = b"secret data".to_vec();
key.encrypt_in_place(&nonce, associated_data, &mut buffer)
.unwrap();
assert_ne!(buffer, b"secret data");
let mut buffer = SafeCell::new(buffer);
key.decrypt_in_place(&nonce, associated_data, &mut buffer)
.unwrap();
let buffer = buffer.read();
assert_eq!(*buffer, b"secret data");
}
#[test]
// We should fuzz this
pub fn test_nonce_increment() {
let mut nonce = Nonce([0u8; NONCE_LENGTH]);
nonce.increment();
assert_eq!(
nonce.0,
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
]
);
}
}

View File

@@ -0,0 +1,412 @@
use chrono::Utc;
use diesel::{
ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper,
dsl::{insert_into, update},
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{Actor, Reply, messages};
use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info};
use crate::safe_cell::SafeCell;
use crate::{
db::{
self,
models::{self, RootKeyHistory},
schema::{self},
},
safe_cell::SafeCellHandle as _,
};
use encryption::v1::{self, KeyCell, Nonce};
pub mod encryption;
#[derive(Default, EnumDiscriminants)]
#[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))]
enum State {
#[default]
Unbootstrapped,
Sealed {
root_key_history_id: i32,
},
Unsealed {
root_key_history_id: i32,
root_key: KeyCell,
},
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error {
#[error("Keyholder is already bootstrapped")]
#[diagnostic(code(arbiter::keyholder::already_bootstrapped))]
AlreadyBootstrapped,
#[error("Keyholder is not bootstrapped")]
#[diagnostic(code(arbiter::keyholder::not_bootstrapped))]
NotBootstrapped,
#[error("Invalid key provided")]
#[diagnostic(code(arbiter::keyholder::invalid_key))]
InvalidKey,
#[error("Requested aead entry not found")]
#[diagnostic(code(arbiter::keyholder::aead_not_found))]
NotFound,
#[error("Encryption error: {0}")]
#[diagnostic(code(arbiter::keyholder::encryption_error))]
Encryption(#[from] chacha20poly1305::aead::Error),
#[error("Database error: {0}")]
#[diagnostic(code(arbiter::keyholder::database_error))]
DatabaseConnection(#[from] db::PoolError),
#[error("Database transaction error: {0}")]
#[diagnostic(code(arbiter::keyholder::database_transaction_error))]
DatabaseTransaction(#[from] diesel::result::Error),
#[error("Broken database")]
#[diagnostic(code(arbiter::keyholder::broken_database))]
BrokenDatabase,
}
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
/// Provides API for encrypting and decrypting data using the vault root key.
/// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor.
#[derive(Actor)]
pub struct KeyHolder {
db: db::DatabasePool,
state: State,
}
#[messages]
impl KeyHolder {
pub async fn new(db: db::DatabasePool) -> Result<Self, Error> {
let state = {
let mut conn = db.get().await?;
let (root_key_history,) = schema::arbiter_settings::table
.left_join(schema::root_key_history::table)
.select((Option::<RootKeyHistory>::as_select(),))
.get_result::<(Option<RootKeyHistory>,)>(&mut conn)
.await?;
match root_key_history {
Some(root_key_history) => State::Sealed {
root_key_history_id: root_key_history.id,
},
None => State::Unbootstrapped,
}
};
Ok(Self { db, state })
}
// Exclusive transaction to avoid race condtions if multiple keyholders write
// additional layer of protection against nonce-reuse
async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> {
let mut conn = pool.get().await?;
let nonce = conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let current_nonce: Vec<u8> = schema::root_key_history::table
.filter(schema::root_key_history::id.eq(root_key_id))
.select(schema::root_key_history::data_encryption_nonce)
.first(conn)
.await?;
let mut nonce =
v1::Nonce::try_from(current_nonce.as_slice()).map_err(|_| {
error!(
"Broken database: invalid nonce for root key history id={}",
root_key_id
);
Error::BrokenDatabase
})?;
nonce.increment();
update(schema::root_key_history::table)
.filter(schema::root_key_history::id.eq(root_key_id))
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
.execute(conn)
.await?;
Result::<_, Error>::Ok(nonce)
})
})
.await?;
Ok(nonce)
}
#[message]
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if !matches!(self.state, State::Unbootstrapped) {
return Err(Error::AlreadyBootstrapped);
}
let salt = v1::generate_salt();
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt);
let mut root_key = KeyCell::new_secure_random();
// Zero nonces are fine because they are one-time
let root_key_nonce = v1::Nonce::default();
let data_encryption_nonce = v1::Nonce::default();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
let root_key_reader = reader.as_slice();
seal_key
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader)
.map_err(|err| {
error!(?err, "Fatal bootstrap error");
Error::Encryption(err)
})
})?;
let mut conn = self.db.get().await?;
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let root_key_history_id = conn
.transaction(|conn| {
Box::pin(async move {
let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: root_key_ciphertext,
tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: root_key_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce_bytes,
schema_version: 1,
salt: salt.to_vec(),
})
.returning(schema::root_key_history::id)
.get_result(conn)
.await?;
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
.execute(conn)
.await?;
Result::<_, diesel::result::Error>::Ok(root_key_history_id)
})
})
.await?;
self.state = State::Unsealed {
root_key,
root_key_history_id,
};
info!("Keyholder bootstrapped successfully");
Ok(())
}
#[message]
pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
let State::Sealed {
root_key_history_id,
} = &self.state
else {
return Err(Error::NotBootstrapped);
};
// We don't want to hold connection while doing expensive KDF 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))
.select(schema::root_key_history::data_encryption_nonce)
.select(RootKeyHistory::as_select())
.first(&mut conn)
.await?
};
let salt = &current_key.salt;
let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| {
error!("Broken database: invalid salt for root key");
Error::BrokenDatabase
})?;
let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt);
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
let nonce = v1::Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(
|_| {
error!("Broken database: invalid nonce for root key");
Error::BrokenDatabase
},
)?;
seal_key
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
.map_err(|err| {
error!(?err, "Failed to unseal root key: invalid seal key");
Error::InvalidKey
})?;
self.state = State::Unsealed {
root_key_history_id: current_key.id,
root_key: v1::KeyCell::try_from(root_key).map_err(|err| {
error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase
})?,
};
info!("Keyholder unsealed successfully");
Ok(())
}
// Decrypts the `aead_encrypted` entry with the given ID and returns the plaintext
#[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let State::Unsealed { root_key, .. } = &mut self.state else {
return Err(Error::NotBootstrapped);
};
let row: models::AeadEncrypted = {
let mut conn = self.db.get().await?;
schema::aead_encrypted::table
.select(models::AeadEncrypted::as_select())
.filter(schema::aead_encrypted::id.eq(aead_id))
.first(&mut conn)
.await
.optional()?
.ok_or(Error::NotFound)?
};
let nonce = v1::Nonce::try_from(row.current_nonce.as_slice()).map_err(|_| {
error!(
"Broken database: invalid nonce for aead_encrypted id={}",
aead_id
);
Error::BrokenDatabase
})?;
let mut output = SafeCell::new(row.ciphertext);
root_key.decrypt_in_place(&nonce, v1::TAG, &mut output)?;
Ok(output)
}
// 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>>) -> Result<i32, Error> {
let State::Unsealed {
root_key,
root_key_history_id,
} = &mut self.state
else {
return Err(Error::NotBootstrapped);
};
// Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire
// Borrow checker note: &mut borrow a few lines above is disjoint from this field
let nonce = Self::get_new_nonce(&self.db, *root_key_history_id).await?;
let mut ciphertext_buffer = plaintext.write();
let ciphertext_buffer: &mut Vec<u8> = ciphertext_buffer.as_mut();
root_key.encrypt_in_place(&nonce, v1::TAG, &mut *ciphertext_buffer)?;
let ciphertext = std::mem::take(ciphertext_buffer);
let mut conn = self.db.get().await?;
let aead_id: i32 = insert_into(schema::aead_encrypted::table)
.values(&models::NewAeadEncrypted {
ciphertext,
tag: v1::TAG.to_vec(),
current_nonce: nonce.to_vec(),
schema_version: 1,
associated_root_key_id: *root_key_history_id,
created_at: Utc::now().into(),
})
.returning(schema::aead_encrypted::id)
.get_result(&mut conn)
.await?;
Ok(aead_id)
}
#[message]
pub fn get_state(&self) -> KeyHolderState {
self.state.discriminant()
}
#[message]
pub fn seal(&mut self) -> Result<(), Error> {
let State::Unsealed {
root_key_history_id,
..
} = &self.state
else {
return Err(Error::NotBootstrapped);
};
self.state = State::Sealed {
root_key_history_id: *root_key_history_id,
};
Ok(())
}
}
#[cfg(test)]
mod tests {
use diesel::SelectableHelper;
use diesel_async::RunQueryDsl;
use crate::{
db::{self},
safe_cell::SafeCell,
};
use super::*;
async fn bootstrapped_actor(db: &db::DatabasePool) -> KeyHolder {
let mut actor = KeyHolder::new(db.clone()).await.unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.bootstrap(seal_key).await.unwrap();
actor
}
#[tokio::test]
#[test_log::test]
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await;
let root_key_history_id = match actor.state {
State::Unsealed {
root_key_history_id,
..
} => root_key_history_id,
_ => panic!("expected unsealed state"),
};
let n1 = KeyHolder::get_new_nonce(&db, root_key_history_id)
.await
.unwrap();
let n2 = KeyHolder::get_new_nonce(&db, root_key_history_id)
.await
.unwrap();
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");
let mut conn = db.get().await.unwrap();
let root_row: models::RootKeyHistory = schema::root_key_history::table
.select(models::RootKeyHistory::as_select())
.first(&mut conn)
.await
.unwrap();
assert_eq!(root_row.data_encryption_nonce, n2.to_vec());
let id = actor
.create_new(SafeCell::new(b"post-interleave".to_vec()))
.await
.unwrap();
let row: models::AeadEncrypted = schema::aead_encrypted::table
.filter(schema::aead_encrypted::id.eq(id))
.select(models::AeadEncrypted::as_select())
.first(&mut conn)
.await
.unwrap();
assert!(
row.current_nonce > n2.to_vec(),
"next write must advance nonce"
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,173 @@
use std::{collections::HashMap, ops::ControlFlow};
use ed25519_dalek::VerifyingKey;
use kameo::{
Actor,
actor::{ActorId, ActorRef},
messages,
prelude::{ActorStopReason, Context, WeakActorRef},
reply::DelegatedReply,
};
use tokio::{sync::watch, task::JoinSet};
use tracing::{info, warn};
use crate::actors::{
client::session::ClientSession,
user_agent::session::{RequestNewClientApproval, UserAgentSession},
};
#[derive(Default)]
pub struct MessageRouter {
pub user_agents: HashMap<ActorId, ActorRef<UserAgentSession>>,
pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
}
impl Actor for MessageRouter {
type Args = Self;
type Error = ();
async fn on_start(args: Self::Args, _: ActorRef<Self>) -> Result<Self, Self::Error> {
Ok(args)
}
async fn on_link_died(
&mut self,
_: WeakActorRef<Self>,
id: ActorId,
_: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
if self.user_agents.remove(&id).is_some() {
info!(
?id,
actor = "MessageRouter",
event = "useragent.disconnected"
);
} else if self.clients.remove(&id).is_some() {
info!(?id, actor = "MessageRouter", event = "client.disconnected");
} else {
info!(
?id,
actor = "MessageRouter",
event = "unknown.actor.disconnected"
);
}
Ok(ControlFlow::Continue(()))
}
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)]
pub enum ApprovalError {
#[error("No user agents connected")]
NoUserAgentsConnected,
}
async fn request_client_approval(
user_agents: &[WeakActorRef<UserAgentSession>],
client_pubkey: VerifyingKey,
) -> Result<bool, ApprovalError> {
if user_agents.is_empty() {
return Err(ApprovalError::NoUserAgentsConnected);
}
let mut pool = JoinSet::new();
let (cancel_tx, cancel_rx) = watch::channel(());
for weak_ref in user_agents {
match weak_ref.upgrade() {
Some(agent) => {
let cancel_rx = cancel_rx.clone();
pool.spawn(async move {
agent
.ask(RequestNewClientApproval {
client_pubkey,
cancel_flag: cancel_rx.clone(),
})
.await
});
}
None => {
warn!(
id = weak_ref.id().to_string(),
actor = "MessageRouter",
event = "useragent.disconnected_before_approval"
);
}
}
}
while let Some(result) = pool.join_next().await {
match result {
Ok(Ok(approved)) => {
// cancel other pending requests
let _ = cancel_tx.send(());
return Ok(approved);
}
Ok(Err(err)) => {
warn!(
?err,
actor = "MessageRouter",
event = "useragent.approval_error"
);
}
Err(err) => {
warn!(
?err,
actor = "MessageRouter",
event = "useragent.approval_task_failed"
);
}
}
}
Err(ApprovalError::NoUserAgentsConnected)
}
#[messages]
impl MessageRouter {
#[message(ctx)]
pub async fn register_user_agent(
&mut self,
actor: ActorRef<UserAgentSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), actor = "MessageRouter", event = "useragent.connected");
ctx.actor_ref().link(&actor).await;
self.user_agents.insert(actor.id(), actor);
}
#[message(ctx)]
pub async fn register_client(
&mut self,
actor: ActorRef<ClientSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), actor = "MessageRouter", event = "client.connected");
ctx.actor_ref().link(&actor).await;
self.clients.insert(actor.id(), actor);
}
#[message(ctx)]
pub async fn request_client_approval(
&mut self,
client_pubkey: VerifyingKey,
ctx: &mut Context<Self, DelegatedReply<Result<bool, ApprovalError>>>,
) -> DelegatedReply<Result<bool, ApprovalError>> {
let (reply, Some(reply_sender)) = ctx.reply_sender() else {
unreachable!("Expected `request_client_approval` to have callback channel");
};
let weak_refs = self
.user_agents
.values()
.map(|agent| agent.downgrade())
.collect::<Vec<_>>();
tokio::task::spawn(async move {
let result = request_client_approval(&weak_refs, client_pubkey).await;
reply_sender.send(result);
});
reply
}
}

View File

@@ -1,19 +1,18 @@
use super::{AuthenticatedOperator, OperatorConnection};
use arbiter_crypto::authn::{self, AuthChallenge};
use arbiter_proto::transport::Bi;
use state::{
AuthContext, AuthError, AuthEvents, AuthStateMachine, AuthStates, ChallengeRequest,
ChallengeSolution,
};
use tracing::error;
use crate::actors::user_agent::{
AuthPublicKey, UserAgentConnection,
auth::state::{AuthContext, AuthStateMachine},
};
mod state;
use state::*;
#[derive(Debug, Clone)]
pub enum Inbound {
AuthChallengeRequest {
pubkey: authn::PublicKey,
pubkey: AuthPublicKey,
bootstrap_token: Option<String>,
},
AuthChallengeSolution {
@@ -38,16 +37,9 @@ impl Error {
}
}
impl From<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
error!(?e, "Database error");
Self::internal("Database error")
}
}
#[derive(Debug, Clone)]
pub enum Outbound {
AuthChallenge { challenge: AuthChallenge },
AuthChallenge { nonce: i32 },
AuthSuccess,
}
@@ -55,11 +47,12 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents {
match payload {
Inbound::AuthChallengeRequest {
pubkey,
bootstrap_token,
} => AuthEvents::AuthRequest(ChallengeRequest {
bootstrap_token: None,
} => AuthEvents::AuthRequest(ChallengeRequest { pubkey }),
Inbound::AuthChallengeRequest {
pubkey,
bootstrap_token,
}),
bootstrap_token: Some(token),
} => AuthEvents::BootstrapAuthRequest(BootstrapAuthRequest { pubkey, token }),
Inbound::AuthChallengeSolution { signature } => {
AuthEvents::ReceivedSolution(ChallengeSolution {
solution: signature,
@@ -69,21 +62,22 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents {
}
pub async fn authenticate<T>(
props: &mut OperatorConnection,
transport: &mut T,
) -> Result<AuthenticatedOperator, Error>
props: &mut UserAgentConnection,
transport: T,
) -> Result<AuthPublicKey, Error>
where
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
T: Bi<Inbound, Result<Outbound, Error>> + Send,
{
let mut state = AuthStateMachine::new(AuthContext::new(props, transport));
loop {
// `state` holds a mutable reference to `props` so we can't access it directly here
let Some(payload) = state.context_mut().transport.recv().await else {
return Err(Error::Transport);
};
match state.process_event(parse_auth_event(payload)).await {
Ok(AuthStates::AuthOk(result)) => return Ok(result.clone()),
Ok(AuthStates::AuthOk(key)) => return Ok(key.clone()),
Err(AuthError::ActionFailed(err)) => {
error!(?err, "State machine action failed");
return Err(err);

View File

@@ -0,0 +1,222 @@
use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::RunQueryDsl;
use tracing::error;
use super::Error;
use crate::{
actors::{
bootstrap::ConsumeToken,
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound},
},
db::schema,
};
pub struct ChallengeRequest {
pub pubkey: AuthPublicKey,
}
pub struct BootstrapAuthRequest {
pub pubkey: AuthPublicKey,
pub token: String,
}
pub struct ChallengeContext {
pub challenge_nonce: i32,
pub key: AuthPublicKey,
}
pub struct ChallengeSolution {
pub solution: Vec<u8>,
}
smlang::statemachine!(
name: Auth,
custom_error: true,
transitions: {
*Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext),
Init + BootstrapAuthRequest(BootstrapAuthRequest) / async verify_bootstrap_token = AuthOk(AuthPublicKey),
SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(AuthPublicKey),
}
);
async fn create_nonce(db: &crate::db::DatabasePool, pubkey_bytes: &[u8]) -> Result<i32, Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let current_nonce = schema::useragent_client::table
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.select(schema::useragent_client::nonce)
.first::<i32>(conn)
.await?;
update(schema::useragent_client::table)
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.set(schema::useragent_client::nonce.eq(current_nonce + 1))
.execute(conn)
.await?;
Result::<_, diesel::result::Error>::Ok(current_nonce)
})
})
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?
.ok_or_else(|| {
error!(?pubkey_bytes, "Public key not found in database");
Error::UnregisteredPublicKey
})
}
async fn register_key(db: &crate::db::DatabasePool, pubkey: &AuthPublicKey) -> Result<(), Error> {
let pubkey_bytes = pubkey.to_stored_bytes();
let key_type = pubkey.key_type();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
diesel::insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes),
schema::useragent_client::nonce.eq(1),
schema::useragent_client::key_type.eq(key_type),
))
.execute(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
Ok(())
}
pub struct AuthContext<'a, T> {
pub(super) conn: &'a mut UserAgentConnection,
pub(super) transport: T,
}
impl<'a, T> AuthContext<'a, T> {
pub fn new(conn: &'a mut UserAgentConnection, transport: T) -> Self {
Self { conn, transport }
}
}
impl<T> AuthStateMachineContext for AuthContext<'_, T>
where
T: Bi<super::Inbound, Result<super::Outbound, Error>> + Send,
{
type Error = Error;
async fn prepare_challenge(
&mut self,
ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> {
let stored_bytes = pubkey.to_stored_bytes();
let nonce = create_nonce(&self.conn.db, &stored_bytes).await?;
self.transport
.send(Ok(Outbound::AuthChallenge { nonce }))
.await
.map_err(|e| {
error!(?e, "Failed to send auth challenge");
Error::Transport
})?;
Ok(ChallengeContext {
challenge_nonce: nonce,
key: pubkey,
})
}
#[allow(missing_docs)]
#[allow(clippy::result_unit_err)]
async fn verify_bootstrap_token(
&mut self,
BootstrapAuthRequest { pubkey, token }: BootstrapAuthRequest,
) -> Result<AuthPublicKey, Self::Error> {
let token_ok: bool = self
.conn
.actors
.bootstrapper
.ask(ConsumeToken {
token: token.clone(),
})
.await
.map_err(|e| {
error!(?e, "Failed to consume bootstrap token");
Error::internal("Failed to consume bootstrap token")
})?;
if !token_ok {
error!("Invalid bootstrap token provided");
return Err(Error::InvalidBootstrapToken);
}
register_key(&self.conn.db, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(pubkey)
}
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
async fn verify_solution(
&mut self,
ChallengeContext {
challenge_nonce,
key,
}: &ChallengeContext,
ChallengeSolution { solution }: ChallengeSolution,
) -> Result<AuthPublicKey, Self::Error> {
let formatted = arbiter_proto::format_challenge(*challenge_nonce, &key.to_stored_bytes());
let valid = match key {
AuthPublicKey::Ed25519(vk) => {
let sig = solution.as_slice().try_into().map_err(|_| {
error!(?solution, "Invalid Ed25519 signature length");
Error::InvalidChallengeSolution
})?;
vk.verify_strict(&formatted, &sig).is_ok()
}
AuthPublicKey::EcdsaSecp256k1(vk) => {
use k256::ecdsa::signature::Verifier as _;
let sig = k256::ecdsa::Signature::try_from(solution.as_slice()).map_err(|_| {
error!(?solution, "Invalid ECDSA signature bytes");
Error::InvalidChallengeSolution
})?;
vk.verify(&formatted, &sig).is_ok()
}
AuthPublicKey::Rsa(pk) => {
use rsa::signature::Verifier as _;
let verifying_key = rsa::pss::VerifyingKey::<sha2::Sha256>::new(pk.clone());
let sig = rsa::pss::Signature::try_from(solution.as_slice()).map_err(|_| {
error!(?solution, "Invalid RSA signature bytes");
Error::InvalidChallengeSolution
})?;
verifying_key.verify(&formatted, &sig).is_ok()
}
};
if valid {
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
}
Ok(key.clone())
}
}

View File

@@ -0,0 +1,94 @@
use crate::{
actors::GlobalActors,
db::{self, models::KeyType},
};
/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake.
#[derive(Clone, Debug)]
pub enum AuthPublicKey {
Ed25519(ed25519_dalek::VerifyingKey),
/// Compressed SEC1 public key; signature bytes are raw 64-byte (r||s).
EcdsaSecp256k1(k256::ecdsa::VerifyingKey),
/// RSA-2048+ public key (Windows Hello / KeyCredentialManager); signature bytes are PSS+SHA-256.
Rsa(rsa::RsaPublicKey),
}
impl AuthPublicKey {
/// Canonical bytes stored in DB and echoed back in the challenge.
/// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI.
pub fn to_stored_bytes(&self) -> Vec<u8> {
match self {
AuthPublicKey::Ed25519(k) => k.to_bytes().to_vec(),
// SEC1 compressed (33 bytes) is the natural compact format for secp256k1
AuthPublicKey::EcdsaSecp256k1(k) => k.to_encoded_point(true).as_bytes().to_vec(),
AuthPublicKey::Rsa(k) => {
use rsa::pkcs8::EncodePublicKey as _;
#[allow(clippy::expect_used)]
k.to_public_key_der()
.expect("rsa SPKI encoding is infallible")
.to_vec()
}
}
}
pub fn key_type(&self) -> KeyType {
match self {
AuthPublicKey::Ed25519(_) => KeyType::Ed25519,
AuthPublicKey::EcdsaSecp256k1(_) => KeyType::EcdsaSecp256k1,
AuthPublicKey::Rsa(_) => KeyType::Rsa,
}
}
}
impl TryFrom<(KeyType, Vec<u8>)> for AuthPublicKey {
type Error = &'static str;
fn try_from(value: (KeyType, Vec<u8>)) -> Result<Self, Self::Error> {
let (key_type, bytes) = value;
match key_type {
KeyType::Ed25519 => {
let bytes: [u8; 32] = bytes.try_into().map_err(|_| "invalid Ed25519 key length")?;
let key = ed25519_dalek::VerifyingKey::from_bytes(&bytes)
.map_err(|_e| "invalid Ed25519 key")?;
Ok(AuthPublicKey::Ed25519(key))
}
KeyType::EcdsaSecp256k1 => {
let point =
k256::EncodedPoint::from_bytes(&bytes).map_err(|_e| "invalid ECDSA key")?;
let key = k256::ecdsa::VerifyingKey::from_encoded_point(&point)
.map_err(|_e| "invalid ECDSA key")?;
Ok(AuthPublicKey::EcdsaSecp256k1(key))
}
KeyType::Rsa => {
use rsa::pkcs8::DecodePublicKey as _;
let key = rsa::RsaPublicKey::from_public_key_der(&bytes)
.map_err(|_e| "invalid RSA key")?;
Ok(AuthPublicKey::Rsa(key))
}
}
}
}
// Messages, sent by user agent to connection client without having a request
#[derive(Debug)]
pub enum OutOfBand {
ClientConnectionRequest { pubkey: ed25519_dalek::VerifyingKey },
ClientConnectionCancel,
}
pub struct UserAgentConnection {
pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors,
}
impl UserAgentConnection {
pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors }
}
}
pub mod auth;
pub mod session;
pub use auth::authenticate;
pub use session::UserAgentSession;

View File

@@ -0,0 +1,132 @@
use std::borrow::Cow;
use arbiter_proto::transport::Sender;
use async_trait::async_trait;
use ed25519_dalek::VerifyingKey;
use kameo::{Actor, messages};
use thiserror::Error;
use tokio::sync::watch;
use tracing::error;
use crate::actors::{
router::RegisterUserAgent,
user_agent::{OutOfBand, UserAgentConnection},
};
mod state;
use state::{DummyContext, UserAgentEvents, UserAgentStateMachine};
#[derive(Debug, Error)]
pub enum Error {
#[error("State transition failed")]
State,
#[error("Internal error: {message}")]
Internal { message: Cow<'static, str> },
}
impl Error {
pub fn internal(message: impl Into<Cow<'static, str>>) -> Self {
Self::Internal {
message: message.into(),
}
}
}
pub struct UserAgentSession {
props: UserAgentConnection,
state: UserAgentStateMachine<DummyContext>,
#[allow(dead_code, reason = "The session keeps ownership of the outbound transport even before the state-machine flow starts using it directly")]
sender: Box<dyn Sender<OutOfBand>>,
}
mod connection;
pub(crate) use connection::{
BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList,
HandleGrantCreate, HandleGrantDelete, HandleGrantList, HandleQueryVaultState,
};
pub use connection::{HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError};
impl UserAgentSession {
pub(crate) fn new(props: UserAgentConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self {
Self {
props,
state: UserAgentStateMachine::new(DummyContext),
sender,
}
}
pub fn new_test(db: crate::db::DatabasePool, actors: crate::actors::GlobalActors) -> Self {
struct DummySender;
#[async_trait]
impl Sender<OutOfBand> for DummySender {
async fn send(
&mut self,
_item: OutOfBand,
) -> Result<(), arbiter_proto::transport::Error> {
Ok(())
}
}
Self::new(UserAgentConnection::new(db, actors), Box::new(DummySender))
}
fn transition(&mut self, event: UserAgentEvents) -> Result<(), Error> {
self.state.process_event(event).map_err(|e| {
error!(?e, "State transition failed");
Error::State
})?;
Ok(())
}
}
#[messages]
impl UserAgentSession {
#[message]
pub async fn request_new_client_approval(
&mut self,
client_pubkey: VerifyingKey,
mut cancel_flag: watch::Receiver<()>,
) -> Result<bool, ()> {
if self
.sender
.send(OutOfBand::ClientConnectionRequest {
pubkey: client_pubkey,
})
.await
.is_err()
{
return Err(());
}
let _ = cancel_flag.changed().await;
let _ = self.sender.send(OutOfBand::ClientConnectionCancel).await;
Ok(false)
}
}
impl Actor for UserAgentSession {
type Args = Self;
type Error = Error;
async fn on_start(
args: Self::Args,
this: kameo::prelude::ActorRef<Self>,
) -> Result<Self, Self::Error> {
args.props
.actors
.router
.ask(RegisterUserAgent {
actor: this.clone(),
})
.await
.map_err(|err| {
error!(?err, "Failed to register user agent connection with router");
Error::internal("Failed to register user agent connection with router")
})?;
Ok(args)
}
}

View File

@@ -0,0 +1,354 @@
use std::sync::Mutex;
use alloy::primitives::Address;
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use kameo::error::SendError;
use kameo::messages;
use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::actors::keyholder::KeyHolderState;
use crate::actors::user_agent::session::Error;
use crate::evm::policies::{Grant, SpecificGrant};
use crate::safe_cell::SafeCell;
use crate::{
actors::{
evm::{
Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
},
keyholder::{self, Bootstrap, TryUnseal},
user_agent::session::{
UserAgentSession,
state::{UnsealContext, UserAgentEvents, UserAgentStates},
},
},
safe_cell::SafeCellHandle as _,
};
impl UserAgentSession {
fn take_unseal_secret(&mut self) -> Result<(EphemeralSecret, PublicKey), Error> {
let UserAgentStates::WaitingForUnsealKey(unseal_context) = self.state.state() else {
error!("Received encrypted key in invalid state");
return Err(Error::internal("Invalid state for unseal encrypted key"));
};
let ephemeral_secret = {
#[allow(
clippy::unwrap_used,
reason = "Mutex poison is unrecoverable and should panic"
)]
let mut secret_lock = unseal_context.secret.lock().unwrap();
let secret = secret_lock.take();
match secret {
Some(secret) => secret,
None => {
drop(secret_lock);
error!("Ephemeral secret already taken");
return Err(Error::internal("Ephemeral secret already taken"));
}
}
};
Ok((ephemeral_secret, unseal_context.client_public_key))
}
fn decrypt_client_key_material(
ephemeral_secret: EphemeralSecret,
client_public_key: PublicKey,
nonce: &[u8],
ciphertext: &[u8],
associated_data: &[u8],
) -> Result<SafeCell<Vec<u8>>, ()> {
let nonce = XNonce::from_slice(nonce);
let shared_secret = ephemeral_secret.diffie_hellman(&client_public_key);
let cipher = XChaCha20Poly1305::new(shared_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),
Err(err) => {
error!(?err, "Failed to decrypt encrypted key material");
Err(())
}
}
}
}
pub struct UnsealStartResponse {
pub server_pubkey: PublicKey,
}
#[derive(Debug, Error)]
pub enum UnsealError {
#[error("Invalid key provided for unsealing")]
InvalidKey,
#[error("Internal error during unsealing process")]
General(#[from] super::Error),
}
#[derive(Debug, Error)]
pub enum BootstrapError {
#[error("Invalid key provided for bootstrapping")]
InvalidKey,
#[error("Vault is already bootstrapped")]
AlreadyBootstrapped,
#[error("Internal error during bootstrapping process")]
General(#[from] super::Error),
}
#[messages]
impl UserAgentSession {
#[message]
pub async fn handle_unseal_request(
&mut self,
client_pubkey: x25519_dalek::PublicKey,
) -> Result<UnsealStartResponse, Error> {
let secret = EphemeralSecret::random();
let public_key = PublicKey::from(&secret);
self.transition(UserAgentEvents::UnsealRequest(UnsealContext {
secret: Mutex::new(Some(secret)),
client_public_key: client_pubkey,
}))?;
Ok(UnsealStartResponse {
server_pubkey: public_key,
})
}
#[message]
pub async fn handle_unseal_encrypted_key(
&mut self,
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> Result<(), UnsealError> {
let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() {
Ok(values) => values,
Err(Error::State) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(UnsealError::InvalidKey);
}
Err(_err) => {
return Err(Error::internal("Failed to take unseal secret").into());
}
};
let seal_key_buffer = match Self::decrypt_client_key_material(
ephemeral_secret,
client_public_key,
&nonce,
&ciphertext,
&associated_data,
) {
Ok(buffer) => buffer,
Err(()) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(UnsealError::InvalidKey);
}
};
match self
.props
.actors
.key_holder
.ask(TryUnseal {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(_) => {
info!("Successfully unsealed key with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
}
Err(SendError::HandlerError(keyholder::Error::InvalidKey)) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(UnsealError::InvalidKey)
}
Err(SendError::HandlerError(err)) => {
error!(?err, "Keyholder failed to unseal key");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(UnsealError::InvalidKey)
}
Err(err) => {
error!(?err, "Failed to send unseal request to keyholder");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(Error::internal("Vault actor error").into())
}
}
}
#[message]
pub(crate) async fn handle_bootstrap_encrypted_key(
&mut self,
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> Result<(), BootstrapError> {
let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() {
Ok(values) => values,
Err(Error::State) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(BootstrapError::InvalidKey);
}
Err(err) => return Err(err.into()),
};
let seal_key_buffer = match Self::decrypt_client_key_material(
ephemeral_secret,
client_public_key,
&nonce,
&ciphertext,
&associated_data,
) {
Ok(buffer) => buffer,
Err(()) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(BootstrapError::InvalidKey);
}
};
match self
.props
.actors
.key_holder
.ask(Bootstrap {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(_) => {
info!("Successfully bootstrapped vault with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
}
Err(SendError::HandlerError(keyholder::Error::AlreadyBootstrapped)) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::AlreadyBootstrapped)
}
Err(SendError::HandlerError(err)) => {
error!(?err, "Keyholder failed to bootstrap vault");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::InvalidKey)
}
Err(err) => {
error!(?err, "Failed to send bootstrap request to keyholder");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::General(Error::internal(
"Vault actor error",
)))
}
}
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<KeyHolderState, Error> {
use crate::actors::keyholder::GetState;
let vault_state = match self.props.actors.key_holder.ask(GetState {}).await {
Ok(state) => state,
Err(err) => {
error!(?err, actor = "useragent", "keyholder.query.failed");
return Err(Error::internal("Vault is in broken state"));
}
};
Ok(vault_state)
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<Address, Error> {
match self.props.actors.evm.ask(Generate {}).await {
Ok(address) => Ok(address),
Err(SendError::HandlerError(err)) => Err(Error::internal(format!(
"EVM wallet generation failed: {err}"
))),
Err(err) => {
error!(?err, "EVM actor unreachable during wallet create");
Err(Error::internal("EVM actor unreachable"))
}
}
}
#[message]
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<Address>, Error> {
match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => Ok(wallets),
Err(err) => {
error!(?err, "EVM wallet list failed");
Err(Error::internal("Failed to list EVM wallets"))
}
}
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.props.actors.evm.ask(UseragentListGrants {}).await {
Ok(grants) => Ok(grants),
Err(err) => {
error!(?err, "EVM grant list failed");
Err(Error::internal("Failed to list EVM grants"))
}
}
}
#[message]
pub(crate) async fn handle_grant_create(
&mut self,
client_id: i32,
basic: crate::evm::policies::SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant,
) -> Result<i32, Error> {
match self
.props
.actors
.evm
.ask(UseragentCreateGrant {
client_id,
basic,
grant,
})
.await
{
Ok(grant_id) => Ok(grant_id),
Err(err) => {
error!(?err, "EVM grant create failed");
Err(Error::internal("Failed to create EVM grant"))
}
}
}
#[message]
pub(crate) async fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), Error> {
match self
.props
.actors
.evm
.ask(UseragentDeleteGrant { grant_id })
.await
{
Ok(()) => Ok(()),
Err(err) => {
error!(?err, "EVM grant delete failed");
Err(Error::internal("Failed to delete EVM grant"))
}
}
}
}

View File

@@ -0,0 +1,27 @@
use std::sync::Mutex;
use x25519_dalek::{EphemeralSecret, PublicKey};
pub struct UnsealContext {
pub client_public_key: PublicKey,
pub secret: Mutex<Option<EphemeralSecret>>,
}
smlang::statemachine!(
name: UserAgent,
custom_error: false,
transitions: {
*Idle + UnsealRequest(UnsealContext) / generate_temp_keypair = WaitingForUnsealKey(UnsealContext),
WaitingForUnsealKey(UnsealContext) + ReceivedValidKey = Unsealed,
WaitingForUnsealKey(UnsealContext) + ReceivedInvalidKey = Idle,
}
);
pub struct DummyContext;
impl UserAgentStateMachineContext for DummyContext {
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
fn generate_temp_keypair(&mut self, event_data: UnsealContext) -> Result<UnsealContext, ()> {
Ok(event_data)
}
}

View File

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

View File

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

View File

@@ -1,45 +1,53 @@
use std::sync::Arc;
use miette::Diagnostic;
use thiserror::Error;
use crate::{
actors::GlobalActors,
context::tls::TlsManager,
db::{self},
};
use std::sync::Arc;
use thiserror::Error;
pub mod tls;
#[derive(Error, Debug)]
#[derive(Error, Debug, Diagnostic)]
pub enum InitError {
#[error("Database setup failed: {0}")]
#[diagnostic(code(arbiter_server::init::database_setup))]
DatabaseSetup(#[from] db::DatabaseSetupError),
#[error("Connection acquire failed: {0}")]
#[diagnostic(code(arbiter_server::init::database_pool))]
DatabasePool(#[from] db::PoolError),
#[error("Database query error: {0}")]
#[diagnostic(code(arbiter_server::init::database_query))]
DatabaseQuery(#[from] diesel::result::Error),
#[error("TLS initialization failed: {0}")]
#[diagnostic(code(arbiter_server::init::tls_init))]
Tls(#[from] tls::InitError),
#[error("Actor spawn failed: {0}")]
#[diagnostic(code(arbiter_server::init::actor_spawn))]
ActorSpawn(#[from] crate::actors::SpawnError),
#[error("I/O Error: {0}")]
#[diagnostic(code(arbiter_server::init::io))]
Io(#[from] std::io::Error),
}
pub struct __ServerContextInner {
pub struct _ServerContextInner {
pub db: db::DatabasePool,
pub tls: TlsManager,
pub actors: GlobalActors,
}
#[derive(Clone)]
pub struct ServerContext(Arc<__ServerContextInner>);
pub struct ServerContext(Arc<_ServerContextInner>);
impl std::ops::Deref for ServerContext {
type Target = __ServerContextInner;
type Target = _ServerContextInner;
fn deref(&self) -> &Self::Target {
&self.0
@@ -48,7 +56,7 @@ impl std::ops::Deref for ServerContext {
impl ServerContext {
pub async fn new(db: db::DatabasePool) -> Result<Self, InitError> {
Ok(Self(Arc::new(__ServerContextInner {
Ok(Self(Arc::new(_ServerContextInner {
actors: GlobalActors::spawn(db.clone()).await?,
tls: TlsManager::new(db.clone()).await?,
db,

View File

@@ -1,3 +1,17 @@
use std::string::FromUtf8Error;
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _};
use diesel_async::{AsyncConnection, RunQueryDsl};
use miette::Diagnostic;
use pem::Pem;
use rcgen::{
BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType,
IsCa, Issuer, KeyPair, KeyUsagePurpose,
};
use rustls::pki_types::pem::PemObject;
use thiserror::Error;
use tonic::transport::CertificateDer;
use crate::db::{
self,
models::{NewTlsHistory, TlsHistory},
@@ -7,58 +21,48 @@ use crate::db::{
},
};
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _};
use diesel_async::{AsyncConnection, RunQueryDsl};
use pem::Pem;
use rcgen::{
BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType,
IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
};
use rustls::pki_types::pem::PemObject;
use std::{net::Ipv4Addr, string::FromUtf8Error};
use thiserror::Error;
use tonic::transport::CertificateDer;
const ENCODE_CONFIG: pem::EncodeConfig = {
let line_ending = if cfg!(target_family = "windows") {
pem::LineEnding::CRLF
} else {
pem::LineEnding::LF
let line_ending = match cfg!(target_family = "windows") {
true => pem::LineEnding::CRLF,
false => pem::LineEnding::LF,
};
pem::EncodeConfig::new().set_line_ending(line_ending)
};
#[derive(Error, Debug)]
#[derive(Error, Debug, Diagnostic)]
pub enum InitError {
#[error("Key generation error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_generation))]
KeyGeneration(#[from] rcgen::Error),
#[error("Key invalid format: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_invalid_format))]
KeyInvalidFormat(#[from] FromUtf8Error),
#[error("Key deserialization error: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_deserialization))]
KeyDeserializationError(rcgen::Error),
#[error("Database error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::database_error))]
DatabaseError(#[from] diesel::result::Error),
#[error("Pem deserialization error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::pem_deserialization))]
PemDeserializationError(#[from] rustls::pki_types::pem::Error),
#[error("Database pool acquire error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::database_pool_acquire))]
DatabasePoolAcquire(#[from] db::PoolError),
}
pub type PemCert = String;
pub fn encode_cert_to_pem(cert: &CertificateDer<'_>) -> PemCert {
pub fn encode_cert_to_pem(cert: &CertificateDer) -> PemCert {
pem::encode_config(&Pem::new("CERTIFICATE", cert.to_vec()), ENCODE_CONFIG)
}
#[expect(
unused,
reason = "may be needed for future cert rotation implementation"
)]
#[allow(unused)]
struct SerializedTls {
cert_pem: PemCert,
cert_key_pem: String,
@@ -87,7 +91,7 @@ impl TlsCa {
let cert_key_pem = certified_issuer.key().serialize_pem();
#[expect(
#[allow(
clippy::unwrap_used,
reason = "Broken cert couldn't bootstrap server anyway"
)]
@@ -110,9 +114,6 @@ impl TlsCa {
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
params
.subject_alt_names
.push(SanType::IpAddress(Ipv4Addr::LOCALHOST.into()));
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Arbiter Instance Leaf");
@@ -126,11 +127,7 @@ impl TlsCa {
})
}
#[expect(
unused,
clippy::unnecessary_wraps,
reason = "may be needed for future cert rotation implementation"
)]
#[allow(unused)]
fn serialize(&self) -> Result<SerializedTls, InitError> {
let cert_key_pem = self.issuer.key().serialize_pem();
Ok(SerializedTls {
@@ -139,10 +136,7 @@ impl TlsCa {
})
}
#[expect(
unused,
reason = "may be needed for future cert rotation implementation"
)]
#[allow(unused)]
fn try_deserialize(cert_pem: &str, cert_key_pem: &str) -> Result<Self, InitError> {
let keypair =
KeyPair::from_pem(cert_key_pem).map_err(InitError::KeyDeserializationError)?;
@@ -174,7 +168,8 @@ impl TlsManager {
{
let mut conn = db.get().await?;
conn.transaction(async |conn| {
conn.transaction(|conn| {
Box::pin(async {
let new_tls_history = NewTlsHistory {
cert: new_cert.cert.pem(),
cert_key: new_cert.cert_key.serialize_pem(),
@@ -185,16 +180,17 @@ impl TlsManager {
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table)
.values(&new_tls_history)
.returning(tls_history::id)
.get_result(&mut *conn)
.get_result(conn)
.await?;
diesel::update(arbiter_settings::table)
.set(arbiter_settings::tls_id.eq(inserted_tls_history))
.execute(&mut *conn)
.execute(conn)
.await?;
Result::<_, diesel::result::Error>::Ok(())
})
})
.await?;
}
@@ -241,10 +237,10 @@ impl TlsManager {
}
}
pub const fn cert(&self) -> &CertificateDer<'static> {
pub fn cert(&self) -> &CertificateDer<'static> {
&self.cert
}
pub const fn ca_cert(&self) -> &CertificateDer<'static> {
pub fn ca_cert(&self) -> &CertificateDer<'static> {
&self.ca_cert
}

View File

@@ -1,3 +0,0 @@
pub mod v1;
pub use v1::*;

View File

@@ -1,102 +0,0 @@
use argon2::password_hash::Salt as ArgonSalt;
use rand::{
Rng as _, SeedableRng,
rngs::{StdRng, SysRng},
};
pub const ROOT_KEY_TAG: &[u8] = b"arbiter/seal/v1";
pub const TAG: &[u8] = b"arbiter/private-key/v1";
pub const NONCE_LENGTH: usize = 24;
#[derive(Default)]
pub struct Nonce(pub [u8; NONCE_LENGTH]);
impl Nonce {
pub fn increment(&mut self) {
for i in (0..self.0.len()).rev() {
if let Some(byte) = self.0.get_mut(i) {
if *byte == 0xFF {
*byte = 0;
} else {
*byte += 1;
break;
}
}
}
}
pub fn to_vec(&self) -> Vec<u8> {
self.0.to_vec()
}
}
impl<'a> TryFrom<&'a [u8]> for Nonce {
type Error = ();
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
if value.len() != NONCE_LENGTH {
return Err(());
}
let mut nonce = [0u8; NONCE_LENGTH];
nonce.copy_from_slice(value);
Ok(Self(nonce))
}
}
pub type Salt = [u8; ArgonSalt::RECOMMENDED_LENGTH];
pub fn generate_salt() -> Salt {
let mut salt = Salt::default();
let mut rng =
StdRng::try_from_rng(&mut SysRng).expect("Rng failure is unrecoverable and should panic");
rng.fill_bytes(&mut salt);
salt
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::derive_key;
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
#[test]
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 password2, &salt);
let key1_reader = key1.0.read();
let key2_reader = key2.0.read();
assert_eq!(&*key1_reader, &*key2_reader);
}
#[test]
fn successful_derive() {
static PASSWORD: &[u8] = b"password";
let mut password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_key(&mut password, &salt);
let key_reader = key.0.read();
assert_ne!(key_reader.as_slice(), &[0u8; 32][..]);
}
#[test]
// We should fuzz this
pub fn nonce_increment() {
let mut nonce = Nonce([0u8; NONCE_LENGTH]);
nonce.increment();
assert_eq!(
nonce.0,
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
]
);
}
}

View File

@@ -1,99 +0,0 @@
//! 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)
));
}
}

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