44 Commits

Author SHA1 Message Date
CleverWild
7ab47ec563 feat(vault)!: implement full Shamir re-key flow and governance execution (§3.3–§3.5)
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
- Add `rekey.proto` with `ContributePassphrase` / `ContributeRecoveryPassphrase` / `RekeyResult`
- Wire `rekey` as a 4th vault stream payload in `vault.proto` and gRPC dispatch
- Add `RekeyRootKey` message to `Vault` actor: generates new random seal key, re-encrypts root key, writes new `root_key_history` row
- Add `StartRekey`, `ContributeRekey`, `ContributeRecoveryRekey` messages to `VaultCoordinator`; `finalize_rekey` uses threshold-1 fast path identical to bootstrap
- `execute_replace_operator` now UPDATEs `operator_identity.public_key` in-place (avoids FK constraint violation), deletes stale `operator` share row, then triggers `StartRekey`
- `execute_update_shamir_parameters` triggers `StartRekey` instead of warning stub
- `ProposalKind::ReplaceOperator` carries `old_operator_id`; encode/decode updated accordingly
- `GlobalActors::spawn` extracts `vault_coordinator` before `Ok(Self { … })` so it can be cloned into `ProposalManager::new`
- Add `handle_rekey` in session handlers forwarding passphrase contributions to `VaultCoordinator`
- Fix test: rename `replace_operator_inserts_identity_row` → `replace_operator_updates_pubkey_and_starts_rekey`, assert count stays 1 and pubkey is updated
2026-06-14 15:11:11 +02:00
CleverWild
aff87c13ca refactor(proposal): replace string kind dispatch with ProposalKindTag enum (strum) 2026-06-14 15:02:25 +02:00
CleverWild
9f9b6820c2 feat(vault): add recovery passphrase handling for bootstrap and unseal processes
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-13 23:09:49 +02:00
CleverWild
6017ef29ca fix(crypto): handle 1-of-N Shamir split when ordinary_count=1 2026-06-13 23:08:53 +02:00
CleverWild
eb16da3a20 feat(server): recovery operators with sleeping/wakeup mechanism (§3.5/§3.6)
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-13 22:31:10 +02:00
CleverWild
2fda0484fc feat(server): key-rotation proposals require full quorum (§3.3)
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-13 22:20:48 +02:00
CleverWild
f8c621b20e feat(server): two-operator vault requires at least one recovery share
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-13 22:13:07 +02:00
CleverWild
3b090cd3ce refactor(server): typed pubkey len via u32::try_from in ReplaceOperator
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-13 21:53:46 +02:00
CleverWild
99e2b841e9 feat(server): ProposalKind::ApproveOneOffTransaction 2026-06-13 21:32:23 +02:00
CleverWild
b2b159b16f feat(server): ProposalKind::ApprovePersistentGrant 2026-06-13 21:27:41 +02:00
CleverWild
ab767fe158 feat(server): ProposalKind::UpdateShamirParameters 2026-06-13 21:20:06 +02:00
CleverWild
f080a8615f feat(server): ProposalKind::ReplaceOperator 2026-06-13 16:46:04 +02:00
CleverWild
514a4cb2d1 feat(server): ProposalKind ::GrantWalletAccess and ::ApproveServerUpdate
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-13 15:51:22 +02:00
CleverWild
0b331d90bf test(server): governance integration tests
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-13 15:23:10 +02:00
CleverWild
f981ddeb79 feat(server::grpc): wire governance RPCs through operator session 2026-06-13 15:23:10 +02:00
CleverWild
8517b981f2 feat(server): introduce ProposalManager actor with quorum voting logic 2026-06-13 15:11:00 +02:00
CleverWild
af13465c03 feat(crypto): expose governance signing context and make shamir_threshold pub const 2026-06-13 15:11:00 +02:00
CleverWild
d7950beb09 feat(db): add proposal and proposal_vote tables 2026-06-13 15:11:00 +02:00
CleverWild
0cb0de759b feat(proto): add governance proposal/vote RPC definitions 2026-06-13 15:11:00 +02:00
CleverWild
6f270ef0c4 housekepping: add fixme for start_bootstrap's operator_id
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-12 22:01:17 +02:00
CleverWild
0098c3c08a refactor(server::crypto): use fixed-size [u8; 32] and KeyCell throughout seal key API
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-06-12 21:15:07 +02:00
CleverWild
a3b98ca024 fix(server::tests): tighten unseal test seal_key params to &[u8; 32] 2026-06-12 21:11:48 +02:00
CleverWild
0d364d1951 feat(server::grpc): wire Shamir committee bootstrap and unseal proto messages
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
Adds DeclareCommittee and ContributePassphrase variants to bootstrap.proto,
ContributePassphrase to unseal.proto, and AwaitingContributions result codes
to both. Implements corresponding inbound converters and outbound reply
mappings. VaultGate handlers delegate to VaultCoordinator.
2026-06-12 19:43:17 +02:00
CleverWild
6f65c907a3 feat(server): introduce VaultCoordinator for multi-operator Shamir bootstrap/unseal
VaultCoordinator collects operator passphrases, splits the seal key into
Shamir shares on bootstrap (encrypting each share with the operator's
passphrase via Argon2 + XChaCha20-Poly1305), and reconstructs the seal
key from threshold shares on unseal. Adds vsss-rs 5.4.0 and rand_core 0.6
dependencies.
2026-06-12 19:43:09 +02:00
CleverWild
9764b0d5ce refactor(server::actors::vault): clean up Bootstrap/TryUnseal, remove Bootstrapping state
Bootstrap and TryUnseal now accept a SafeCell<Vec<u8>> seal key directly.
The Bootstrapping intermediate state is removed — multi-operator coordination
is the responsibility of VaultCoordinator, which calls Bootstrap atomically
once all shares are collected.
2026-06-12 19:43:02 +02:00
CleverWild
50fe18d6ce feat(server::crypto): add Shamir secret sharing utilities
Wraps vsss_rs Gf256::split_array / combine_array into thin split_key /
combine_shares helpers. Also widens derive_key salt parameter from &[u8;16]
to &[u8] to accommodate the 32-byte share salts.
2026-06-12 19:42:56 +02:00
CleverWild
3e5f0cb3df feat(server::db): add share_salt column to operator table
Each operator row now stores a 32-byte random salt used to derive the
per-operator share encryption key from their passphrase (Argon2 KDF).
2026-06-12 19:42:49 +02:00
CleverWild
34850137df feat(server::actors::evm): implement operator_delete_grant
Sets revoked_at on the evm_basic_grant row; returns NotFound if the grant
does not exist. Wires the handler in OperatorSession replacing the todo!().
2026-06-12 19:42:43 +02:00
CleverWild
d1b96c8409 fix(server::peers::operator::auth): make ChallengeContext pub for smlang state machine
smlang generates a public state enum whose variants contain ChallengeContext,
requiring the type itself to be fully public. Also tightens the wildcard arm
in client auth to an exhaustive match.
2026-06-12 19:42:37 +02:00
Skipper
9dbb18ae82 WIP: some things
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-05-20 21:04:16 +02:00
Skipper
a773255935 refactor(server::db): introduced newtype wrappers for entity id's in database 2026-05-04 19:35:27 +02:00
Skipper
3f801abdff housekeeping(server): deps upgrade + diesel migration to AsyncFnOnce
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-05-01 11:22:40 +02:00
Skipper
2b44570ab4 fix(server): MacOS build version
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-19 13:47:47 +02:00
Skipper
1f9b253433 housekeeping(server): removed unused deps 2026-04-19 13:46:49 +02:00
Skipper
a1c3ffd2d1 refactor: rename to to better reflect meaning
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-19 13:41:50 +02:00
Skipper
fd25de32a1 docs: move to folder and update to new challenge payload 2026-04-18 15:17:18 +02:00
Skipper
9ab074170b merge: feat-lints into main
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
ci/woodpecker/push/useragent-analyze Pipeline failed
2026-04-18 15:04:33 +02:00
18b8a3bbf5 Merge pull request 'refactor-integrity-check' (#90) from refactor-integrity-check into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
ci/woodpecker/push/useragent-analyze Pipeline failed
Reviewed-on: #90
2026-04-18 11:54:30 +00:00
Skipper
38cf1b98b9 housekeeping(server): clippy warns fix
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-18 13:53:11 +02:00
Skipper
9cf87b2058 merge: refactor-integrity-check into main
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-18 13:46:28 +02:00
28f84d03ab Merge pull request 'housekeeping(server): dependencies upgrade' (#89) from push-zmvtzuwrnyyv into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline was successful
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #89
2026-04-17 19:20:50 +00:00
Skipper
e88df432fb housekeeping(server): dependencies upgrade
Some checks failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-04-14 19:10:07 +02:00
CleverWild
41b3fc5d39 fix(lints): remove unstable ones
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-10 01:00:21 +02:00
CleverWild
f6a0c32b9d feat: rustc and clippy linting
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-10 00:42:43 +02:00
121 changed files with 6456 additions and 2256 deletions

2
.gitignore vendored
View File

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

View File

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

129
CLAUDE.md
View File

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

View File

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

View File

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

View File

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

View File

@@ -111,7 +111,7 @@ String shortAddress(List<int> bytes) {
- [ ] **Step 2: Verify**
```sh
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/utils.dart
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/utils.dart
```
Expected: no errors.
@@ -168,7 +168,7 @@ class GrantCreation extends _$GrantCreation {
- [ ] **Step 2: Run code generator**
```sh
cd useragent && dart run build_runner build --delete-conflicting-outputs
cd operator && dart run build_runner build --delete-conflicting-outputs
```
Expected: generates `provider.freezed.dart` and `provider.g.dart`, no errors.
@@ -176,7 +176,7 @@ Expected: generates `provider.freezed.dart` and `provider.g.dart`, no errors.
- [ ] **Step 3: Verify**
```sh
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/provider.dart
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/provider.dart
```
Expected: no errors.
@@ -204,7 +204,7 @@ jj describe -m "feat(grants): add GrantCreation provider (client selection + gra
```dart
// lib/screens/dashboard/evm/grants/create/fields/client_picker_field.dart
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/proto/operator.pb.dart';
import 'package:arbiter/providers/sdk_clients/list.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart';
import 'package:flutter/material.dart';
@@ -246,7 +246,7 @@ class ClientPickerField extends ConsumerWidget {
```dart
// lib/screens/dashboard/evm/grants/create/fields/wallet_access_picker_field.dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/proto/operator.pb.dart';
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
import 'package:arbiter/screens/dashboard/evm/grants/create/provider.dart';
@@ -522,7 +522,7 @@ class TransactionRateLimitField extends StatelessWidget {
- [ ] **Step 8: Verify all field widgets**
```sh
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/fields/
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/fields/
```
Expected: no errors.
@@ -585,7 +585,7 @@ class SharedGrantFields extends StatelessWidget {
- [ ] **Step 2: Verify**
```sh
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/shared_grant_fields.dart
```
Expected: no errors.
@@ -978,7 +978,7 @@ class _TokenVolumeLimitRow extends HookWidget {
- [ ] **Step 4: Run code generator for token_transfer_grant.g.dart**
```sh
cd useragent && dart run build_runner build --delete-conflicting-outputs
cd operator && dart run build_runner build --delete-conflicting-outputs
```
Expected: generates `token_transfer_grant.g.dart`, no errors.
@@ -986,7 +986,7 @@ Expected: generates `token_transfer_grant.g.dart`, no errors.
- [ ] **Step 5: Verify**
```sh
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/grants/
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/grants/
```
Expected: no errors.
@@ -1265,7 +1265,7 @@ String _formatError(Object error) {
- [ ] **Step 2: Verify the full create/ directory**
```sh
cd useragent && dart analyze lib/screens/dashboard/evm/grants/create/
cd operator && dart analyze lib/screens/dashboard/evm/grants/create/
```
Expected: no errors.

View File

@@ -14,24 +14,24 @@
| File | Action | Responsibility |
|---|---|---|
| `useragent/lib/theme/palette.dart` | Modify | Add `Palette.token` (indigo accent for token-transfer cards) |
| `useragent/lib/features/connection/evm/wallet_access.dart` | Modify | Add `listAllWalletAccesses()` function |
| `useragent/lib/providers/sdk_clients/wallet_access_list.dart` | Create | `WalletAccessListProvider` — fetches full wallet access list with IDs |
| `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart` | Create | `GrantCard` widget — watches enrichment providers + revoke mutation; one card per grant |
| `useragent/lib/screens/dashboard/evm/grants/grants.dart` | Create | `EvmGrantsScreen` — watches `evmGrantsProvider`; handles loading/error/empty/data states; renders `GrantCard` list |
| `useragent/lib/router.dart` | Modify | Register `EvmGrantsRoute` in dashboard children |
| `useragent/lib/screens/dashboard.dart` | Modify | Add Grants entry to `routes` list and `NavigationDestination` list |
| `operator/lib/theme/palette.dart` | Modify | Add `Palette.token` (indigo accent for token-transfer cards) |
| `operator/lib/features/connection/evm/wallet_access.dart` | Modify | Add `listAllWalletAccesses()` function |
| `operator/lib/providers/sdk_clients/wallet_access_list.dart` | Create | `WalletAccessListProvider` — fetches full wallet access list with IDs |
| `operator/lib/screens/dashboard/evm/grants/widgets/grant_card.dart` | Create | `GrantCard` widget — watches enrichment providers + revoke mutation; one card per grant |
| `operator/lib/screens/dashboard/evm/grants/grants.dart` | Create | `EvmGrantsScreen` — watches `evmGrantsProvider`; handles loading/error/empty/data states; renders `GrantCard` list |
| `operator/lib/router.dart` | Modify | Register `EvmGrantsRoute` in dashboard children |
| `operator/lib/screens/dashboard.dart` | Modify | Add Grants entry to `routes` list and `NavigationDestination` list |
---
## Task 1: Add `Palette.token`
**Files:**
- Modify: `useragent/lib/theme/palette.dart`
- Modify: `operator/lib/theme/palette.dart`
- [ ] **Step 1: Add the color**
Replace the contents of `useragent/lib/theme/palette.dart` with:
Replace the contents of `operator/lib/theme/palette.dart` with:
```dart
import 'package:flutter/material.dart';
@@ -48,7 +48,7 @@ class Palette {
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/theme/palette.dart
cd operator && flutter analyze lib/theme/palette.dart
```
Expected: no issues.
@@ -65,20 +65,20 @@ jj new
## Task 2: Add `listAllWalletAccesses` feature function
**Files:**
- Modify: `useragent/lib/features/connection/evm/wallet_access.dart`
- Modify: `operator/lib/features/connection/evm/wallet_access.dart`
`readClientWalletAccess` (existing) filters the list to one client's wallet IDs and returns `Set<int>`. This new function returns the complete unfiltered list with row IDs so the grant cards can resolve wallet_access_id → wallet + client.
- [ ] **Step 1: Append function**
Add at the bottom of `useragent/lib/features/connection/evm/wallet_access.dart`:
Add at the bottom of `operator/lib/features/connection/evm/wallet_access.dart`:
```dart
Future<List<SdkClientWalletAccess>> listAllWalletAccesses(
Connection connection,
) async {
final response = await connection.ask(
UserAgentRequest(listWalletAccess: Empty()),
OperatorRequest(listWalletAccess: Empty()),
);
if (!response.hasListWalletAccessResponse()) {
throw Exception(
@@ -97,7 +97,7 @@ Each returned `SdkClientWalletAccess` has:
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/features/connection/evm/wallet_access.dart
cd operator && flutter analyze lib/features/connection/evm/wallet_access.dart
```
Expected: no issues.
@@ -114,18 +114,18 @@ jj new
## Task 3: Create `WalletAccessListProvider`
**Files:**
- Create: `useragent/lib/providers/sdk_clients/wallet_access_list.dart`
- Generated: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart`
- Create: `operator/lib/providers/sdk_clients/wallet_access_list.dart`
- Generated: `operator/lib/providers/sdk_clients/wallet_access_list.g.dart`
Mirrors the structure of `EvmGrants` in `providers/evm/evm_grants.dart` — class-based `@riverpod` with a `refresh()` method.
- [ ] **Step 1: Write the provider**
Create `useragent/lib/providers/sdk_clients/wallet_access_list.dart`:
Create `operator/lib/providers/sdk_clients/wallet_access_list.dart`:
```dart
import 'package:arbiter/features/connection/evm/wallet_access.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/proto/operator.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:mtcore/markettakers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -165,15 +165,15 @@ class WalletAccessList extends _$WalletAccessList {
- [ ] **Step 2: Run code generation**
```sh
cd useragent && dart run build_runner build --delete-conflicting-outputs
cd operator && dart run build_runner build --delete-conflicting-outputs
```
Expected: `useragent/lib/providers/sdk_clients/wallet_access_list.g.dart` created. No errors.
Expected: `operator/lib/providers/sdk_clients/wallet_access_list.g.dart` created. No errors.
- [ ] **Step 3: Verify**
```sh
cd useragent && flutter analyze lib/providers/sdk_clients/
cd operator && flutter analyze lib/providers/sdk_clients/
```
Expected: no issues.
@@ -190,26 +190,26 @@ jj new
## Task 4: Create `GrantCard` widget
**Files:**
- Create: `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`
- Create: `operator/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`
This widget owns all per-card logic: enrichment lookups, revoke action, and rebuild scope. The screen only passes it a `GrantEntry` — the card fetches everything else itself.
**Key types:**
- `GrantEntry` (from `proto/evm.pb.dart`): `.id`, `.shared.walletAccessId`, `.shared.chainId`, `.specific.whichGrant()`
- `SpecificGrant_Grant.etherTransfer` / `.tokenTransfer` — enum values for the oneof
- `SdkClientWalletAccess` (from `proto/user_agent.pb.dart`): `.id`, `.access.walletId`, `.access.sdkClientId`
- `SdkClientWalletAccess` (from `proto/operator.pb.dart`): `.id`, `.access.walletId`, `.access.sdkClientId`
- `WalletEntry` (from `proto/evm.pb.dart`): `.id`, `.address` (List<int>)
- `SdkClientEntry` (from `proto/user_agent.pb.dart`): `.id`, `.info.name`
- `SdkClientEntry` (from `proto/operator.pb.dart`): `.id`, `.info.name`
- `revokeEvmGrantMutation``Mutation<void>` (global; all revoke buttons disable together while any revoke is in flight)
- `executeRevokeEvmGrant(ref, grantId: int)``Future<void>`
- [ ] **Step 1: Write the widget**
Create `useragent/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`:
Create `operator/lib/screens/dashboard/evm/grants/widgets/grant_card.dart`:
```dart
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:arbiter/proto/operator.pb.dart';
import 'package:arbiter/providers/evm/evm.dart';
import 'package:arbiter/providers/evm/evm_grants.dart';
import 'package:arbiter/providers/sdk_clients/list.dart';
@@ -438,7 +438,7 @@ class GrantCard extends ConsumerWidget {
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/screens/dashboard/evm/grants/widgets/grant_card.dart
cd operator && flutter analyze lib/screens/dashboard/evm/grants/widgets/grant_card.dart
```
Expected: no issues.
@@ -455,13 +455,13 @@ jj new
## Task 5: Create `EvmGrantsScreen`
**Files:**
- Create: `useragent/lib/screens/dashboard/evm/grants/grants.dart`
- Create: `operator/lib/screens/dashboard/evm/grants/grants.dart`
The screen watches only `evmGrantsProvider` for top-level state (loading / error / no connection / empty / data). When there is data it renders a list of `GrantCard` widgets — each card manages its own enrichment subscriptions.
- [ ] **Step 1: Write the screen**
Create `useragent/lib/screens/dashboard/evm/grants/grants.dart`:
Create `operator/lib/screens/dashboard/evm/grants/grants.dart`:
```dart
import 'package:arbiter/proto/evm.pb.dart';
@@ -702,7 +702,7 @@ class EvmGrantsScreen extends ConsumerWidget {
- [ ] **Step 2: Verify**
```sh
cd useragent && flutter analyze lib/screens/dashboard/evm/grants/
cd operator && flutter analyze lib/screens/dashboard/evm/grants/
```
Expected: no issues.
@@ -719,13 +719,13 @@ jj new
## Task 6: Wire router and dashboard tab
**Files:**
- Modify: `useragent/lib/router.dart`
- Modify: `useragent/lib/screens/dashboard.dart`
- Regenerated: `useragent/lib/router.gr.dart`
- Modify: `operator/lib/router.dart`
- Modify: `operator/lib/screens/dashboard.dart`
- Regenerated: `operator/lib/router.gr.dart`
- [ ] **Step 1: Add route to `router.dart`**
Replace the contents of `useragent/lib/router.dart` with:
Replace the contents of `operator/lib/router.dart` with:
```dart
import 'package:auto_route/auto_route.dart';
@@ -759,7 +759,7 @@ class Router extends RootStackRouter {
- [ ] **Step 2: Update `dashboard.dart`**
In `useragent/lib/screens/dashboard.dart`, replace the `routes` constant:
In `operator/lib/screens/dashboard.dart`, replace the `routes` constant:
```dart
final routes = [
@@ -800,7 +800,7 @@ destinations: const [
- [ ] **Step 3: Regenerate router**
```sh
cd useragent && dart run build_runner build --delete-conflicting-outputs
cd operator && dart run build_runner build --delete-conflicting-outputs
```
Expected: `lib/router.gr.dart` updated, `EvmGrantsRoute` now available, no errors.
@@ -808,7 +808,7 @@ Expected: `lib/router.gr.dart` updated, `EvmGrantsRoute` now available, no error
- [ ] **Step 4: Full project verify**
```sh
cd useragent && flutter analyze
cd operator && flutter analyze
```
Expected: no issues.

View File

@@ -4,7 +4,7 @@
## Overview
Add a "Grants" dashboard tab to the Flutter user-agent app that displays all EVM grants as a card-based grid. Each card shows a compact summary (type, chain, wallet address, client name) with a revoke action. The tab integrates into the existing `AdaptiveScaffold` navigation alongside Wallets, Clients, and About.
Add a "Grants" dashboard tab to the Flutter operator app that displays all EVM grants as a card-based grid. Each card shows a compact summary (type, chain, wallet address, client name) with a revoke action. The tab integrates into the existing `AdaptiveScaffold` navigation alongside Wallets, Clients, and About.
## Scope
@@ -23,7 +23,7 @@ Add a "Grants" dashboard tab to the Flutter user-agent app that displays all EVM
### `walletAccessListProvider`
**File:** `useragent/lib/providers/sdk_clients/wallet_access_list.dart`
**File:** `operator/lib/providers/sdk_clients/wallet_access_list.dart`
- `@riverpod` class, watches `connectionManagerProvider.future`
- Returns `List<SdkClientWalletAccess>?` (null when not connected)
@@ -85,7 +85,7 @@ NavigationDestination(
## Screen: `EvmGrantsScreen`
**File:** `useragent/lib/screens/dashboard/evm/grants/grants.dart`
**File:** `operator/lib/screens/dashboard/evm/grants/grants.dart`
```
Scaffold

View File

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

View File

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

View File

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

View File

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

View File

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

31
protobufs/operator.proto Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

864
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,48 +4,166 @@ members = [
]
resolver = "3"
[workspace.lints.clippy]
disallowed-methods = "deny"
[workspace.dependencies]
tonic = { version = "0.14.5", features = [
"deflate",
"gzip",
"tls-connect-info",
"zstd",
] }
tracing = "0.1.44"
tokio = { version = "1.50.0", features = ["full"] }
ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] }
alloy = "2.0.4"
async-trait = "0.1.89"
base64 = "0.22.1"
chrono = { version = "0.4.44", features = ["serde"] }
rand = "0.10.0"
rustls = { version = "0.23.37", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
futures = "0.3.32"
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "3e18ba2"}
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "3e18ba2"}
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"
thiserror = "2.0.18"
async-trait = "0.1.89"
futures = "0.3.32"
tokio = { version = "1.52.1", features = ["full"] }
tokio-stream = { version = "0.1.18", features = ["full"] }
prost-types = { version = "0.14.3", features = ["chrono"] }
tonic = { version = "0.14.5", features = [ "deflate", "gzip", "tls-connect-info", "zstd" ] }
tracing = "0.1.44"
x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
rstest = "0.26.1"
rustls-pki-types = "1.14.0"
alloy = "1.7.3"
rcgen = { version = "0.14.7", features = [
"aws_lc_rs",
"pem",
"x509-parser",
"zeroize",
], default-features = false }
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
rsa = { version = "0.9", features = ["sha2"] }
sha2 = "0.10"
spki = "0.7"
prost = "0.14.3"
miette = { version = "7.6.0", features = ["fancy", "serde"] }
mutants = "0.0.4"
ml-dsa = { version = "0.1.0-rc.8", features = ["zeroize"] }
base64 = "0.22.1"
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
hmac = "0.12.1"
[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

View File

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

View File

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

View File

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

View File

@@ -29,16 +29,16 @@ async fn main() {
}
};
println!("{:#?}", url);
println!("{url:#?}");
let metadata = ClientMetadata {
name: "arbiter-client test_connect".to_string(),
description: Some("Manual connection smoke test".to_string()),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
name: "arbiter-client test_connect".to_owned(),
description: Some("Manual connection smoke test".to_owned()),
version: Some(env!("CARGO_PKG_VERSION").to_owned()),
};
match ArbiterClient::connect(url, metadata).await {
Ok(_) => println!("Connected and authenticated successfully."),
Err(err) => eprintln!("Failed to connect: {:#?}", err),
Err(err) => eprintln!("Failed to connect: {err:#?}"),
}
}

View File

@@ -17,33 +17,39 @@ use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::ClientTlsConfig;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("gRPC error")]
Grpc(#[from] tonic::Status),
pub enum ArbiterClientError {
#[error("Authentication error")]
Authentication(#[from] AuthError),
#[error("Could not establish connection")]
Connection(#[from] tonic::transport::Error),
#[error("Invalid server URI")]
InvalidUri(#[from] http::uri::InvalidUri),
#[error("gRPC error")]
Grpc(#[from] tonic::Status),
#[error("Invalid CA certificate")]
InvalidCaCert(#[from] webpki::Error),
#[error("Authentication error")]
Authentication(#[from] AuthError),
#[error("Invalid server URI")]
InvalidUri(#[from] http::uri::InvalidUri),
#[error("Storage error")]
Storage(#[from] StorageError),
}
pub struct ArbiterClient {
#[allow(dead_code)]
#[expect(
dead_code,
reason = "transport will be used in future methods for sending requests and receiving responses"
)]
transport: Arc<Mutex<ClientTransport>>,
}
impl ArbiterClient {
pub async fn connect(url: ArbiterUrl, metadata: ClientMetadata) -> Result<Self, Error> {
pub async fn connect(
url: ArbiterUrl,
metadata: ClientMetadata,
) -> Result<Self, ArbiterClientError> {
let storage = FileSigningKeyStorage::from_default_location()?;
Self::connect_with_storage(url, metadata, &storage).await
}
@@ -52,7 +58,7 @@ impl ArbiterClient {
url: ArbiterUrl,
metadata: ClientMetadata,
storage: &S,
) -> Result<Self, Error> {
) -> Result<Self, ArbiterClientError> {
let key = storage.load_or_create()?;
Self::connect_with_key(url, metadata, key).await
}
@@ -61,7 +67,7 @@ impl ArbiterClient {
url: ArbiterUrl,
metadata: ClientMetadata,
key: SigningKey,
) -> Result<Self, Error> {
) -> Result<Self, ArbiterClientError> {
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
let tls = ClientTlsConfig::new().trust_anchor(anchor);
@@ -88,7 +94,8 @@ impl ArbiterClient {
}
#[cfg(feature = "evm")]
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, Error> {
#[expect(clippy::unused_async, reason = "false positive")]
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, ArbiterClientError> {
todo!("fetch EVM wallet list from server")
}
}

View File

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

View File

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

View File

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

View File

@@ -58,7 +58,11 @@ pub struct ArbiterEvmWallet {
}
impl ArbiterEvmWallet {
pub(crate) fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
#[expect(
dead_code,
reason = "new will be used in future methods for creating wallets with different parameters"
)]
pub(crate) const fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
Self {
transport,
address,
@@ -66,11 +70,12 @@ impl ArbiterEvmWallet {
}
}
pub fn address(&self) -> Address {
pub const fn address(&self) -> Address {
self.address
}
pub fn with_chain_id(mut self, chain_id: ChainId) -> Self {
#[must_use]
pub const fn with_chain_id(mut self, chain_id: ChainId) -> Self {
self.chain_id = Some(chain_id);
self
}
@@ -145,6 +150,7 @@ impl TxSigner<Signature> for ArbiterEvmWallet {
.recv()
.await
.map_err(|_| Error::other("failed to receive evm sign transaction response"))?;
drop(transport);
if response.request_id != Some(request_id) {
return Err(Error::other(

View File

@@ -11,6 +11,7 @@ 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
@@ -19,3 +20,6 @@ workspace = true
default = ["authn", "safecell"]
authn = ["dep:ml-dsa", "dep:rand"]
safecell = ["dep:memsafe"]
[lib]
doctest = false

View File

@@ -7,10 +7,18 @@ use ml_dsa::{
use rand::RngExt;
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
pub static USERAGENT_CONTEXT: &[u8] = b"arbiter_user_agent";
pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator";
pub static GOVERNANCE_CONTEXT: &[u8] = b"arbiter_governance_vote";
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],
@@ -43,9 +51,12 @@ impl AuthChallenge {
}
}
pub fn from_parts(nonce: &[u8], timestamp: i64) -> Result<Self, ()> {
let random_nonce = nonce.as_array().ok_or(())?;
Ok(AuthChallenge {
pub fn from_parts(nonce: &[u8], timestamp: i64) -> Result<Self, InvalidLength> {
let random_nonce = nonce.as_array().ok_or(InvalidLength {
expected: NONCE_SIZE,
actual: nonce.len(),
})?;
Ok(Self {
nonce: *random_nonce,
timestamp: DateTime::from_timestamp_nanos(timestamp),
})
@@ -74,11 +85,17 @@ impl PublicKey {
self.0.encode().0.to_vec()
}
#[must_use]
pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool {
let challenge = challenge.format();
self.0
.verify_with_context(&challenge, context, &signature.0)
}
#[must_use]
pub fn verify_message(&self, message: &[u8], context: &[u8], signature: &Signature) -> bool {
self.0.verify_with_context(message, context, &signature.0)
}
}
impl Signature {
@@ -181,7 +198,7 @@ mod tests {
use crate::authn::AuthChallenge;
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, USERAGENT_CONTEXT};
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, OPERATOR_CONTEXT};
#[test]
fn public_key_round_trip_decodes() {
@@ -216,7 +233,7 @@ mod tests {
.expect("signature should be created");
assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature));
assert!(!public_key.verify(&challenge, USERAGENT_CONTEXT, &signature));
assert!(!public_key.verify(&challenge, OPERATOR_CONTEXT, &signature));
}
#[test]

View File

@@ -50,7 +50,7 @@ impl<T: Hashable + PartialOrd> Hashable for Vec<T> {
}
}
impl<T: Hashable + PartialOrd> Hashable for HashSet<T> {
impl<T: Hashable + PartialOrd, S: std::hash::BuildHasher> Hashable for HashSet<T, S> {
fn hash<H: Digest>(&self, hasher: &mut H) {
let ref_sorted = {
let mut sorted = self.iter().collect::<Vec<_>>();

View File

@@ -22,7 +22,7 @@ pub trait SafeCellHandle<T> {
fn read(&mut self) -> Self::CellRead<'_>;
fn write(&mut self) -> Self::CellWrite<'_>;
fn new_inline<F>(f: F) -> Self
fn new_inline_default<F>(f: F) -> Self
where
Self: Sized,
T: Default,
@@ -31,11 +31,19 @@ pub trait SafeCellHandle<T> {
let mut cell = Self::new(T::default());
{
let mut handle = cell.write();
f(handle.deref_mut());
f(&mut *handle);
}
cell
}
fn new_inline<F>(f: Box<F>) -> Self
where
Self: Sized,
F: for<'a> FnOnce() -> T,
{
Self::new(f())
}
#[inline(always)]
fn read_inline<F, R>(&mut self, f: F) -> R
where

View File

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

View File

@@ -18,7 +18,7 @@ pub(crate) fn derive(input: &DeriveInput) -> TokenStream {
}
}
fn hashable_struct(input: &DeriveInput, struct_data: &syn::DataStruct) -> TokenStream {
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();

View File

@@ -1,4 +1,4 @@
pub(crate) struct ToPath(pub(crate) &'static str);
pub(crate) struct ToPath(pub &'static str);
impl ToPath {
pub(crate) fn to_path(&self) -> syn::Path {
@@ -7,13 +7,18 @@ impl ToPath {
}
macro_rules! ensure_path {
($path:path) => {{
#[cfg(test)]
#[expect(unused_imports)]
use $path as _;
ToPath(stringify!($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));
};
}
pub(crate) const HASHABLE_TRAIT_PATH: ToPath = ensure_path!(::arbiter_crypto::hashing::Hashable);
pub(crate) const HMAC_DIGEST_PATH: ToPath = ensure_path!(::arbiter_crypto::hashing::Digest);
ensure_path!(::arbiter_crypto::hashing::Hashable as HASHABLE_TRAIT_PATH);
ensure_path!(::arbiter_crypto::hashing::Digest as HMAC_DIGEST_PATH);

View File

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

View File

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

View File

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

View File

@@ -104,7 +104,7 @@ mod tests {
#[rstest]
fn test_parsing_correctness(
fn parsing_correctness(
#[values("127.0.0.1", "localhost", "192.168.1.1", "some.domain.com")] host: &str,
#[values(None, Some("token123".to_string()))] bootstrap_token: Option<String>,

View File

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

View File

@@ -43,13 +43,25 @@ create table if not exists arbiter_settings (
insert into arbiter_settings (id) values (1) on conflict do nothing;
-- ensure singleton row exists
create table if not exists useragent_client (
create table if not exists operator_identity (
id integer not null primary key,
public_key blob not null,
created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now'))
) STRICT;
create unique index if not exists uniq_useragent_client_public_key on useragent_client (public_key);
create unique index if not exists uniq_operator_identity_public_key on operator_identity (public_key);
create table if not exists operator (
id integer primary key references operator_identity(id) on delete restrict, -- same id as operator_identity
share blob not null,
share_nonce blob not null,
share_salt blob not null default (randomblob(32)),
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,
@@ -204,3 +216,73 @@ create table if not exists integrity_envelope (
) STRICT;
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);
create table if not exists proposal (
id integer not null primary key,
kind text not null,
payload blob not null,
initiator_id integer not null references operator_identity(id) on delete restrict,
created_at integer not null default(unixepoch('now')),
expires_at integer not null,
status text not null default 'pending'
check (status in ('pending', 'approved', 'rejected', 'expired'))
) STRICT;
create table if not exists proposal_vote (
id integer not null primary key,
proposal_id integer not null references proposal(id) on delete cascade,
operator_id integer not null references operator_identity(id) on delete restrict,
approve integer not null check (approve in (0, 1)),
signature blob not null,
voted_at integer not null default(unixepoch('now')),
unique (proposal_id, operator_id)
) STRICT;
create table if not exists proposal_result (
proposal_id integer not null primary key references proposal(id) on delete cascade,
data blob not null,
created_at integer not null default(unixepoch('now'))
) STRICT;
-- ===============================
-- Recovery Operators (§3.4/§3.5/§3.6)
-- ===============================
-- Encrypted Shamir shares for recovery operators (mirrors the `operator` table).
create table if not exists recovery_operator (
id integer not null primary key references recovery_operator_identity(id) on delete restrict,
share blob not null,
share_nonce blob not null,
share_salt blob not null,
created_at integer not null default(unixepoch('now')),
updated_at integer not null default(unixepoch('now'))
) STRICT;
create table if not exists recovery_operator_identity (
id integer not null primary key,
public_key blob not null unique,
created_at integer not null default(unixepoch('now')),
updated_at integer not null default(unixepoch('now'))
) STRICT;
-- One active wakeup request at a time. A request is pending when cancelled_at IS NULL
-- and requested_at + 14 days > now. It becomes active (recovery live) after 14 days.
create table if not exists recovery_wakeup_request (
id integer not null primary key,
requested_by integer not null references operator_identity(id) on delete restrict,
requested_at integer not null default(unixepoch('now')),
cancelled_by integer references operator_identity(id) on delete restrict,
cancelled_at integer
) STRICT;
-- Votes cast by recovery operators; only allowed on replace_operator proposals.
create table if not exists recovery_proposal_vote (
id integer not null primary key,
proposal_id integer not null references proposal(id) on delete cascade,
recovery_operator_id integer not null references recovery_operator_identity(id) on delete restrict,
approve integer not null check (approve in (0, 1)),
signature blob not null,
voted_at integer not null default(unixepoch('now')),
unique (proposal_id, recovery_operator_id)
) STRICT;

View File

@@ -13,8 +13,8 @@ const TOKEN_LENGTH: usize = 64;
pub async fn generate_token() -> Result<String, std::io::Error> {
let rng: StdRng = make_rng();
let token: String = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold(
Default::default(),
let token = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold(
String::default(),
|mut accum, char| {
accum += char.to_string().as_str();
accum
@@ -31,11 +31,11 @@ pub enum Error {
#[error("Database error: {0}")]
Database(#[from] db::PoolError),
#[error("Database query error: {0}")]
Query(#[from] diesel::result::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Database query error: {0}")]
Query(#[from] diesel::result::Error),
}
#[derive(Actor)]
@@ -48,7 +48,7 @@ impl Bootstrapper {
let row_count: i64 = {
let mut conn = db.get().await?;
schema::useragent_client::table
schema::operator::table
.count()
.get_result(&mut conn)
.await?
@@ -69,16 +69,13 @@ impl Bootstrapper {
impl Bootstrapper {
#[message]
pub fn is_correct_token(&self, token: String) -> bool {
match &self.token {
Some(expected) => {
let expected_bytes = expected.as_bytes();
let token_bytes = token.as_bytes();
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)
}
None => false,
}
let choice = expected_bytes.ct_eq(token_bytes);
bool::from(choice)
})
}
#[message]

View File

@@ -3,7 +3,7 @@ use crate::{
crypto::integrity,
db::{
DatabaseError, DatabasePool,
models::{self},
models::{self, EvmWalletId},
schema,
},
evm::{
@@ -16,7 +16,9 @@ use crate::{
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use alloy::{
consensus::TxEip1559, network::TxSignerSync as _, primitives::Address, signers::Signature,
};
use diesel::{
ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into,
};
@@ -114,7 +116,7 @@ impl EvmActor {
}
#[message]
pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
pub async fn list_wallets(&self) -> Result<Vec<(EvmWalletId, Address)>, Error> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
.select(models::EvmWallet::as_select())
@@ -132,7 +134,7 @@ impl EvmActor {
#[messages]
impl EvmActor {
#[message]
pub async fn useragent_create_grant(
pub async fn operator_create_grant(
&mut self,
basic: SharedGrantSettings,
grant: SpecificGrant,
@@ -158,32 +160,27 @@ impl EvmActor {
}
#[message]
pub async fn useragent_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> {
// let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
// let vault = self.vault.clone();
pub async fn operator_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
// diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
// Box::pin(async move {
// diesel::update(schema::evm_basic_grant::table)
// .filter(schema::evm_basic_grant::id.eq(grant_id))
// .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
// .execute(conn)
// .await?;
let affected = diesel::update(schema::evm_basic_grant::table)
.filter(schema::evm_basic_grant::id.eq(grant_id))
.set(schema::evm_basic_grant::revoked_at.eq(models::SqliteTimestamp::now()))
.execute(&mut conn)
.await
.map_err(DatabaseError::from)?;
// let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?;
if affected == 0 {
return Err(Error::Database(DatabaseError::from(
diesel::result::Error::NotFound,
)));
}
// diesel::result::QueryResult::Ok(())
// })
// })
// .await
// .map_err(DatabaseError::from)?;
// Ok(())
todo!()
Ok(())
}
#[message]
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
pub async fn operator_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.engine.list_all_grants().await {
Ok(grants) => Ok(grants),
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),
@@ -267,7 +264,6 @@ impl EvmActor {
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.await?;
use alloy::network::TxSignerSync as _;
Ok(signer.sign_transaction_sync(&mut transaction)?)
}
}

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
use crate::{
actors::{
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
useragent_registry::UserAgentRegistry, vault::Vault,
operator_registry::OperatorRegistry, proposal_manager::ProposalManager, vault::Vault,
vault_coordinator::VaultCoordinator,
},
db,
};
@@ -13,8 +14,10 @@ use thiserror::Error;
pub mod bootstrap;
pub mod evm;
pub mod flow_coordinator;
pub mod useragent_registry;
pub mod operator_registry;
pub mod proposal_manager;
pub mod vault;
pub mod vault_coordinator;
#[derive(Error, Debug)]
pub enum SpawnError {
@@ -30,9 +33,11 @@ pub enum SpawnError {
pub struct GlobalActors {
pub vault: ActorRef<Vault>,
pub bootstrapper: ActorRef<Bootstrapper>,
pub vault_coordinator: ActorRef<VaultCoordinator>,
pub flow_coordinator: ActorRef<FlowCoordinator>,
pub useragent_registry: ActorRef<UserAgentRegistry>,
pub operator_registry: ActorRef<OperatorRegistry>,
pub evm: ActorRef<EvmActor>,
pub proposal_manager: ActorRef<ProposalManager>,
pub events: ActorRef<MessageBus>,
}
@@ -44,16 +49,28 @@ impl GlobalActors {
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
let message_bus = Self::spawn_message_bus();
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
let useragent_registry = UserAgentRegistry::spawn(UserAgentRegistry::default());
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
let evm = EvmActor::spawn(EvmActor::new(key_holder.clone(), db.clone()));
let vault_coordinator = VaultCoordinator::spawn(VaultCoordinator::new(
db.clone(),
key_holder.clone(),
));
Ok(Self {
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)),
vault: key_holder,
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
useragent_registry.clone(),
proposal_manager: ProposalManager::spawn(ProposalManager::new(
db,
key_holder.clone(),
evm.clone(),
vault_coordinator.clone(),
)),
useragent_registry,
vault: key_holder,
vault_coordinator,
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new(
operator_registry.clone(),
)),
operator_registry,
events: message_bus,
evm,
})
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
use crate::{
crypto::{
KeyCell, derive_key,
KeyCell,
encryption::v1::{self, Nonce},
integrity::v1::HmacSha256,
},
db::{
self,
models::{self, RootKeyHistory},
schema::{self},
models::{self, RootKeyHistory, RootKeyHistoryId},
schema,
},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
@@ -18,14 +18,13 @@ use diesel::{
dsl::{insert_into, update},
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::Mac as _;
use hmac::{KeyInit as _, Mac as _};
use kameo::{Actor, Reply, actor::ActorRef, messages};
use kameo_actors::message_bus::{MessageBus, Publish};
use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info};
pub mod events {
#[derive(Clone, Copy)]
pub struct Bootstrapped;
@@ -64,7 +63,7 @@ pub enum Error {
}
struct Unsealed {
root_key_history_id: i32,
root_key_history_id: RootKeyHistoryId,
root_key: KeyCell,
}
@@ -73,13 +72,15 @@ struct Unsealed {
enum State {
#[default]
Unbootstrapped,
Sealed {
root_key_history_id: i32,
root_key_history_id: RootKeyHistoryId,
},
Unsealed(Unsealed),
}
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
///
/// Provides API for encrypting and decrypting data using the vault root key.
/// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor.
#[derive(Actor)]
@@ -89,7 +90,6 @@ pub struct Vault {
events: ActorRef<MessageBus>,
}
#[messages]
impl Vault {
pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
let state = {
@@ -112,100 +112,105 @@ impl Vault {
Ok(Self { db, state, events })
}
// Exclusive transaction to avoid race condtions if multiple vaults write
// Exclusive transaction to avoid race conditions if multiple vaults write
// additional layer of protection against nonce-reuse
async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> {
async fn get_new_nonce(
pool: &db::DatabasePool,
root_key_id: RootKeyHistoryId,
) -> Result<Nonce, Error> {
let mut conn = pool.get().await?;
let nonce = conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let current_nonce: Vec<u8> = schema::root_key_history::table
.filter(schema::root_key_history::id.eq(root_key_id))
.select(schema::root_key_history::data_encryption_nonce)
.first(conn)
.await?;
.exclusive_transaction(async |conn| {
let current_nonce: Vec<u8> = schema::root_key_history::table
.filter(schema::root_key_history::id.eq(root_key_id))
.select(schema::root_key_history::data_encryption_nonce)
.first(&mut *conn)
.await?;
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|_| {
error!(
"Broken database: invalid nonce for root key history id={}",
root_key_id
);
Error::BrokenDatabase
})?;
nonce.increment();
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
error!(
"Broken database: invalid nonce for root key history id={:#?}",
root_key_id
);
Error::BrokenDatabase
})?;
nonce.increment();
update(schema::root_key_history::table)
.filter(schema::root_key_history::id.eq(root_key_id))
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
.execute(conn)
.await?;
update(schema::root_key_history::table)
.filter(schema::root_key_history::id.eq(root_key_id))
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
.execute(&mut *conn)
.await?;
Result::<_, Error>::Ok(nonce)
})
Result::<_, Error>::Ok(nonce)
})
.await?;
Ok(nonce)
}
fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
const fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
match state {
State::Unsealed(unsealed) => Ok(unsealed),
State::Unbootstrapped => Err(Error::NotBootstrapped),
State::Sealed { .. } => Err(Error::Sealed),
}
}
}
#[messages]
impl Vault {
#[message]
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if !matches!(self.state, State::Unbootstrapped) {
pub async fn bootstrap(&mut self, mut seal_key: KeyCell) -> Result<(), Error> {
if !matches!(&self.state, State::Unbootstrapped) {
return Err(Error::AlreadyBootstrapped);
}
let salt = v1::generate_salt();
let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = KeyCell::new_secure_random();
// Zero nonces are fine because they are one-time
let root_key_nonce = Nonce::default();
let data_encryption_nonce = Nonce::default();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
let root_key_reader = reader.as_slice();
// Generate salt (kept for schema compat)
let root_key_salt = v1::generate_salt();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|rk| {
seal_key
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, root_key_reader)
.encrypt(&root_key_nonce, v1::ROOT_KEY_TAG, rk.as_slice())
.map_err(|err| {
error!(?err, "Fatal bootstrap error");
Error::Encryption(err)
})
})?;
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let mut conn = self.db.get().await?;
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let root_key_history_id = conn
.transaction(|conn| {
Box::pin(async move {
let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: root_key_ciphertext,
tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: root_key_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce_bytes,
schema_version: 1,
salt: salt.to_vec(),
})
.returning(schema::root_key_history::id)
.get_result(conn)
.await?;
.transaction(async |conn| {
let root_key_history_id = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: root_key_ciphertext.clone(),
tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: root_key_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce_bytes.clone(),
schema_version: 1,
salt: root_key_salt.to_vec(),
})
.returning(schema::root_key_history::id)
.get_result(&mut *conn)
.await?;
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
.execute(conn)
.await?;
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
.execute(&mut *conn)
.await?;
Result::<_, diesel::result::Error>::Ok(root_key_history_id)
})
Result::<_, diesel::result::Error>::Ok(RootKeyHistoryId::from_raw(
root_key_history_id,
))
})
.await?;
@@ -221,53 +226,47 @@ impl Vault {
}
#[message]
pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
pub async fn try_unseal(&mut self, mut seal_key: KeyCell) -> Result<(), Error> {
let State::Sealed {
root_key_history_id,
} = &self.state
else {
return Err(Error::NotBootstrapped);
};
let root_key_history_id = *root_key_history_id;
// We don't want to hold connection while doing expensive KDF work
// We don't want to hold connection while doing expensive work
let current_key = {
let mut conn = self.db.get().await?;
schema::root_key_history::table
.filter(schema::root_key_history::id.eq(*root_key_history_id))
.filter(schema::root_key_history::id.eq(root_key_history_id))
.select(RootKeyHistory::as_select())
.first(&mut conn)
.await?
};
let salt = &current_key.salt;
let salt = v1::Salt::try_from(salt.as_slice()).map_err(|_| {
error!("Broken database: invalid salt for root key");
Error::BrokenDatabase
})?;
let mut seal_key = derive_key(seal_key_raw, &salt);
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
let nonce = v1::Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(
|_| {
let nonce =
Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| {
error!("Broken database: invalid nonce for root key");
Error::BrokenDatabase
},
)?;
})?;
let mut root_key_bytes = SafeCell::new(current_key.ciphertext.clone());
seal_key
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key_bytes)
.map_err(|err| {
error!(?err, "Failed to unseal root key: invalid seal key");
Error::InvalidKey
})?;
let root_key = KeyCell::try_from(root_key_bytes).map_err(|()| {
error!("Broken database: invalid encryption key size");
Error::BrokenDatabase
})?;
self.state = State::Unsealed(Unsealed {
root_key_history_id: current_key.id,
root_key: KeyCell::try_from(root_key).map_err(|err| {
error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase
})?,
root_key,
});
info!("Vault unsealed successfully");
@@ -276,6 +275,77 @@ impl Vault {
Ok(())
}
/// Re-encrypts the root key with `new_seal_key` and records a new root_key_history row.
/// Called after a Shamir re-key so the old seal key is no longer sufficient to unseal.
#[message]
pub async fn rekey_root_key(&mut self, mut new_seal_key: KeyCell) -> Result<(), Error> {
let Unsealed {
root_key,
root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?;
let new_nonce = Nonce::default();
let new_salt = v1::generate_salt();
let new_ciphertext: Vec<u8> = root_key.0.read_inline(|rk| {
new_seal_key
.encrypt(&new_nonce, v1::ROOT_KEY_TAG, rk.as_slice())
.map_err(|err| {
error!(?err, "Fatal rekey error");
Error::Encryption(err)
})
})?;
let data_encryption_nonce = Nonce::default();
let mut conn = self.db.get().await?;
let new_root_key_history_id: i32 = conn
.transaction(async |conn| {
let new_id = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: new_ciphertext,
tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: new_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce.to_vec(),
schema_version: 1,
salt: new_salt.to_vec(),
})
.returning(schema::root_key_history::id)
.get_result::<i32>(&mut *conn)
.await?;
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(new_id))
.execute(&mut *conn)
.await?;
Result::<_, diesel::result::Error>::Ok(new_id)
})
.await?;
*root_key_history_id = RootKeyHistoryId::from_raw(new_root_key_history_id);
info!("Vault root key rekeyed successfully");
Ok(())
}
#[message]
pub async fn seal(&mut self) -> Result<(), Error> {
let Unsealed {
root_key_history_id,
..
} = Self::expect_unsealed(&mut self.state)?;
self.state = State::Sealed {
root_key_history_id: *root_key_history_id,
};
let _ = self.events.tell(Publish(events::VaultResealed)).await;
Ok(())
}
}
// Server-side cryptographic operations
#[messages]
impl Vault {
#[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?;
@@ -291,7 +361,7 @@ impl Vault {
.ok_or(Error::NotFound)?
};
let nonce = v1::Nonce::try_from(row.current_nonce.as_slice()).map_err(|_| {
let nonce = Nonce::try_from(row.current_nonce.as_slice()).map_err(|()| {
error!(
"Broken database: invalid nonce for aead_encrypted id={}",
aead_id
@@ -344,19 +414,20 @@ impl Vault {
}
#[message]
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
pub fn sign_integrity(
&mut self,
mac_input: Vec<u8>,
) -> Result<(RootKeyHistoryId, Vec<u8>), Error> {
let Unsealed {
root_key,
root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?;
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
hmac.update(&root_key_history_id.to_be_bytes());
let mut hmac = root_key.0.read_inline(|k| {
HmacSha256::new_from_slice(k)
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
});
hmac.update(&root_key_history_id.to_raw().to_be_bytes());
hmac.update(&mac_input);
let mac = hmac.finalize().into_bytes().to_vec();
@@ -368,7 +439,7 @@ impl Vault {
&mut self,
mac_input: Vec<u8>,
expected_mac: Vec<u8>,
key_version: i32,
key_version: RootKeyHistoryId,
) -> Result<bool, Error> {
let Unsealed {
root_key,
@@ -379,44 +450,21 @@ impl Vault {
return Ok(false);
}
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
hmac.update(&key_version.to_be_bytes());
let mut hmac = root_key.0.read_inline(|k| {
HmacSha256::new_from_slice(k)
.unwrap_or_else(|_| unreachable!("HMAC accepts keys of any size"))
});
hmac.update(&key_version.to_raw().to_be_bytes());
hmac.update(&mac_input);
Ok(hmac.verify_slice(&expected_mac).is_ok())
}
#[message]
pub async fn seal(&mut self) -> Result<(), Error> {
let Unsealed {
root_key_history_id,
..
} = Self::expect_unsealed(&mut self.state)?;
self.state = State::Sealed {
root_key_history_id: *root_key_history_id,
};
let _ = self.events.tell(Publish(events::VaultResealed)).await;
Ok(())
}
}
#[cfg(test)]
mod tests {
use diesel::SelectableHelper;
use diesel_async::RunQueryDsl;
use crate::{
actors::GlobalActors,
db::{self},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use crate::actors::GlobalActors;
use arbiter_crypto::safecell::SafeCellHandle as _;
use super::*;
@@ -424,8 +472,7 @@ mod tests {
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.bootstrap(seal_key).await.unwrap();
actor.bootstrap(KeyCell::from([0u8; 32])).await.unwrap();
actor
}
@@ -434,12 +481,12 @@ mod tests {
async fn nonce_monotonic_even_when_nonce_allocation_interleaves() {
let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await;
let root_key_history_id = match actor.state {
State::Unsealed(Unsealed {
root_key_history_id,
..
}) => root_key_history_id,
_ => panic!("expected unsealed state"),
let State::Unsealed(Unsealed {
root_key_history_id,
..
}) = actor.state
else {
panic!("expected unsealed state");
};
let n1 = Vault::get_new_nonce(&db, root_key_history_id)
@@ -451,8 +498,8 @@ mod tests {
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");
let mut conn = db.get().await.unwrap();
let root_row: models::RootKeyHistory = schema::root_key_history::table
.select(models::RootKeyHistory::as_select())
let root_row: RootKeyHistory = schema::root_key_history::table
.select(RootKeyHistory::as_select())
.first(&mut conn)
.await
.unwrap();

View File

@@ -0,0 +1,721 @@
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};
use rand_core::{OsRng, RngCore as _};
use tracing::error;
use crate::{
actors::vault::{Bootstrap, RekeyRootKey, TryUnseal, Vault},
crypto::{KeyCell, derive_key, encryption::v1::Nonce, shamir, shamir::shamir_threshold},
db::{self, models, 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,
}
// 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 `update_shamir_parameters` is approved (§3.3).
/// Collects new passphrases from all current operators, then generates a fresh seal key,
/// re-splits it, and re-encrypts the vault root key.
Rekeying {
ordinary_count: usize,
recovery_count: usize,
passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
},
}
#[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())
}
/// §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);
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?;
}
vault.ask(Bootstrap { seal_key }).await.map_err(|err| {
error!(?err, "Vault bootstrap failed");
Error::VaultError
})?;
Ok(())
}
/// §3.5: Unseal using any threshold-sized mix of ordinary + recovery shares.
async fn finalize_unseal(
db: db::DatabasePool,
vault: ActorRef<Vault>,
ordinary_passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
) -> Result<(), Error> {
let mut conn = db.get().await?;
// Determine whether shares were stored as raw keys (threshold=1) or vsss-rs splits (threshold>=2).
let ordinary_operator_count: i64 = schema::operator::table
.count()
.get_result(&mut conn)
.await?;
let threshold = shamir_threshold(ordinary_operator_count as usize);
let mut shares: Vec<Vec<u8>> = Vec::new();
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
schema::operator::table
.filter(schema::operator::id.eq(Some(operator_id_raw)))
.select((
schema::operator::share,
schema::operator::share_nonce,
schema::operator::share_salt,
))
.first(&mut conn)
.await
.map_err(|_| Error::OperatorNotFound)?;
shares.push(decrypt_share(
passphrase_bytes,
encrypted_share,
&share_nonce_bytes,
&share_salt,
operator_id_raw,
)?);
}
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
let (encrypted_share, share_nonce_bytes, share_salt): (Vec<u8>, Vec<u8>, Vec<u8>) =
schema::recovery_operator::table
.find(recovery_id_raw)
.select((
schema::recovery_operator::share,
schema::recovery_operator::share_nonce,
schema::recovery_operator::share_salt,
))
.first(&mut conn)
.await
.map_err(|_| Error::OperatorNotFound)?;
shares.push(decrypt_share(
passphrase_bytes,
encrypted_share,
&share_nonce_bytes,
&share_salt,
recovery_id_raw,
)?);
}
// When threshold==1, shares are raw 32-byte seal keys (vsss-rs cannot split 1-of-N).
// Any single decrypted share is the key itself.
let seal_key_bytes: [u8; 32] = if threshold <= 1 {
let raw = shares
.into_iter()
.next()
.ok_or_else(|| Error::Shamir("No shares available".into()))?;
raw.try_into()
.map_err(|_| Error::Shamir("Invalid share length".into()))?
} else {
shamir::combine_shares(&shares).map_err(|e| Error::Shamir(e.to_string()))?
};
let seal_key = KeyCell::from(seal_key_bytes);
vault.ask(TryUnseal { seal_key }).await.map_err(|err| {
error!(?err, "Vault unseal failed");
Error::VaultError
})?;
Ok(())
}
/// §3.3: Generate a fresh seal key, split across current operators, re-encrypt the vault root key.
/// Called after `replace_operator` or `update_shamir_parameters` is approved and all contributors submit.
async fn finalize_rekey(
db: db::DatabasePool,
vault: ActorRef<Vault>,
ordinary_passphrases: HashMap<i32, Vec<u8>>,
recovery_passphrases: HashMap<i32, Vec<u8>>,
) -> Result<(), Error> {
let ordinary_count = ordinary_passphrases.len();
let recovery_count = recovery_passphrases.len();
let total = ordinary_count + recovery_count;
let threshold = shamir_threshold(ordinary_count);
let mut new_seal_key_bytes = [0u8; 32];
OsRng.fill_bytes(&mut new_seal_key_bytes);
let shares: Vec<Vec<u8>> = if threshold >= 2 {
shamir::split_key(threshold, total, &new_seal_key_bytes, OsRng)
.map_err(|e| Error::Shamir(e.to_string()))?
} else {
std::iter::repeat_with(|| new_seal_key_bytes.to_vec())
.take(total)
.collect()
};
let mut conn = db.get().await?;
let mut shares_iter = shares.into_iter();
for (operator_id_raw, passphrase_bytes) in ordinary_passphrases {
let share = shares_iter
.next()
.expect("split_key returned enough shares");
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
diesel::replace_into(schema::operator::table)
.values((
schema::operator::id.eq(Some(operator_id_raw)),
schema::operator::share.eq(&encrypted_share),
schema::operator::share_nonce.eq(&nonce_bytes),
schema::operator::share_salt.eq(&share_salt),
schema::operator::created_at.eq(models::SqliteTimestamp::now()),
schema::operator::updated_at.eq(models::SqliteTimestamp::now()),
))
.execute(&mut conn)
.await?;
}
for (recovery_id_raw, passphrase_bytes) in recovery_passphrases {
let share = shares_iter
.next()
.expect("split_key returned enough shares");
let (encrypted_share, nonce_bytes, share_salt) = encrypt_share(passphrase_bytes, &share)?;
diesel::replace_into(schema::recovery_operator::table)
.values((
schema::recovery_operator::id.eq(recovery_id_raw),
schema::recovery_operator::share.eq(&encrypted_share),
schema::recovery_operator::share_nonce.eq(&nonce_bytes),
schema::recovery_operator::share_salt.eq(&share_salt),
schema::recovery_operator::created_at.eq(models::SqliteTimestamp::now()),
schema::recovery_operator::updated_at.eq(models::SqliteTimestamp::now()),
))
.execute(&mut conn)
.await?;
}
drop(conn);
let new_seal_key = KeyCell::from(new_seal_key_bytes);
vault
.ask(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 == 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> {
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 is based on ordinary operator count only (§3.4).
async fn ensure_unsealing_state(&mut self) -> Result<(), Error> {
if matches!(self.state, CoordinatorState::Idle) {
let mut conn = self.db.get().await?;
let ordinary_count: i64 = schema::operator::table
.count()
.get_result(&mut conn)
.await?;
let threshold = shamir_threshold(usize::try_from(ordinary_count).unwrap_or_default());
self.state = CoordinatorState::Unsealing {
threshold,
ordinary_passphrases: HashMap::new(),
recovery_passphrases: HashMap::new(),
};
}
Ok(())
}
/// Moves state back to Idle and calls finalize_unseal.
async fn do_finalize_unseal(&mut self) -> Result<bool, Error> {
let CoordinatorState::Unsealing {
ordinary_passphrases,
recovery_passphrases,
..
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
else {
unreachable!()
};
finalize_unseal(
self.db.clone(),
self.vault.clone(),
ordinary_passphrases,
recovery_passphrases,
)
.await?;
Ok(true)
}
async fn do_finalize_rekey(&mut self) -> Result<bool, Error> {
let CoordinatorState::Rekeying {
passphrases,
recovery_passphrases,
..
} = std::mem::replace(&mut self.state, CoordinatorState::Idle)
else {
unreachable!()
};
finalize_rekey(
self.db.clone(),
self.vault.clone(),
passphrases,
recovery_passphrases,
)
.await?;
Ok(true)
}
}
#[messages]
impl VaultCoordinator {
/// Begin Shamir re-key after a key-rotation proposal is approved (§3.3).
/// Queries the current operator and recovery operator counts from the DB,
/// then transitions to Rekeying state awaiting contributions from all of them.
#[message]
pub async fn start_rekey(&mut self) -> Result<(), Error> {
if !matches!(self.state, CoordinatorState::Idle) {
return Err(Error::AlreadyBootstrapping);
}
let mut conn = self.db.get().await?;
let ordinary_count: i64 = schema::operator_identity::table
.count()
.get_result(&mut conn)
.await?;
let recovery_count: i64 = schema::recovery_operator_identity::table
.count()
.get_result(&mut conn)
.await?;
self.state = CoordinatorState::Rekeying {
ordinary_count: ordinary_count as usize,
recovery_count: recovery_count as usize,
passphrases: HashMap::new(),
recovery_passphrases: HashMap::new(),
};
Ok(())
}
/// Contribute an ordinary operator passphrase for the re-key.
/// Returns Ok(true) when all contributors have submitted and the re-key is complete.
#[message]
pub async fn contribute_rekey(
&mut self,
operator_id: i32,
mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
let CoordinatorState::Rekeying {
ordinary_count,
recovery_count,
passphrases,
recovery_passphrases,
} = &mut self.state
else {
return Err(Error::NotRekeying);
};
if passphrases.contains_key(&operator_id) {
return Err(Error::DuplicateContribution);
}
passphrases.insert(operator_id, passphrase.read().to_vec());
if passphrases.len() < *ordinary_count || recovery_passphrases.len() < *recovery_count {
return Ok(false);
}
self.do_finalize_rekey().await
}
/// Contribute a recovery operator passphrase for the re-key.
/// Returns Ok(true) when all contributors have submitted and the re-key is complete.
#[message]
pub async fn contribute_recovery_rekey(
&mut self,
recovery_operator_id: i32,
mut passphrase: SafeCell<Vec<u8>>,
) -> Result<bool, Error> {
let CoordinatorState::Rekeying {
ordinary_count,
recovery_count,
passphrases,
recovery_passphrases,
} = &mut self.state
else {
return Err(Error::NotRekeying);
};
if recovery_passphrases.contains_key(&recovery_operator_id) {
return Err(Error::DuplicateContribution);
}
recovery_passphrases.insert(recovery_operator_id, passphrase.read().to_vec());
if passphrases.len() < *ordinary_count || recovery_passphrases.len() < *recovery_count {
return Ok(false);
}
self.do_finalize_rekey().await
}
}

View File

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

View File

@@ -20,9 +20,10 @@ use thiserror::Error;
use tonic::transport::CertificateDer;
const ENCODE_CONFIG: pem::EncodeConfig = {
let line_ending = match cfg!(target_family = "windows") {
true => pem::LineEnding::CRLF,
false => pem::LineEnding::LF,
let line_ending = if cfg!(target_family = "windows") {
pem::LineEnding::CRLF
} else {
pem::LineEnding::LF
};
pem::EncodeConfig::new().set_line_ending(line_ending)
};
@@ -50,11 +51,14 @@ pub enum InitError {
pub type PemCert = String;
pub fn encode_cert_to_pem(cert: &CertificateDer) -> PemCert {
pub fn encode_cert_to_pem(cert: &CertificateDer<'_>) -> PemCert {
pem::encode_config(&Pem::new("CERTIFICATE", cert.to_vec()), ENCODE_CONFIG)
}
#[allow(unused)]
#[expect(
unused,
reason = "may be needed for future cert rotation implementation"
)]
struct SerializedTls {
cert_pem: PemCert,
cert_key_pem: String,
@@ -83,7 +87,7 @@ impl TlsCa {
let cert_key_pem = certified_issuer.key().serialize_pem();
#[allow(
#[expect(
clippy::unwrap_used,
reason = "Broken cert couldn't bootstrap server anyway"
)]
@@ -122,7 +126,11 @@ impl TlsCa {
})
}
#[allow(unused)]
#[expect(
unused,
clippy::unnecessary_wraps,
reason = "may be needed for future cert rotation implementation"
)]
fn serialize(&self) -> Result<SerializedTls, InitError> {
let cert_key_pem = self.issuer.key().serialize_pem();
Ok(SerializedTls {
@@ -131,7 +139,10 @@ impl TlsCa {
})
}
#[allow(unused)]
#[expect(
unused,
reason = "may be needed for future cert rotation implementation"
)]
fn try_deserialize(cert_pem: &str, cert_key_pem: &str) -> Result<Self, InitError> {
let keypair =
KeyPair::from_pem(cert_key_pem).map_err(InitError::KeyDeserializationError)?;
@@ -163,28 +174,26 @@ impl TlsManager {
{
let mut conn = db.get().await?;
conn.transaction(|conn| {
Box::pin(async {
let new_tls_history = NewTlsHistory {
cert: new_cert.cert.pem(),
cert_key: new_cert.cert_key.serialize_pem(),
ca_cert: encode_cert_to_pem(&ca.cert),
ca_key: ca.issuer.key().serialize_pem(),
};
conn.transaction(async |conn| {
let new_tls_history = NewTlsHistory {
cert: new_cert.cert.pem(),
cert_key: new_cert.cert_key.serialize_pem(),
ca_cert: encode_cert_to_pem(&ca.cert),
ca_key: ca.issuer.key().serialize_pem(),
};
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table)
.values(&new_tls_history)
.returning(tls_history::id)
.get_result(conn)
.await?;
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table)
.values(&new_tls_history)
.returning(tls_history::id)
.get_result(&mut *conn)
.await?;
diesel::update(arbiter_settings::table)
.set(arbiter_settings::tls_id.eq(inserted_tls_history))
.execute(conn)
.await?;
diesel::update(arbiter_settings::table)
.set(arbiter_settings::tls_id.eq(inserted_tls_history))
.execute(&mut *conn)
.await?;
Result::<_, diesel::result::Error>::Ok(())
})
Result::<_, diesel::result::Error>::Ok(())
})
.await?;
}
@@ -232,10 +241,10 @@ impl TlsManager {
}
}
pub fn cert(&self) -> &CertificateDer<'static> {
pub const fn cert(&self) -> &CertificateDer<'static> {
&self.cert
}
pub fn ca_cert(&self) -> &CertificateDer<'static> {
pub const fn ca_cert(&self) -> &CertificateDer<'static> {
&self.ca_cert
}

View File

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

View File

@@ -64,6 +64,11 @@ fn payload_hash(payload: &impl Hashable) -> [u8; 32] {
}
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #85"
)]
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
@@ -115,7 +120,7 @@ pub async fn sign_entity<E: Integrable>(
.ask(SignIntegrity { mac_input })
.await
.map_err(|err| match err {
kameo::error::SendError::HandlerError(inner) => Error::Vault(inner),
SendError::HandlerError(inner) => Error::Vault(inner),
_ => Error::VaultSend,
})?;
@@ -125,7 +130,7 @@ pub async fn sign_entity<E: Integrable>(
entity_id,
payload_version: E::VERSION,
key_version,
mac: mac.to_vec(),
mac: mac.clone(),
})
.on_conflict((
integrity_envelope::entity_id,
@@ -210,8 +215,6 @@ mod tests {
},
db::{self, schema},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use super::{Error, Integrable, sign_entity, verify_entity};
#[derive(Clone, arbiter_macros::Hashable)]
struct DummyEntity {
@@ -230,7 +233,7 @@ mod tests {
);
actor
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
seal_key: crate::crypto::KeyCell::from([0u8; 32]),
})
.await
.unwrap();
@@ -239,12 +242,12 @@ mod tests {
#[tokio::test]
async fn sign_writes_envelope_and_verify_passes() {
const ENTITY_ID: &[u8] = b"entity-id-7";
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-7";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
@@ -270,12 +273,12 @@ mod tests {
#[tokio::test]
async fn tampered_mac_fails_verification() {
const ENTITY_ID: &[u8] = b"entity-id-11";
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-11";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
@@ -301,12 +304,12 @@ mod tests {
#[tokio::test]
async fn changed_payload_fails_verification() {
const ENTITY_ID: &[u8] = b"entity-id-21";
let db = db::create_test_pool().await;
let vault = bootstrapped_vault(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-21";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),

View File

@@ -1,5 +1,5 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use encryption::v1::{Nonce, Salt};
use encryption::v1::Nonce;
use argon2::{Algorithm, Argon2};
use chacha20poly1305::{
@@ -10,10 +10,10 @@ use rand::{
Rng as _, SeedableRng as _,
rngs::{StdRng, SysRng},
};
use std::ops::Deref as _;
pub mod encryption;
pub mod integrity;
pub mod shamir;
pub struct KeyCell(pub SafeCell<Key>);
impl From<SafeCell<Key>> for KeyCell {
@@ -21,6 +21,15 @@ impl From<SafeCell<Key>> for KeyCell {
Self(value)
}
}
impl From<[u8; 32]> for KeyCell {
fn from(bytes: [u8; 32]) -> Self {
let cell = SafeCell::new_inline_default(|key: &mut Key| {
key.copy_from_slice(&bytes);
});
Self(cell)
}
}
impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
type Error = ();
@@ -29,7 +38,7 @@ impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
if value.len() != size_of::<Key>() {
return Err(());
}
let cell = SafeCell::new_inline(|cell_write: &mut Key| {
let cell = SafeCell::new_inline_default(|cell_write: &mut Key| {
cell_write.copy_from_slice(&value);
});
Ok(Self(cell))
@@ -38,12 +47,9 @@ impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
impl KeyCell {
pub fn new_secure_random() -> Self {
let key = SafeCell::new_inline(|key_buffer: &mut Key| {
#[allow(
clippy::unwrap_used,
reason = "Rng failure is unrecoverable and should panic"
)]
let mut rng = StdRng::try_from_rng(&mut SysRng).unwrap();
let key = SafeCell::new_inline_default(|key_buffer: &mut Key| {
let mut rng = StdRng::try_from_rng(&mut SysRng)
.expect("Rng failure is unrecoverable and should panic");
rng.fill_bytes(key_buffer);
});
@@ -57,8 +63,7 @@ impl KeyCell {
mut buffer: impl AsMut<Vec<u8>>,
) -> Result<(), Error> {
let key_reader = self.0.read();
let key_ref = key_reader.deref();
let cipher = XChaCha20Poly1305::new(key_ref);
let cipher = XChaCha20Poly1305::new(&key_reader);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let buffer = buffer.as_mut();
cipher.encrypt_in_place(nonce, associated_data, buffer)
@@ -70,8 +75,7 @@ impl KeyCell {
buffer: &mut SafeCell<Vec<u8>>,
) -> Result<(), Error> {
let key_reader = self.0.read();
let key_ref = key_reader.deref();
let cipher = XChaCha20Poly1305::new(key_ref);
let cipher = XChaCha20Poly1305::new(&key_reader);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let mut buffer = buffer.write();
let buffer: &mut Vec<u8> = buffer.as_mut();
@@ -85,8 +89,7 @@ impl KeyCell {
plaintext: impl AsRef<[u8]>,
) -> Result<Vec<u8>, Error> {
let key_reader = self.0.read();
let key_ref = key_reader.deref();
let mut cipher = XChaCha20Poly1305::new(key_ref);
let mut cipher = XChaCha20Poly1305::new(&key_reader);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let ciphertext = cipher.encrypt(
@@ -101,7 +104,7 @@ impl KeyCell {
}
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
pub fn derive_key(password: &mut SafeCell<Vec<u8>>, salt: &[u8]) -> KeyCell {
let params = {
#[cfg(debug_assertions)]
{
@@ -114,20 +117,15 @@ pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
}
};
#[allow(clippy::unwrap_used)]
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = SafeCell::new(Key::default());
password.read_inline(|password_source| {
let mut key_buffer = key.write();
let key_buffer: &mut [u8] = key_buffer.as_mut();
#[allow(
clippy::unwrap_used,
reason = "Better fail completely than return a weak key"
)]
hasher
.hash_password_into(password_source.deref(), salt, key_buffer)
.unwrap();
.hash_password_into(password_source, salt, key_buffer)
.expect("Better fail completely than return a weak key");
});
key.into()
@@ -144,10 +142,10 @@ mod tests {
#[test]
fn encrypt_decrypt() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let mut password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_key(password, &salt);
let mut key = derive_key(&mut password, &salt);
let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
let associated_data = b"associated data";
let mut buffer = b"secret data".to_vec();

View File

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

View File

@@ -22,14 +22,14 @@ const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
#[derive(Error, Debug)]
pub enum DatabaseSetupError {
#[error("Failed to determine home directory")]
HomeDir(std::io::Error),
#[error(transparent)]
ConcurrencySetup(diesel::result::Error),
#[error(transparent)]
Connection(diesel::ConnectionError),
#[error(transparent)]
ConcurrencySetup(diesel::result::Error),
#[error("Failed to determine home directory")]
HomeDir(std::io::Error),
#[error(transparent)]
Migration(Box<dyn std::error::Error + Send + Sync>),
@@ -40,10 +40,11 @@ pub enum DatabaseSetupError {
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("Database connection error")]
Pool(#[from] PoolError),
#[error("Database query error")]
Connection(#[from] diesel::result::Error),
#[error("Database connection error")]
Pool(#[from] PoolError),
}
#[tracing::instrument(level = "info")]
@@ -92,13 +93,16 @@ fn initialize_database(url: &str) -> Result<(), DatabaseSetupError> {
}
#[tracing::instrument(level = "info")]
/// Creates a connection pool for the `SQLite` database.
///
/// # Panics
/// Panics if the database path is not valid UTF-8.
pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetupError> {
let database_url = url.map(String::from).unwrap_or(
#[allow(clippy::expect_used)]
database_path()?
.to_str()
.expect("database path is not valid UTF-8")
.to_string(),
.to_owned(),
);
initialize_database(&database_url)?;
@@ -133,19 +137,19 @@ pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetu
}
#[mutants::skip]
#[expect(clippy::missing_panics_doc, reason = "Tests oriented function")]
/// Creates a test database pool with a temporary `SQLite` database file.
pub async fn create_test_pool() -> DatabasePool {
use rand::distr::{Alphanumeric, SampleString as _};
let tempfile_name = Alphanumeric.sample_string(&mut rand::rng(), 16);
let file = std::env::temp_dir().join(tempfile_name);
#[allow(clippy::expect_used)]
let url = file
.to_str()
.expect("temp file path is not valid UTF-8")
.to_string();
.to_owned();
#[allow(clippy::expect_used)]
create_pool(Some(&url))
.await
.expect("Failed to create test database pool")

View File

@@ -1,5 +1,7 @@
#![allow(unused)]
#![allow(clippy::all)]
#![allow(
clippy::duplicated_attributes,
reason = "restructed's #[view] causes false positives"
)]
use crate::db::schema::{
self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant,
evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant,
@@ -7,17 +9,17 @@ use crate::db::schema::{
integrity_envelope, root_key_history, tls_history,
};
use chrono::{DateTime, Utc};
use diesel::{prelude::*, sqlite::Sqlite};
use restructed::Models;
pub mod types {
use chrono::{DateTime, Utc};
use diesel::{
backend::Backend,
deserialize::{FromSql, FromSqlRow},
expression::AsExpression,
serialize::{IsNull, ToSql},
sql_types::Integer,
sql_types::{Integer, Text},
sqlite::{Sqlite, SqliteType},
};
@@ -27,16 +29,16 @@ pub mod types {
pub struct SqliteTimestamp(pub DateTime<Utc>);
impl SqliteTimestamp {
pub fn now() -> Self {
SqliteTimestamp(Utc::now())
Self(Utc::now())
}
}
impl From<chrono::DateTime<Utc>> for SqliteTimestamp {
fn from(dt: chrono::DateTime<Utc>) -> Self {
SqliteTimestamp(dt)
impl From<DateTime<Utc>> for SqliteTimestamp {
fn from(dt: DateTime<Utc>) -> Self {
Self(dt)
}
}
impl From<SqliteTimestamp> for chrono::DateTime<Utc> {
impl From<SqliteTimestamp> for DateTime<Utc> {
fn from(ts: SqliteTimestamp) -> Self {
ts.0
}
@@ -47,6 +49,11 @@ pub mod types {
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #84; this will break up in 2038 :3"
)]
let unix_timestamp = self.0.timestamp() as i32;
out.set_value(unix_timestamp);
Ok(IsNull::No)
@@ -55,7 +62,7 @@ pub mod types {
impl FromSql<Integer, Sqlite> for SqliteTimestamp {
fn from_sql(
mut bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
mut bytes: <Sqlite as Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
let Some(SqliteType::Long) = bytes.value_type() else {
return Err(format!(
@@ -69,7 +76,109 @@ pub mod types {
let datetime =
DateTime::from_timestamp(unix_timestamp, 0).ok_or("Timestamp is out of bounds")?;
Ok(SqliteTimestamp(datetime))
Ok(Self(datetime))
}
}
macro_rules! declare_id {
($name:ident) => {
#[derive(Debug, FromSqlRow, AsExpression, Clone, Hash, Copy, PartialEq, Eq)]
#[diesel(sql_type = Integer)]
#[repr(transparent)] // hint compiler to optimize the wrapper struct away
pub struct $name(i32);
impl $name {
pub const fn to_raw(self) -> i32 {
self.0
}
pub const fn from_raw(raw: i32) -> Self {
Self(raw)
}
}
impl FromSql<Integer, Sqlite> for $name {
fn from_sql(
bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
FromSql::<Integer, Sqlite>::from_sql(bytes).map(Self)
}
}
impl ToSql<Integer, Sqlite> for $name {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
}
}
};
}
declare_id!(ChainId);
#[expect(
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants"
)]
const _: () = {
impl From<ChainId> for alloy::primitives::ChainId {
fn from(chain_id: ChainId) -> Self {
chain_id.0 as Self
}
}
impl From<alloy::primitives::ChainId> for ChainId {
fn from(chain_id: alloy::primitives::ChainId) -> Self {
Self(chain_id as _)
}
}
};
declare_id!(OperatorId);
declare_id!(OperatorIdentityId);
declare_id!(AeadEncryptedId);
declare_id!(RootKeyHistoryId);
declare_id!(TlsHistoryId);
declare_id!(EvmWalletId);
declare_id!(ClientId);
#[derive(Debug, Clone, PartialEq, Eq, AsExpression, FromSqlRow)]
#[diesel(sql_type = Text)]
pub enum ProposalStatus {
Pending,
Approved,
Rejected,
Expired,
}
impl ToSql<Text, Sqlite> for ProposalStatus {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
let s: &str = match self {
Self::Pending => "pending",
Self::Approved => "approved",
Self::Rejected => "rejected",
Self::Expired => "expired",
};
<str as ToSql<Text, Sqlite>>::to_sql(s, out)
}
}
impl FromSql<Text, Sqlite> for ProposalStatus {
fn from_sql(
bytes: <Sqlite as Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
match s.as_str() {
"pending" => Ok(Self::Pending),
"approved" => Ok(Self::Approved),
"rejected" => Ok(Self::Rejected),
"expired" => Ok(Self::Expired),
other => Err(format!("Unknown proposal status: {other}").into()),
}
}
}
}
@@ -84,12 +193,12 @@ pub use types::*;
)]
#[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))]
pub struct AeadEncrypted {
pub id: i32,
pub id: AeadEncryptedId,
pub ciphertext: Vec<u8>,
pub tag: Vec<u8>,
pub current_nonce: Vec<u8>,
pub schema_version: i32,
pub associated_root_key_id: i32, // references root_key_history.id
pub associated_root_key_id: RootKeyHistoryId,
pub created_at: SqliteTimestamp,
}
@@ -102,7 +211,7 @@ pub struct AeadEncrypted {
attributes_with = "deriveless"
)]
pub struct RootKeyHistory {
pub id: i32,
pub id: RootKeyHistoryId,
pub ciphertext: Vec<u8>,
pub tag: Vec<u8>,
pub root_key_encryption_nonce: Vec<u8>,
@@ -120,7 +229,7 @@ pub struct RootKeyHistory {
attributes_with = "deriveless"
)]
pub struct TlsHistory {
pub id: i32,
pub id: TlsHistoryId,
pub cert: String,
pub cert_key: String, // PEM Encoded private key
pub ca_cert: String, // PEM Encoded certificate for cert signing
@@ -145,7 +254,7 @@ pub struct ArbiterSettings {
attributes_with = "deriveless"
)]
pub struct EvmWallet {
pub id: i32,
pub id: EvmWalletId,
pub address: Vec<u8>,
pub aead_encrypted_id: i32,
pub created_at: SqliteTimestamp,
@@ -167,7 +276,7 @@ pub struct EvmWallet {
)]
pub struct EvmWalletAccess {
pub id: i32,
pub wallet_id: i32,
pub wallet_id: EvmWalletId,
pub client_id: i32,
pub created_at: SqliteTimestamp,
}
@@ -194,7 +303,7 @@ pub struct ProgramClientMetadataHistory {
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = schema::program_client, check_for_backend(Sqlite))]
pub struct ProgramClient {
pub id: i32,
pub id: ClientId,
pub public_key: Vec<u8>,
pub metadata_id: i32,
pub created_at: SqliteTimestamp,
@@ -202,14 +311,25 @@ pub struct ProgramClient {
}
#[derive(Queryable, Debug)]
#[diesel(table_name = schema::useragent_client, check_for_backend(Sqlite))]
pub struct UseragentClient {
pub id: i32,
#[diesel(table_name = schema::operator_client, check_for_backend(Sqlite))]
pub struct OperatorClient {
pub id: OperatorIdentityId,
pub public_key: Vec<u8>,
pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp,
}
#[derive(Queryable, Debug)]
#[diesel(table_name = schema::operator, check_for_backend(Sqlite))]
pub struct Operator {
pub id: OperatorId,
pub share: Vec<u8>,
pub share_nonce: Vec<u8>,
pub share_salt: Vec<u8>,
pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_ether_transfer_limit, check_for_backend(Sqlite))]
#[view(
@@ -235,7 +355,7 @@ pub struct EvmEtherTransferLimit {
pub struct EvmBasicGrant {
pub id: i32,
pub wallet_access_id: i32, // references evm_wallet_access.id
pub chain_id: i32,
pub chain_id: ChainId,
pub valid_from: Option<SqliteTimestamp>,
pub valid_until: Option<SqliteTimestamp>,
pub max_gas_fee_per_gas: Option<Vec<u8>>,
@@ -258,7 +378,7 @@ pub struct EvmTransactionLog {
pub id: i32,
pub grant_id: i32,
pub wallet_access_id: i32,
pub chain_id: i32,
pub chain_id: ChainId,
pub eth_value: Vec<u8>,
pub signed_at: SqliteTimestamp,
}
@@ -333,7 +453,7 @@ pub struct EvmTokenTransferLog {
pub id: i32,
pub grant_id: i32,
pub log_id: i32,
pub chain_id: i32,
pub chain_id: ChainId,
pub token_contract: Vec<u8>,
pub recipient_address: Vec<u8>,
pub value: Vec<u8>,
@@ -353,8 +473,73 @@ pub struct IntegrityEnvelope {
pub entity_kind: String,
pub entity_id: Vec<u8>,
pub payload_version: i32,
pub key_version: i32,
pub key_version: RootKeyHistoryId,
pub mac: Vec<u8>,
pub signed_at: SqliteTimestamp,
pub created_at: SqliteTimestamp,
}
#[derive(Debug, Queryable, Selectable, Identifiable)]
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
pub struct Proposal {
pub id: i32,
pub kind: String,
pub payload: Vec<u8>,
pub initiator_id: i32,
pub created_at: SqliteTimestamp,
pub expires_at: SqliteTimestamp,
pub status: ProposalStatus,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::proposal, check_for_backend(Sqlite))]
pub struct NewProposal {
pub kind: String,
pub payload: Vec<u8>,
pub initiator_id: i32,
// status defaults to 'pending' at the DB layer
pub expires_at: SqliteTimestamp,
}
#[derive(Debug, Queryable, Selectable, Identifiable)]
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
pub struct ProposalVote {
pub id: i32,
pub proposal_id: i32,
pub operator_id: i32,
pub approve: bool,
pub signature: Vec<u8>,
pub voted_at: SqliteTimestamp,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::proposal_vote, check_for_backend(Sqlite))]
pub struct NewProposalVote {
pub proposal_id: i32,
pub operator_id: i32,
pub approve: bool,
pub signature: Vec<u8>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::proposal_result, check_for_backend(Sqlite))]
pub struct NewProposalResult {
pub proposal_id: i32,
pub data: Vec<u8>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::recovery_proposal_vote, check_for_backend(Sqlite))]
pub struct NewRecoveryProposalVote {
pub proposal_id: i32,
pub recovery_operator_id: i32,
pub approve: bool,
pub signature: Vec<u8>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = schema::recovery_wakeup_request, check_for_backend(Sqlite))]
pub struct NewRecoveryWakeupRequest {
pub requested_by: i32,
}

View File

@@ -152,6 +152,98 @@ diesel::table! {
}
}
diesel::table! {
operator (id) {
id -> Nullable<Integer>,
share -> Binary,
share_nonce -> Binary,
share_salt -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
operator_identity (id) {
id -> Integer,
public_key -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
proposal (id) {
id -> Integer,
kind -> Text,
payload -> Binary,
initiator_id -> Integer,
created_at -> Integer,
expires_at -> Integer,
status -> Text,
}
}
diesel::table! {
proposal_result (proposal_id) {
proposal_id -> Integer,
data -> Binary,
created_at -> Integer,
}
}
diesel::table! {
recovery_operator (id) {
id -> Integer,
share -> Binary,
share_nonce -> Binary,
share_salt -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
recovery_operator_identity (id) {
id -> Integer,
public_key -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
recovery_wakeup_request (id) {
id -> Integer,
requested_by -> Integer,
requested_at -> Integer,
cancelled_by -> Nullable<Integer>,
cancelled_at -> Nullable<Integer>,
}
}
diesel::table! {
recovery_proposal_vote (id) {
id -> Integer,
proposal_id -> Integer,
recovery_operator_id -> Integer,
approve -> Bool,
signature -> Binary,
voted_at -> Integer,
}
}
diesel::table! {
proposal_vote (id) {
id -> Integer,
proposal_id -> Integer,
operator_id -> Integer,
approve -> Bool,
signature -> Binary,
voted_at -> Integer,
}
}
diesel::table! {
program_client (id) {
id -> Integer,
@@ -185,15 +277,6 @@ diesel::table! {
}
}
diesel::table! {
useragent_client (id) {
id -> Integer,
public_key -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::joinable!(aead_encrypted -> root_key_history (associated_root_key_id));
diesel::joinable!(arbiter_settings -> root_key_history (root_key_id));
diesel::joinable!(arbiter_settings -> tls_history (tls_id));
@@ -212,10 +295,24 @@ diesel::joinable!(evm_transaction_log -> evm_wallet_access (wallet_access_id));
diesel::joinable!(evm_wallet -> aead_encrypted (aead_encrypted_id));
diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id));
diesel::joinable!(evm_wallet_access -> program_client (client_id));
diesel::joinable!(operator -> operator_identity (id));
diesel::joinable!(program_client -> client_metadata (metadata_id));
diesel::joinable!(proposal -> operator_identity (initiator_id));
diesel::joinable!(proposal_result -> proposal (proposal_id));
diesel::joinable!(proposal_vote -> proposal (proposal_id));
diesel::joinable!(proposal_vote -> operator_identity (operator_id));
diesel::joinable!(recovery_operator -> recovery_operator_identity (id));
diesel::joinable!(recovery_proposal_vote -> proposal (proposal_id));
diesel::joinable!(recovery_proposal_vote -> recovery_operator_identity (recovery_operator_id));
diesel::joinable!(recovery_wakeup_request -> operator_identity (requested_by));
diesel::allow_tables_to_appear_in_same_query!(
aead_encrypted,
proposal_result,
recovery_operator,
recovery_operator_identity,
recovery_wakeup_request,
recovery_proposal_vote,
arbiter_settings,
client_metadata,
client_metadata_history,
@@ -230,8 +327,11 @@ diesel::allow_tables_to_appear_in_same_query!(
evm_wallet,
evm_wallet_access,
integrity_envelope,
operator,
operator_identity,
program_client,
proposal,
proposal_vote,
root_key_history,
tls_history,
useragent_client,
);

View File

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

View File

@@ -34,7 +34,7 @@ mod utils;
#[derive(Debug, thiserror::Error)]
pub enum PolicyError {
#[error("Database error")]
Database(#[from] crate::db::DatabaseError),
Database(#[from] DatabaseError),
#[error("Transaction violates policy: {0:?}")]
Violations(Vec<EvalViolation>),
#[error("No matching grant found")]
@@ -66,7 +66,7 @@ pub enum AnalyzeError {
#[derive(Debug, thiserror::Error)]
pub enum ListError {
#[error("Database error")]
Database(#[from] crate::db::DatabaseError),
Database(#[from] DatabaseError),
#[error("Integrity verification failed for grant")]
Integrity(#[from] integrity::Error),
@@ -127,7 +127,7 @@ async fn check_shared_constraints(
.get_result(conn)
.await?;
if count >= rate_limit.count as i64 {
if count >= rate_limit.count.into() {
violations.push(EvalViolation::RateLimitExceeded);
}
}
@@ -179,24 +179,22 @@ impl Engine {
}
if run_kind == RunKind::Execution {
conn.transaction(|conn| {
Box::pin(async move {
let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog {
grant_id: grant.common_settings_id,
wallet_access_id: context.target.id,
chain_id: context.chain as i32,
eth_value: utils::u256_to_bytes(context.value).to_vec(),
signed_at: Utc::now().into(),
})
.returning(evm_transaction_log::id)
.get_result(conn)
.await?;
conn.transaction(async |conn| {
let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog {
grant_id: grant.common_settings_id,
wallet_access_id: context.target.id,
chain_id: context.chain.into(),
eth_value: utils::u256_to_bytes(context.value).to_vec(),
signed_at: Utc::now().into(),
})
.returning(evm_transaction_log::id)
.get_result(&mut *conn)
.await?;
P::record_transaction(&context, meaning, log_id, &grant, conn).await?;
P::record_transaction(&context, meaning, log_id, &grant, &mut *conn).await?;
QueryResult::Ok(())
})
QueryResult::Ok(())
})
.await
.map_err(DatabaseError::from)?;
@@ -207,7 +205,7 @@ impl Engine {
}
impl Engine {
pub fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self {
Self { db, vault }
}
@@ -222,48 +220,52 @@ impl Engine {
let vault = self.vault.clone();
let id = conn
.transaction(|conn| {
Box::pin(async move {
use schema::evm_basic_grant;
.transaction(async |conn| {
use schema::evm_basic_grant;
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant {
chain_id: full_grant.shared.chain as i32,
wallet_access_id: full_grant.shared.wallet_access_id,
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant
.shared
.max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant
.shared
.max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.window.num_seconds() as i32),
revoked_at: None,
})
.returning(evm_basic_grant::all_columns)
.get_result(conn)
.await?;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::as_conversions,
reason = "fixme! #86"
)]
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant {
chain_id: full_grant.shared.chain.into(),
wallet_access_id: full_grant.shared.wallet_access_id,
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant
.shared
.max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant
.shared
.max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant
.shared
.rate_limit
.as_ref()
.map(|rl| rl.window.num_seconds() as i32),
revoked_at: None,
})
.returning(evm_basic_grant::all_columns)
.get_result(&mut *conn)
.await?;
P::create_grant(&basic_grant, &full_grant.specific, conn).await?;
P::create_grant(&basic_grant, &full_grant.specific, &mut *conn).await?;
integrity::sign_entity(conn, &vault, &full_grant, basic_grant.id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
integrity::sign_entity(&mut *conn, &vault, &full_grant, basic_grant.id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
QueryResult::Ok(basic_grant.id)
})
QueryResult::Ok(basic_grant.id)
})
.await?;
@@ -313,7 +315,7 @@ impl Engine {
let TxKind::Call(to) = transaction.to else {
return Err(VetError::ContractCreationNotSupported);
};
let context = policies::EvalContext {
let context = EvalContext {
target,
chain: transaction.chain_id,
to,
@@ -357,7 +359,8 @@ mod tests {
use crate::db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog,
SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
};
@@ -375,7 +378,7 @@ mod tests {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
wallet_id: EvmWalletId::from_raw(5),
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},
@@ -404,10 +407,16 @@ mod tests {
conn: &mut DatabaseConnection,
shared: &SharedGrantSettings,
) -> EvmBasicGrant {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::as_conversions,
reason = "fixme! #86"
)]
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_access_id: shared.wallet_access_id,
chain_id: shared.chain as i32,
chain_id: shared.chain.into(),
valid_from: shared.valid_from.map(SqliteTimestamp),
valid_until: shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: shared
@@ -571,7 +580,7 @@ mod tests {
.values(NewEvmTransactionLog {
grant_id: basic_grant.id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
eth_value: super::utils::u256_to_bytes(U256::ZERO).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})

View File

@@ -1,6 +1,6 @@
use crate::{
crypto::integrity::v1::Integrable,
db::models::{self, EvmBasicGrant, EvmWalletAccess},
db::models::{EvmBasicGrant, EvmWalletAccess},
evm::utils,
};
@@ -85,10 +85,10 @@ pub trait Policy: Sized {
// Create a new grant in the database based on the provided grant details, and return its ID
fn create_grant(
basic: &models::EvmBasicGrant,
basic: &EvmBasicGrant,
grant: &Self::Settings,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> impl std::future::Future<Output = QueryResult<DatabaseID>> + Send;
) -> impl Future<Output = QueryResult<DatabaseID>> + Send;
// Try to find an existing grant that matches the transaction context, and return its details if found
// Additionally, return ID of basic grant for shared-logic checks like rate limits and validity periods
@@ -155,7 +155,7 @@ impl SharedGrantSettings {
pub(crate) fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
Ok(Self {
wallet_access_id: model.wallet_access_id,
chain: model.chain_id as u64, // safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants
chain: model.chain_id.into(),
valid_from: model.valid_from.map(Into::into),
valid_until: model.valid_until.map(Into::into),
max_gas_fee_per_gas: model
@@ -166,10 +166,11 @@ impl SharedGrantSettings {
.max_priority_fee_per_gas
.map(|b| utils::try_bytes_to_u256(&b))
.transpose()?,
#[expect(clippy::cast_sign_loss, clippy::as_conversions, reason = "fixme! #86")]
rate_limit: match (model.rate_limit_count, model.rate_limit_window_secs) {
(Some(count), Some(window_secs)) => Some(TransactionRateLimit {
count: count as u32,
window: Duration::seconds(window_secs as i64),
window: Duration::seconds(window_secs.into()),
}),
_ => None,
},
@@ -179,7 +180,7 @@ impl SharedGrantSettings {
pub async fn query_by_id(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
id: i32,
) -> diesel::result::QueryResult<Self> {
) -> QueryResult<Self> {
use crate::db::schema::evm_basic_grant;
let basic_grant: EvmBasicGrant = evm_basic_grant::table

View File

@@ -7,7 +7,7 @@ use crate::{
},
db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log},
db::{
models::{self, NewEvmEtherTransferGrant, NewEvmEtherTransferGrantTarget},
models::{NewEvmEtherTransferGrant, NewEvmEtherTransferGrantTarget},
schema::{evm_ether_transfer_grant, evm_ether_transfer_grant_target},
},
evm::policies::{
@@ -20,7 +20,6 @@ use crate::{
use alloy::primitives::{Address, U256};
use chrono::{DateTime, Duration, Utc};
use diesel::{
ExpressionMethods, JoinOnDsl,
dsl::{auto_type, insert_into},
prelude::*,
sqlite::Sqlite,
@@ -47,8 +46,8 @@ impl Display for Meaning {
}
}
impl From<Meaning> for SpecificMeaning {
fn from(val: Meaning) -> SpecificMeaning {
SpecificMeaning::EtherTransfer(val)
fn from(val: Meaning) -> Self {
Self::EtherTransfer(val)
}
}
@@ -63,8 +62,8 @@ impl Integrable for Settings {
}
impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant {
SpecificGrant::EtherTransfer(val)
fn from(val: Settings) -> Self {
Self::EtherTransfer(val)
}
}
@@ -75,9 +74,7 @@ async fn query_relevant_past_transaction(
) -> QueryResult<Vec<(U256, DateTime<Utc>)>> {
let past_transactions: Vec<(Vec<u8>, SqliteTimestamp)> = evm_transaction_log::table
.filter(evm_transaction_log::grant_id.eq(grant_id))
.filter(
evm_transaction_log::signed_at.ge(SqliteTimestamp(chrono::Utc::now() - longest_window)),
)
.filter(evm_transaction_log::signed_at.ge(SqliteTimestamp(Utc::now() - longest_window)))
.select((
evm_transaction_log::eth_value,
evm_transaction_log::signed_at,
@@ -104,7 +101,7 @@ async fn check_rate_limits(
let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?;
let window_start = chrono::Utc::now() - grant.settings.specific.limit.window;
let window_start = Utc::now() - grant.settings.specific.limit.window;
let prospective_cumulative_volume: U256 = past_transaction
.iter()
.filter(|(_, timestamp)| timestamp >= &window_start)
@@ -154,10 +151,15 @@ impl Policy for EtherTransfer {
}
async fn create_grant(
basic: &models::EvmBasicGrant,
basic: &EvmBasicGrant,
grant: &Self::Settings,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> diesel::result::QueryResult<DatabaseID> {
) -> QueryResult<DatabaseID> {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #86"
)]
let limit_id: i32 = insert_into(evm_ether_transfer_limit::table)
.values(NewEvmEtherTransferLimit {
window_secs: grant.limit.window.num_seconds() as i32,
@@ -192,7 +194,7 @@ impl Policy for EtherTransfer {
async fn try_find_grant(
context: &EvalContext,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> diesel::result::QueryResult<Option<Grant<Self::Settings>>> {
) -> QueryResult<Option<Grant<Self::Settings>>> {
let target_bytes = context.to.to_vec();
// Find a grant where:
@@ -246,7 +248,7 @@ impl Policy for EtherTransfer {
limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume)
.map_err(|err| diesel::result::Error::DeserializationError(Box::new(err)))?,
window: chrono::Duration::seconds(limit.window_secs as i64),
window: Duration::seconds(limit.window_secs.into()),
},
};
@@ -266,7 +268,7 @@ impl Policy for EtherTransfer {
_log_id: i32,
_grant: &Grant<Self::Settings>,
_conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> diesel::result::QueryResult<()> {
) -> QueryResult<()> {
// Basic log is sufficient
Ok(())
@@ -319,7 +321,7 @@ impl Policy for EtherTransfer {
.map(|(basic, specific)| {
let targets: Vec<Address> = targets_by_grant
.get(&specific.id)
.map(|v| v.as_slice())
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.filter_map(|t| {
@@ -343,7 +345,7 @@ impl Policy for EtherTransfer {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
|e| diesel::result::Error::DeserializationError(Box::new(e)),
)?,
window: Duration::seconds(limit.window_secs as i64),
window: Duration::seconds(limit.window_secs.into()),
},
},
},

View File

@@ -3,7 +3,8 @@ use crate::{
db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog,
SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
},
@@ -22,7 +23,7 @@ use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
const WALLET_ACCESS_ID: i32 = 1;
const CHAIN_ID: u64 = 1;
const CHAIN_ID: alloy::primitives::ChainId = 1;
const ALLOWED: Address = address!("1111111111111111111111111111111111111111");
const OTHER: Address = address!("2222222222222222222222222222222222222222");
@@ -31,7 +32,7 @@ fn ctx(to: Address, value: U256) -> EvalContext {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
wallet_id: EvmWalletId::from_raw(10),
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},
@@ -48,7 +49,7 @@ async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicG
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
@@ -161,7 +162,7 @@ async fn evaluate_passes_when_volume_within_limit() {
.values(NewEvmTransactionLog {
grant_id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
@@ -203,7 +204,7 @@ async fn evaluate_rejects_volume_over_limit() {
.values(NewEvmTransactionLog {
grant_id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
@@ -246,7 +247,7 @@ async fn evaluate_passes_at_exactly_volume_limit() {
.values(NewEvmTransactionLog {
grant_id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})

View File

@@ -27,7 +27,6 @@ use alloy::{
};
use chrono::{DateTime, Duration, Utc};
use diesel::{
ExpressionMethods,
dsl::{auto_type, insert_into},
prelude::*,
sqlite::Sqlite,
@@ -58,8 +57,8 @@ impl std::fmt::Display for Meaning {
}
}
impl From<Meaning> for SpecificMeaning {
fn from(val: Meaning) -> SpecificMeaning {
SpecificMeaning::TokenTransfer(val)
fn from(val: Meaning) -> Self {
Self::TokenTransfer(val)
}
}
@@ -75,8 +74,8 @@ impl Integrable for Settings {
}
impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant {
SpecificGrant::TokenTransfer(val)
fn from(val: Settings) -> Self {
Self::TokenTransfer(val)
}
}
@@ -87,10 +86,7 @@ async fn query_relevant_past_transfers(
) -> QueryResult<Vec<(U256, DateTime<Utc>)>> {
let past_logs: Vec<(Vec<u8>, SqliteTimestamp)> = evm_token_transfer_log::table
.filter(evm_token_transfer_log::grant_id.eq(grant_id))
.filter(
evm_token_transfer_log::created_at
.ge(SqliteTimestamp(chrono::Utc::now() - longest_window)),
)
.filter(evm_token_transfer_log::created_at.ge(SqliteTimestamp(Utc::now() - longest_window)))
.select((
evm_token_transfer_log::value,
evm_token_transfer_log::created_at,
@@ -130,7 +126,7 @@ async fn check_volume_rate_limits(
let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?;
for limit in &grant.settings.specific.volume_limits {
let window_start = chrono::Utc::now() - limit.window;
let window_start = Utc::now() - limit.window;
let prospective_cumulative_volume: U256 = past_transfers
.iter()
.filter(|(_, timestamp)| timestamp >= &window_start)
@@ -206,6 +202,11 @@ impl Policy for TokenTransfer {
.await?;
for limit in &grant.volume_limits {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #86"
)]
insert_into(evm_token_transfer_volume_limit::table)
.values(NewEvmTokenTransferVolumeLimit {
grant_id,
@@ -255,7 +256,7 @@ impl Policy for TokenTransfer {
max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(|err| {
diesel::result::Error::DeserializationError(Box::new(err))
})?,
window: Duration::seconds(row.window_secs as i64),
window: Duration::seconds(row.window_secs.into()),
})
})
.collect::<QueryResult<Vec<_>>>()?;
@@ -305,7 +306,7 @@ impl Policy for TokenTransfer {
.values(NewEvmTokenTransferLog {
grant_id: grant.id,
log_id,
chain_id: context.chain as i32,
chain_id: context.chain.into(),
token_contract: context.to.to_vec(),
recipient_address: meaning.to.to_vec(),
value: utils::u256_to_bytes(meaning.value).to_vec(),
@@ -354,7 +355,7 @@ impl Policy for TokenTransfer {
.map(|(basic, specific)| {
let volume_limits: Vec<VolumeRateLimit> = limits_by_grant
.get(&specific.id)
.map(|v| v.as_slice())
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.map(|row| {
@@ -362,7 +363,7 @@ impl Policy for TokenTransfer {
max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(|e| {
diesel::result::Error::DeserializationError(Box::new(e))
})?,
window: Duration::seconds(row.window_secs as i64),
window: Duration::seconds(row.window_secs.into()),
})
})
.collect::<QueryResult<Vec<_>>>()?;

View File

@@ -2,7 +2,7 @@ use super::{Settings, TokenTransfer};
use crate::{
db::{
self, DatabaseConnection,
models::{EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, SqliteTimestamp},
models::{EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, SqliteTimestamp},
schema::evm_basic_grant,
},
evm::{
@@ -45,7 +45,7 @@ fn ctx(to: Address, calldata: Bytes) -> EvalContext {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
wallet_id: EvmWalletId::from_raw(10),
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},
@@ -62,7 +62,7 @@ async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicG
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
@@ -241,12 +241,11 @@ async fn evaluate_passes_volume_at_exact_limit() {
.unwrap();
// Record a past transfer of 900, with current transfer 100 => exactly 1000 limit
use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log};
insert_into(evm_token_transfer_log::table)
.values(NewEvmTokenTransferLog {
insert_into(db::schema::evm_token_transfer_log::table)
.values(db::models::NewEvmTokenTransferLog {
grant_id,
log_id: 0,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(900u64)).to_vec(),
@@ -286,12 +285,11 @@ async fn evaluate_rejects_volume_over_limit() {
.await
.unwrap();
use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log};
insert_into(evm_token_transfer_log::table)
.values(NewEvmTokenTransferLog {
insert_into(db::schema::evm_token_transfer_log::table)
.values(db::models::NewEvmTokenTransferLog {
grant_id,
log_id: 0,
chain_id: CHAIN_ID as i32,
chain_id: CHAIN_ID.into(),
token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),

View File

@@ -44,7 +44,7 @@ impl std::fmt::Debug for SafeSigner {
/// Returns the protected key bytes and the derived Ethereum address.
pub fn generate(rng: &mut impl rand::Rng) -> (SafeCell<[u8; 32]>, Address) {
loop {
let mut cell = SafeCell::new_inline(|w: &mut [u8; 32]| {
let mut cell = SafeCell::new_inline_default(|w: &mut [u8; 32]| {
rng.fill_bytes(w);
});
@@ -82,8 +82,8 @@ impl SafeSigner {
})
}
#[expect(clippy::significant_drop_tightening, reason = "false positive")]
fn sign_hash_inner(&self, hash: &B256) -> Result<Signature> {
#[allow(clippy::expect_used)]
let mut cell = self.key.lock().expect("SafeSigner mutex poisoned");
let reader = cell.read();
let sig: (ecdsa::Signature, RecoveryId) = reader.sign_prehash(hash.as_ref())?;
@@ -96,7 +96,6 @@ impl SafeSigner {
{
return Err(Error::TransactionChainIdMismatch {
signer: chain_id,
#[allow(clippy::expect_used)]
tx: tx.chain_id().expect("Chain ID is guaranteed to be set"),
});
}

View File

@@ -7,7 +7,7 @@ pub(super) struct LengthError {
pub(super) actual: usize,
}
pub(super) fn u256_to_bytes(value: U256) -> [u8; 32] {
pub const fn u256_to_bytes(value: U256) -> [u8; 32] {
value.to_le_bytes()
}
pub(super) fn bytes_to_u256(bytes: &[u8]) -> Option<U256> {

View File

@@ -98,8 +98,7 @@ pub async fn start(mut conn: ClientConnection, mut bi: GrpcBi<ClientRequest, Cli
Err(err) => {
let _ = bi
.send(Err(Status::unauthenticated(format!(
"Authentication failed: {}",
err
"Authentication failed: {err}",
))))
.await;
warn!(error = ?err, "Client authentication failed");

View File

@@ -1,6 +1,6 @@
use crate::{
grpc::request_tracker::RequestTracker,
peers::client::{self, ClientConnection, auth},
grpc::{Convert, request_tracker::RequestTracker},
peers::client::{ClientConnection, auth},
};
use arbiter_crypto::authn;
use arbiter_proto::{
@@ -32,7 +32,7 @@ pub(super) struct AuthTransportAdapter<'a> {
}
impl<'a> AuthTransportAdapter<'a> {
pub(super) fn new(
pub(super) const fn new(
bi: &'a mut GrpcBi<ClientRequest, ClientResponse>,
request_tracker: &'a mut RequestTracker,
) -> Self {
@@ -42,44 +42,6 @@ impl<'a> AuthTransportAdapter<'a> {
}
}
fn response_to_proto(response: auth::Outbound) -> AuthResponsePayload {
match response {
auth::Outbound::AuthChallenge { challenge } => {
AuthResponsePayload::Challenge(ProtoAuthChallenge {
timestamp_nanos: challenge
.timestamp
.timestamp_nanos_opt()
.expect("timestamp within range")
as u64,
random: challenge.nonce.to_vec(),
})
}
auth::Outbound::AuthSuccess => {
AuthResponsePayload::Result(ProtoAuthResult::Success.into())
}
}
}
fn error_to_proto(error: auth::Error) -> AuthResponsePayload {
AuthResponsePayload::Result(
match error {
auth::Error::InvalidChallengeSolution => ProtoAuthResult::InvalidSignature,
auth::Error::ApproveError(auth::ApproveError::Denied) => {
ProtoAuthResult::ApprovalDenied
}
auth::Error::ApproveError(auth::ApproveError::Upstream(
crate::actors::flow_coordinator::ApprovalError::NoUserAgentsConnected,
)) => ProtoAuthResult::NoUserAgentsOnline,
auth::Error::ApproveError(auth::ApproveError::Internal)
| auth::Error::DatabasePoolUnavailable
| auth::Error::DatabaseOperationFailed
| auth::Error::IntegrityCheckFailed
| auth::Error::Transport => ProtoAuthResult::Internal,
}
.into(),
)
}
async fn send_client_response(
&mut self,
payload: AuthResponsePayload,
@@ -107,8 +69,8 @@ impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {
item: Result<auth::Outbound, auth::Error>,
) -> Result<(), TransportError> {
let payload = match item {
Ok(message) => AuthTransportAdapter::response_to_proto(message),
Err(err) => AuthTransportAdapter::error_to_proto(err),
Ok(message) => message.convert(),
Err(err) => err.convert(),
};
self.send_client_response(payload).await
@@ -171,7 +133,7 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
};
Some(auth::Inbound::AuthChallengeRequest {
pubkey,
metadata: client_metadata_from_proto(client_info),
metadata: client_info.convert(),
})
}
AuthRequestPayload::ChallengeSolution(ProtoAuthChallengeSolution { signature }) => {
@@ -189,11 +151,61 @@ impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
impl Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {}
fn client_metadata_from_proto(metadata: ProtoClientInfo) -> ClientMetadata {
ClientMetadata {
name: metadata.name,
description: metadata.description,
version: metadata.version,
impl Convert for ProtoClientInfo {
type Output = ClientMetadata;
fn convert(self) -> Self::Output {
ClientMetadata {
name: self.name,
description: self.description,
version: self.version,
}
}
}
impl Convert for auth::Error {
type Output = AuthResponsePayload;
fn convert(self) -> Self::Output {
use auth::Error::{
ApproveError, DatabaseOperationFailed, DatabasePoolUnavailable, IntegrityCheckFailed,
InvalidChallengeSolution, Transport,
};
AuthResponsePayload::Result(
match self {
InvalidChallengeSolution => ProtoAuthResult::InvalidSignature,
ApproveError(auth::ApproveError::Denied) => ProtoAuthResult::ApprovalDenied,
ApproveError(auth::ApproveError::Upstream(
crate::actors::flow_coordinator::ApprovalError::NoOperatorsConnected,
)) => ProtoAuthResult::NoOperatorsOnline,
ApproveError(auth::ApproveError::Internal)
| DatabasePoolUnavailable
| DatabaseOperationFailed
| IntegrityCheckFailed
| Transport => ProtoAuthResult::Internal,
}
.into(),
)
}
}
impl Convert for auth::Outbound {
type Output = AuthResponsePayload;
fn convert(self) -> Self::Output {
match self {
Self::AuthChallenge { challenge } => {
AuthResponsePayload::Challenge(ProtoAuthChallenge {
timestamp_nanos: challenge
.timestamp
.timestamp_nanos_opt()
.expect("timestamp within range")
as u64,
random: challenge.nonce.to_vec(),
})
}
Self::AuthSuccess => AuthResponsePayload::Result(ProtoAuthResult::Success.into()),
}
}
}
@@ -203,5 +215,5 @@ pub(super) async fn start(
request_tracker: &mut RequestTracker,
) -> Result<i32, auth::Error> {
let mut transport = AuthTransportAdapter::new(bi, request_tracker);
client::auth::authenticate(conn, &mut transport).await
auth::authenticate(conn, &mut transport).await
}

View File

@@ -23,7 +23,7 @@ use kameo::actor::ActorRef;
use tonic::Status;
use tracing::warn;
fn wrap_response(payload: EvmResponsePayload) -> ClientResponsePayload {
const fn wrap_response(payload: EvmResponsePayload) -> ClientResponsePayload {
ClientResponsePayload::Evm(proto_evm::Response {
payload: Some(payload),
})

View File

@@ -28,7 +28,7 @@ pub(super) async fn dispatch(
};
match payload {
VaultRequestPayload::QueryState(_) => {
VaultRequestPayload::QueryState(()) => {
let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(VaultState::Sealed) => ProtoVaultState::Sealed,

View File

@@ -31,16 +31,16 @@ impl Convert for SpecificMeaning {
fn convert(self) -> Self::Output {
let kind = match self {
SpecificMeaning::EtherTransfer(meaning) => ProtoSpecificMeaningKind::EtherTransfer(
Self::EtherTransfer(meaning) => ProtoSpecificMeaningKind::EtherTransfer(
arbiter_proto::proto::shared::evm::EtherTransferMeaning {
to: meaning.to.to_vec(),
value: u256_to_proto_bytes(meaning.value),
},
),
SpecificMeaning::TokenTransfer(meaning) => ProtoSpecificMeaningKind::TokenTransfer(
Self::TokenTransfer(meaning) => ProtoSpecificMeaningKind::TokenTransfer(
arbiter_proto::proto::shared::evm::TokenTransferMeaning {
token: Some(ProtoTokenInfo {
symbol: meaning.token.symbol.to_string(),
symbol: meaning.token.symbol.to_owned(),
address: meaning.token.contract.to_vec(),
chain_id: meaning.token.chain,
}),
@@ -61,25 +61,21 @@ impl Convert for EvalViolation {
fn convert(self) -> Self::Output {
let kind = match self {
EvalViolation::InvalidTarget { target } => {
Self::InvalidTarget { target } => {
ProtoEvalViolationKind::InvalidTarget(target.to_vec())
}
EvalViolation::GasLimitExceeded {
Self::GasLimitExceeded {
max_gas_fee_per_gas,
max_priority_fee_per_gas,
} => ProtoEvalViolationKind::GasLimitExceeded(GasLimitExceededViolation {
max_gas_fee_per_gas: max_gas_fee_per_gas.map(u256_to_proto_bytes),
max_priority_fee_per_gas: max_priority_fee_per_gas.map(u256_to_proto_bytes),
}),
EvalViolation::RateLimitExceeded => ProtoEvalViolationKind::RateLimitExceeded(()),
EvalViolation::VolumetricLimitExceeded => {
ProtoEvalViolationKind::VolumetricLimitExceeded(())
}
EvalViolation::InvalidTime => ProtoEvalViolationKind::InvalidTime(()),
EvalViolation::InvalidTransactionType => {
ProtoEvalViolationKind::InvalidTransactionType(())
}
EvalViolation::MismatchingChainId { expected, actual } => {
Self::RateLimitExceeded => ProtoEvalViolationKind::RateLimitExceeded(()),
Self::VolumetricLimitExceeded => ProtoEvalViolationKind::VolumetricLimitExceeded(()),
Self::InvalidTime => ProtoEvalViolationKind::InvalidTime(()),
Self::InvalidTransactionType => ProtoEvalViolationKind::InvalidTransactionType(()),
Self::MismatchingChainId { expected, actual } => {
ProtoEvalViolationKind::ChainIdMismatch(proto_eval_violation::ChainIdMismatch {
expected,
actual,
@@ -96,13 +92,13 @@ impl Convert for VetError {
fn convert(self) -> Self::Output {
let kind = match self {
VetError::ContractCreationNotSupported => {
Self::ContractCreationNotSupported => {
ProtoTransactionEvalErrorKind::ContractCreationNotSupported(())
}
VetError::UnsupportedTransactionType => {
Self::UnsupportedTransactionType => {
ProtoTransactionEvalErrorKind::UnsupportedTransactionType(())
}
VetError::Evaluated(meaning, policy_error) => match policy_error {
Self::Evaluated(meaning, policy_error) => match policy_error {
PolicyError::NoMatchingGrant => {
ProtoTransactionEvalErrorKind::NoMatchingGrant(NoMatchingGrantError {
meaning: Some(meaning.convert()),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,10 @@
use crate::{
db::models::{CoreEvmWalletAccess, NewEvmWalletAccess},
db::models::{CoreEvmWalletAccess, EvmWalletId, NewEvmWalletAccess},
evm::policies::{
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer,
token_transfers,
},
grpc::Convert,
grpc::TryConvert,
grpc::{Convert, TryConvert},
};
use arbiter_proto::{
proto::evm::{
@@ -14,7 +13,7 @@ use arbiter_proto::{
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
specific_grant::Grant as ProtoSpecificGrantType,
},
proto::user_agent::sdk_client::{WalletAccess, WalletAccessEntry as SdkClientWalletAccess},
proto::operator::sdk_client::{WalletAccess, WalletAccessEntry as SdkClientWalletAccess},
};
use alloy::primitives::{Address, U256};
@@ -22,11 +21,11 @@ use chrono::{DateTime, TimeZone, Utc};
use prost_types::Timestamp as ProtoTimestamp;
use tonic::Status;
fn address_from_bytes(bytes: Vec<u8>) -> Result<Address, Status> {
fn address_from_bytes(bytes: &[u8]) -> Result<Address, Status> {
if bytes.len() != 20 {
return Err(Status::invalid_argument("Invalid EVM address"));
}
Ok(Address::from_slice(&bytes))
Ok(Address::from_slice(bytes))
}
fn u256_from_proto_bytes(bytes: &[u8]) -> Result<U256, Status> {
@@ -41,7 +40,7 @@ impl TryConvert for ProtoTimestamp {
type Error = Status;
fn try_convert(self) -> Result<DateTime<Utc>, Status> {
Utc.timestamp_opt(self.seconds, self.nanos as u32)
Utc.timestamp_opt(self.seconds, self.nanos.try_into().unwrap_or_default())
.single()
.ok_or_else(|| Status::invalid_argument("Invalid timestamp"))
}
@@ -116,7 +115,8 @@ impl TryConvert for ProtoSpecificGrant {
limit,
})) => Ok(SpecificGrant::EtherTransfer(ether_transfer::Settings {
target: targets
.into_iter()
.iter()
.map(Vec::as_slice)
.map(address_from_bytes)
.collect::<Result<_, _>>()?,
limit: limit
@@ -130,8 +130,10 @@ impl TryConvert for ProtoSpecificGrant {
target,
volume_limits,
})) => Ok(SpecificGrant::TokenTransfer(token_transfers::Settings {
token_contract: address_from_bytes(token_contract)?,
target: target.map(address_from_bytes).transpose()?,
token_contract: address_from_bytes(&token_contract)?,
target: target
.map(|target| address_from_bytes(&target))
.transpose()?,
volume_limits: volume_limits
.into_iter()
.map(ProtoVolumeRateLimit::try_convert)
@@ -147,7 +149,7 @@ impl Convert for WalletAccess {
fn convert(self) -> Self::Output {
NewEvmWalletAccess {
wallet_id: self.wallet_id,
wallet_id: EvmWalletId::from_raw(self.wallet_id),
client_id: self.sdk_client_id,
}
}
@@ -162,7 +164,7 @@ impl TryConvert for SdkClientWalletAccess {
return Err(Status::invalid_argument("Missing wallet access entry"));
};
Ok(CoreEvmWalletAccess {
wallet_id: access.wallet_id,
wallet_id: EvmWalletId::from_raw(access.wallet_id),
client_id: access.sdk_client_id,
id: self.id,
})

View File

@@ -10,7 +10,7 @@ use arbiter_proto::proto::{
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
specific_grant::Grant as ProtoSpecificGrantType,
},
user_agent::sdk_client::{WalletAccess, WalletAccessEntry as ProtoSdkClientWalletAccess},
operator::sdk_client::{WalletAccess, WalletAccessEntry as ProtoSdkClientWalletAccess},
};
use chrono::{DateTime, Utc};
@@ -22,7 +22,7 @@ impl Convert for DateTime<Utc> {
fn convert(self) -> ProtoTimestamp {
ProtoTimestamp {
seconds: self.timestamp(),
nanos: self.timestamp_subsec_nanos() as i32,
nanos: self.timestamp_subsec_nanos().try_into().unwrap_or(i32::MAX),
}
}
}
@@ -74,13 +74,13 @@ impl Convert for SpecificGrant {
fn convert(self) -> ProtoSpecificGrant {
let grant = match self {
SpecificGrant::EtherTransfer(s) => {
Self::EtherTransfer(s) => {
ProtoSpecificGrantType::EtherTransfer(ProtoEtherTransferSettings {
targets: s.target.into_iter().map(|a| a.to_vec()).collect(),
limit: Some(s.limit.convert()),
})
}
SpecificGrant::TokenTransfer(s) => {
Self::TokenTransfer(s) => {
ProtoSpecificGrantType::TokenTransfer(ProtoTokenTransferSettings {
token_contract: s.token_contract.to_vec(),
target: s.target.map(|a| a.to_vec()),
@@ -103,7 +103,7 @@ impl Convert for EvmWalletAccess {
Self::Output {
id: self.id,
access: Some(WalletAccess {
wallet_id: self.wallet_id,
wallet_id: self.wallet_id.to_raw(),
sdk_client_id: self.client_id,
}),
}

View File

@@ -1,8 +1,8 @@
use crate::{
db::models::NewEvmWalletAccess,
grpc::Convert,
peers::user_agent::{
OutOfBand, UserAgentSession,
peers::operator::{
OperatorSession, OutOfBand,
session::handlers::{
HandleGrantEvmWalletAccess, HandleListWalletAccess, HandleNewClientApprove,
HandleRevokeEvmWalletAccess, HandleSdkClientList,
@@ -11,8 +11,8 @@ use crate::{
};
use arbiter_crypto::authn;
use arbiter_proto::proto::{
shared::ClientInfo as ProtoClientMetadata,
user_agent::{
operator::{
operator_response::Payload as OperatorResponsePayload,
sdk_client::{
self as proto_sdk_client, ConnectionCancel as ProtoSdkClientConnectionCancel,
ConnectionRequest as ProtoSdkClientConnectionRequest,
@@ -24,21 +24,21 @@ use arbiter_proto::proto::{
request::Payload as SdkClientRequestPayload,
response::Payload as SdkClientResponsePayload,
},
user_agent_response::Payload as UserAgentResponsePayload,
},
shared::ClientInfo as ProtoClientMetadata,
};
use kameo::actor::ActorRef;
use tonic::Status;
use tracing::{info, warn};
fn wrap_sdk_client_response(payload: SdkClientResponsePayload) -> UserAgentResponsePayload {
UserAgentResponsePayload::SdkClient(proto_sdk_client::Response {
const fn wrap_sdk_client_response(payload: SdkClientResponsePayload) -> OperatorResponsePayload {
OperatorResponsePayload::SdkClient(proto_sdk_client::Response {
payload: Some(payload),
})
}
pub(super) fn out_of_band_payload(oob: OutOfBand) -> UserAgentResponsePayload {
pub(super) fn out_of_band_payload(oob: OutOfBand) -> OperatorResponsePayload {
match oob {
OutOfBand::ClientConnectionRequest { profile } => wrap_sdk_client_response(
SdkClientResponsePayload::ConnectionRequest(ProtoSdkClientConnectionRequest {
@@ -59,9 +59,9 @@ pub(super) fn out_of_band_payload(oob: OutOfBand) -> UserAgentResponsePayload {
}
pub(super) async fn dispatch(
actor: &ActorRef<UserAgentSession>,
actor: &ActorRef<OperatorSession>,
req: proto_sdk_client::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> {
) -> Result<Option<OperatorResponsePayload>, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument(
"Missing SDK client request payload",
@@ -75,23 +75,23 @@ pub(super) async fn dispatch(
SdkClientRequestPayload::Revoke(_) => Err(Status::unimplemented(
"SdkClientRevoke is not yet implemented",
)),
SdkClientRequestPayload::List(_) => handle_list(actor).await,
SdkClientRequestPayload::List(()) => handle_list(actor).await,
SdkClientRequestPayload::GrantWalletAccess(req) => {
handle_grant_wallet_access(actor, req).await
}
SdkClientRequestPayload::RevokeWalletAccess(req) => {
handle_revoke_wallet_access(actor, req).await
}
SdkClientRequestPayload::ListWalletAccess(_) => handle_list_wallet_access(actor).await,
SdkClientRequestPayload::ListWalletAccess(()) => handle_list_wallet_access(actor).await,
}
}
async fn handle_connection_response(
actor: &ActorRef<UserAgentSession>,
actor: &ActorRef<OperatorSession>,
resp: ProtoSdkClientConnectionResponse,
) -> Result<Option<UserAgentResponsePayload>, Status> {
) -> Result<Option<OperatorResponsePayload>, Status> {
let pubkey = authn::PublicKey::try_from(resp.pubkey.as_slice())
.map_err(|_| Status::invalid_argument("Invalid ML-DSA public key"))?;
.map_err(|()| Status::invalid_argument("Invalid ML-DSA public key"))?;
actor
.ask(HandleNewClientApprove {
@@ -108,20 +108,25 @@ async fn handle_connection_response(
}
async fn handle_list(
actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> {
actor: &ActorRef<OperatorSession>,
) -> Result<Option<OperatorResponsePayload>, Status> {
let result = match actor.ask(HandleSdkClientList {}).await {
Ok(clients) => ProtoSdkClientListResult::Clients(ProtoSdkClientList {
clients: clients
.into_iter()
.map(|(client, metadata)| ProtoSdkClientEntry {
id: client.id,
pubkey: client.public_key.to_vec(),
id: client.id.to_raw(),
pubkey: client.public_key.clone(),
info: Some(ProtoClientMetadata {
name: metadata.name,
description: metadata.description,
version: metadata.version,
}),
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #84"
)]
created_at: client.created_at.0.timestamp() as i32,
})
.collect(),
@@ -139,10 +144,10 @@ async fn handle_list(
}
async fn handle_grant_wallet_access(
actor: &ActorRef<UserAgentSession>,
actor: &ActorRef<OperatorSession>,
req: ProtoSdkClientGrantWalletAccess,
) -> Result<Option<UserAgentResponsePayload>, Status> {
let entries: Vec<NewEvmWalletAccess> = req.accesses.into_iter().map(|a| a.convert()).collect();
) -> Result<Option<OperatorResponsePayload>, Status> {
let entries: Vec<NewEvmWalletAccess> = req.accesses.into_iter().map(Convert::convert).collect();
match actor.ask(HandleGrantEvmWalletAccess { entries }).await {
Ok(()) => {
info!("Successfully granted wallet access");
@@ -156,9 +161,9 @@ async fn handle_grant_wallet_access(
}
async fn handle_revoke_wallet_access(
actor: &ActorRef<UserAgentSession>,
actor: &ActorRef<OperatorSession>,
req: ProtoSdkClientRevokeWalletAccess,
) -> Result<Option<UserAgentResponsePayload>, Status> {
) -> Result<Option<OperatorResponsePayload>, Status> {
match actor
.ask(HandleRevokeEvmWalletAccess {
entries: req.accesses,
@@ -177,12 +182,12 @@ async fn handle_revoke_wallet_access(
}
async fn handle_list_wallet_access(
actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> {
actor: &ActorRef<OperatorSession>,
) -> Result<Option<OperatorResponsePayload>, Status> {
match actor.ask(HandleListWalletAccess {}).await {
Ok(accesses) => Ok(Some(wrap_sdk_client_response(
SdkClientResponsePayload::ListWalletAccess(ListWalletAccessResponse {
accesses: accesses.into_iter().map(|a| a.convert()).collect(),
accesses: accesses.into_iter().map(Convert::convert).collect(),
}),
))),
Err(err) => {

View File

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

View File

@@ -1,7 +1,7 @@
use super::auth::AuthTransportAdapter;
use crate::{
grpc::TryConvert,
peers::user_agent::vault_gate::{self as vault_gate},
peers::operator::vault_gate::{self as vault_gate},
};
use arbiter_proto::transport::{Bi, Error as TransportError, Receiver, Sender};
@@ -15,37 +15,35 @@ mod outbound;
#[async_trait]
impl Receiver<vault_gate::Inbound> for AuthTransportAdapter<'_> {
async fn recv(&mut self) -> Option<vault_gate::Inbound> {
loop {
let request = match self.bi_mut().recv().await? {
Ok(request) => request,
Err(error) => {
warn!(
?error,
"Failed to receive user agent request during vault gate"
);
return None;
}
};
if let Err(err) = self.tracker_mut().request(request.id) {
let _ = self.bi_mut().send(Err(err)).await;
let request = match self.bi_mut().recv().await? {
Ok(request) => request,
Err(error) => {
warn!(
?error,
"Failed to receive operator request during vault gate"
);
return None;
}
};
let Some(payload) = request.payload else {
let _ = self
.bi_mut()
.send(Err(Status::invalid_argument("Missing request payload")))
.await;
return None;
};
if let Err(err) = self.tracker_mut().request(request.id) {
let _ = self.bi_mut().send(Err(err)).await;
return None;
}
match payload.try_convert() {
Ok(inbound) => return Some(inbound),
Err(status) => {
let _ = self.bi_mut().send(Err(status)).await;
return None;
}
let Some(payload) = request.payload else {
let _ = self
.bi_mut()
.send(Err(Status::invalid_argument("Missing request payload")))
.await;
return None;
};
match payload.try_convert() {
Ok(inbound) => Some(inbound),
Err(status) => {
let _ = self.bi_mut().send(Err(status)).await;
None
}
}
}

View File

@@ -1,14 +1,17 @@
use crate::{
grpc::{Convert, TryConvert},
peers::user_agent::vault_gate::{
self as vault_gate, HandleBootstrapEncryptedKey, HandleHandshake, HandleUnsealEncryptedKey,
peers::operator::vault_gate::{
self as vault_gate, HandleBootstrapEncryptedKey, HandleContributeBootstrapPassphrase,
HandleContributeRecoveryBootstrapPassphrase, HandleContributeRecoveryUnsealPassphrase,
HandleContributeUnsealPassphrase, HandleDeclareCommittee, HandleHandshake,
HandleUnsealEncryptedKey,
},
};
use arbiter_proto::proto::user_agent::{
user_agent_request::Payload as UserAgentRequestPayload,
use arbiter_proto::proto::operator::{
operator_request::Payload as OperatorRequestPayload,
vault::{
self as proto_vault,
bootstrap::{self as proto_bootstrap},
bootstrap::{self as proto_bootstrap, request::Payload as BootstrapRequestPayload},
request::Payload as VaultRequestPayload,
unseal::{self as proto_unseal, request::Payload as UnsealRequestPayload},
},
@@ -16,13 +19,13 @@ use arbiter_proto::proto::user_agent::{
use tonic::Status;
impl TryConvert for UserAgentRequestPayload {
impl TryConvert for OperatorRequestPayload {
type Output = vault_gate::Inbound;
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
match self {
UserAgentRequestPayload::Vault(req) => req.try_convert(),
Self::Vault(req) => req.try_convert(),
_ => Err(Status::permission_denied(
"Only vault operations are permitted before unsealing",
)),
@@ -47,9 +50,12 @@ impl TryConvert for VaultRequestPayload {
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
match self {
VaultRequestPayload::QueryState(_) => Ok(vault_gate::Inbound::HandleVaultState),
VaultRequestPayload::Unseal(req) => req.try_convert(),
VaultRequestPayload::Bootstrap(req) => req.try_convert(),
Self::QueryState(()) => Ok(vault_gate::Inbound::HandleVaultState),
Self::Unseal(req) => req.try_convert(),
Self::Bootstrap(req) => req.try_convert(),
Self::Rekey(_) => Err(Status::permission_denied(
"Rekey requires an authenticated session",
)),
}
}
}
@@ -71,8 +77,23 @@ impl TryConvert for UnsealRequestPayload {
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
match self {
UnsealRequestPayload::Start(start) => start.try_convert(),
UnsealRequestPayload::EncryptedKey(key) => Ok(key.convert()),
Self::Start(start) => start.try_convert(),
Self::EncryptedKey(key) => Ok(key.convert()),
Self::ContributePassphrase(cp) => Ok(
vault_gate::Inbound::HandleContributeUnsealPassphrase(
HandleContributeUnsealPassphrase {
passphrase: cp.passphrase,
},
),
),
Self::ContributeRecoveryPassphrase(crp) => Ok(
vault_gate::Inbound::HandleContributeRecoveryUnsealPassphrase(
HandleContributeRecoveryUnsealPassphrase {
recovery_operator_id: crp.recovery_operator_id,
passphrase: crp.passphrase,
},
),
),
}
}
}
@@ -107,12 +128,44 @@ impl TryConvert for proto_bootstrap::Request {
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
self.encrypted_key
.ok_or_else(|| Status::invalid_argument("Missing bootstrap encrypted key"))?
self.payload
.ok_or_else(|| Status::invalid_argument("Missing bootstrap payload"))?
.try_convert()
}
}
impl TryConvert for BootstrapRequestPayload {
type Output = vault_gate::Inbound;
type Error = Status;
fn try_convert(self) -> Result<vault_gate::Inbound, Status> {
match self {
Self::EncryptedKey(key) => key.try_convert(),
Self::DeclareCommittee(dc) => Ok(
vault_gate::Inbound::HandleDeclareCommittee(HandleDeclareCommittee {
count: dc.count as usize,
recovery_count: dc.recovery_count as usize,
}),
),
Self::ContributePassphrase(cp) => Ok(
vault_gate::Inbound::HandleContributeBootstrapPassphrase(
HandleContributeBootstrapPassphrase {
passphrase: cp.passphrase,
},
),
),
Self::ContributeRecoveryPassphrase(crp) => Ok(
vault_gate::Inbound::HandleContributeRecoveryBootstrapPassphrase(
HandleContributeRecoveryBootstrapPassphrase {
recovery_operator_id: crp.recovery_operator_id,
passphrase: crp.passphrase,
},
),
),
}
}
}
impl TryConvert for proto_bootstrap::BootstrapEncryptedKey {
type Output = vault_gate::Inbound;
type Error = Status;

View File

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

View File

@@ -20,7 +20,7 @@ impl RequestTracker {
// This is used to set the response id for auth responses, which need to match the request id of the auth challenge request.
// -1 offset is needed because request() increments the next_request_id after returning the current request id.
pub(super) fn current_request_id(&self) -> i32 {
pub(super) const fn current_request_id(&self) -> i32 {
self.next_request_id - 1
}
}

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
#![forbid(unsafe_code)]
use crate::context::ServerContext;
pub mod actors;
@@ -15,7 +14,7 @@ pub struct Server {
}
impl Server {
pub fn new(context: ServerContext) -> Self {
pub const fn new(context: ServerContext) -> Self {
Self { context }
}
}

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