6 Commits

Author SHA1 Message Date
CleverWild
64a07e0ed6 docs(service): clarify ACL setup requirements for service and interactive user access
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
ci/woodpecker/pr/useragent-analyze Pipeline failed
2026-04-03 01:54:25 +02:00
CleverWild
f245a6575d fix(service): change service start type from OnDemand to AutoStart 2026-04-03 01:49:37 +02:00
CleverWild
e3050bc5ff refactor(server): inline runtime.rs in the root module 2026-04-03 01:45:09 +02:00
CleverWild
d593eedf01 housekeeping(cli): move DEFAULT_SERVER_PORT upper to exports scope 2026-04-03 01:37:12 +02:00
CleverWild
2fb5bb3d84 refactor(server): extract shared runtime and implement service install/run in arbiter-server.exe
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-04-02 18:31:05 +02:00
CleverWild
86052c9350 chore: bump mise deps 2026-03-29 19:07:12 +02:00
307 changed files with 10549 additions and 27702 deletions

2
.gitignore vendored
View File

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

View File

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

View File

@@ -6,7 +6,7 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t
Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of: Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of:
- **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies - **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies
- **`operator/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf - **`useragent/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
- **`protobufs/`** — Protocol Buffer definitions shared between server and client - **`protobufs/`** — Protocol Buffer definitions shared between server and client
The vault never exposes key material; it only produces signatures when requests satisfy configured policies. The vault never exposes key material; it only produces signatures when requests satisfy configured policies.
@@ -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-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-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation |
| `arbiter-operator` | Rust client library for the operator side of the gRPC protocol | | `arbiter-useragent` | Rust client library for the user agent side of the gRPC protocol |
| `arbiter-client` | Rust client library for SDK clients | | `arbiter-client` | Rust client library for SDK clients |
### Common Commands ### Common Commands
@@ -66,11 +66,11 @@ cargo insta review
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`: 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. - **`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. - **`KeyHolder`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
- **`FlowCoordinator`** — Coordinates cross-connection flow between operators and SDK clients. - **`FlowCoordinator`** — Coordinates cross-connection flow between user agents and SDK clients.
- **`EvmActor`** — Handles EVM transaction policy enforcement and signing. - **`EvmActor`** — Handles EVM transaction policy enforcement and signing.
Per-connection actors live under `actors/operator/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules. Per-connection actors live under `actors/user_agent/` and `actors/client/`, each with `auth` (challenge-response authentication) and `session` (post-auth operations) sub-modules.
**Database:** SQLite via `diesel-async` + `bb8` connection pool. Schema managed by embedded Diesel migrations in `crates/arbiter-server/migrations/`. DB file lives at `~/.arbiter/arbiter.sqlite`. Tests use a temp-file DB via `db::create_test_pool()`. **Database:** SQLite via `diesel-async` + `bb8` connection pool. Schema managed by embedded Diesel migrations in `crates/arbiter-server/migrations/`. DB file lives at `~/.arbiter/arbiter.sqlite`. Tests use a temp-file DB via `db::create_test_pool()`.
@@ -100,41 +100,20 @@ diesel migration generate <name> --migration-dir crates/arbiter-server/migration
diesel migration run --migration-dir crates/arbiter-server/migrations diesel migration run --migration-dir crates/arbiter-server/migrations
``` ```
### Code Conventions ## User Agent (Flutter + Rinf at `useragent/`)
**`#[must_use]` Attribute:** 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.
Apply the `#[must_use]` attribute to return types of functions where the return value is critical and should not be accidentally ignored. This is commonly used for:
- Methods that return `bool` indicating success/failure or validation state Communication between Dart and Rust uses typed **signals** defined in `useragent/native/hub/src/signals/`. After modifying signal structs, regenerate Dart bindings:
- 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 ```sh
cd operator && rinf gen cd useragent && rinf gen
``` ```
### Common Commands ### Common Commands
```sh ```sh
cd operator cd useragent
# Run the app (macOS or Windows) # Run the app (macOS or Windows)
flutter run flutter run
@@ -146,4 +125,4 @@ rinf gen
flutter analyze flutter analyze
``` ```
The Rinf Rust entry point is `operator/native/hub/src/lib.rs`. It spawns actors defined in `operator/native/hub/src/actors/` which handle Dart↔server communication via signals. The Rinf Rust entry point is `useragent/native/hub/src/lib.rs`. It spawns actors defined in `useragent/native/hub/src/actors/` which handle Dart↔server communication via signals.

155
ARCHITECTURE.md Normal file
View File

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

129
CLAUDE.md
View File

@@ -1 +1,128 @@
Refer to @AGENTS.md for instructions. # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Arbiter is a **permissioned signing service** for cryptocurrency wallets. It consists of:
- **`server/`** — Rust gRPC daemon that holds encrypted keys and enforces policies
- **`useragent/`** — Flutter desktop app (macOS/Windows) with a Rust backend via Rinf
- **`protobufs/`** — Protocol Buffer definitions shared between server and client
The vault never exposes key material; it only produces signatures when requests satisfy configured policies.
## Toolchain Setup
Tools are managed via [mise](https://mise.jdx.dev/). Install all required tools:
```sh
mise install
```
Key versions: Rust 1.93.0 (with clippy), Flutter 3.38.9-stable, protoc 29.6, diesel_cli 2.3.6 (sqlite).
## Server (Rust workspace at `server/`)
### Crates
| Crate | Purpose |
|---|---|
| `arbiter-proto` | Generated gRPC stubs + protobuf types; compiled from `protobufs/*.proto` via `tonic-prost-build` |
| `arbiter-server` | Main daemon — actors, DB, EVM policy engine, gRPC service implementation |
| `arbiter-useragent` | Rust client library for the user agent side of the gRPC protocol |
| `arbiter-client` | Rust client library for SDK clients |
### Common Commands
```sh
cd server
# Build
cargo build
# Run the server daemon
cargo run -p arbiter-server
# Run all tests (preferred over cargo test)
cargo nextest run
# Run a single test
cargo nextest run <test_name>
# Lint
cargo clippy
# Security audit
cargo audit
# Check unused dependencies
cargo shear
# Run snapshot tests and update snapshots
cargo insta review
```
### Architecture
The server is actor-based using the **kameo** crate. All long-lived state lives in `GlobalActors`:
- **`Bootstrapper`** — Manages the one-time bootstrap token written to `~/.arbiter/bootstrap_token` on first run.
- **`KeyHolder`** — Holds the encrypted root key and manages the Sealed/Unsealed vault state machine. On unseal, decrypts the root key into a `memsafe` hardened memory cell.
- **`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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,28 +0,0 @@
syntax = "proto3";
package arbiter.operator;
import "operator/auth.proto";
import "operator/evm.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;
}
}
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;
}
}

View File

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

View File

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

View File

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

View File

@@ -1,24 +0,0 @@
syntax = "proto3";
package arbiter.operator.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,37 +0,0 @@
syntax = "proto3";
package arbiter.operator.vault.unseal;
message UnsealStart {
bytes client_pubkey = 1;
}
message UnsealStartResponse {
bytes server_pubkey = 1;
}
message UnsealEncryptedKey {
bytes nonce = 1;
bytes ciphertext = 2;
bytes associated_data = 3;
}
enum UnsealResult {
UNSEAL_RESULT_UNSPECIFIED = 0;
UNSEAL_RESULT_SUCCESS = 1;
UNSEAL_RESULT_INVALID_KEY = 2;
UNSEAL_RESULT_UNBOOTSTRAPPED = 3;
}
message Request {
oneof payload {
UnsealStart start = 1;
UnsealEncryptedKey encrypted_key = 2;
}
}
message Response {
oneof payload {
UnsealStartResponse start = 1;
UnsealResult result = 2;
}
}

View File

@@ -1,24 +0,0 @@
syntax = "proto3";
package arbiter.operator.vault;
import "google/protobuf/empty.proto";
import "shared/vault.proto";
import "operator/vault/bootstrap.proto";
import "operator/vault/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

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

View File

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

View File

@@ -1,11 +0,0 @@
syntax = "proto3";
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;
}

199
protobufs/user_agent.proto Normal file
View File

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

View File

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

2
server/.gitignore vendored
View File

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

1288
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,170 +4,42 @@ members = [
] ]
resolver = "3" resolver = "3"
[workspace.lints.clippy]
disallowed-methods = "deny"
[workspace.dependencies] [workspace.dependencies]
alloy = "2.0.0" tonic = { version = "0.14.5", features = [
async-trait = "0.1.89" "deflate",
base64 = "0.22.1" "gzip",
chrono = { version = "0.4.44", features = ["serde"] } "tls-connect-info",
ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] } "zstd",
futures = "0.3.32" ] }
k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
kameo = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
kameo_actors = {git = "https://github.com/hdbg/kameo.git", rev = "805b417"}
hmac = "0.13.0"
miette = { version = "7.6.0", features = ["fancy", "serde"] }
ml-dsa = { version = "0.1.0-rc.8", 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 }
rsa = { version = "0.9", features = ["sha2"] }
rstest = "0.26.1"
rustls = { version = "0.23.38", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
rustls-pki-types = "1.14.0"
sha2 = "0.11"
smlang = "0.8.0"
spki = "0.8"
thiserror = "2.0.18"
tokio = { version = "1.52.1", features = ["full"] }
tokio-stream = { version = "0.1.18", features = ["full"] }
tonic = { version = "0.14.5", features = [ "deflate", "gzip", "tls-connect-info", "zstd" ] }
tracing = "0.1.44" tracing = "0.1.44"
tokio = { version = "1.50.0", features = ["full"] }
ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] }
chrono = { version = "0.4.44", features = ["serde"] }
rand = "0.10.0"
rustls = { version = "0.23.37", features = ["aws-lc-rs"] }
smlang = "0.8.0"
miette = { version = "7.6.0", features = ["fancy", "serde"] }
thiserror = "2.0.18"
async-trait = "0.1.89"
futures = "0.3.32"
tokio-stream = { version = "0.1.18", features = ["full"] }
kameo = "0.19.2"
prost-types = { version = "0.14.3", features = ["chrono"] }
x25519-dalek = { version = "2.0.1", features = ["getrandom"] } x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
rstest = "0.26.1"
[workspace.lints.rust] rustls-pki-types = "1.14.0"
missing_unsafe_on_extern = "deny" alloy = "1.7.3"
unsafe_attr_outside_unsafe = "deny" rcgen = { version = "0.14.7", features = [
unsafe_op_in_unsafe_fn = "deny" "aws_lc_rs",
unstable_features = "deny" "pem",
"x509-parser",
deprecated_safe_2024 = "warn" "zeroize",
ffi_unwind_calls = "warn" ], default-features = false }
linker_messages = "warn" k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
rsa = { version = "0.9", features = ["sha2"] }
elided_lifetimes_in_paths = "warn" sha2 = "0.10"
explicit_outlives_requirements = "warn" spki = "0.7"
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"
integer_division = "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,22 +7,3 @@ disallowed-methods = [
{ path = "rsa::traits::Decryptor::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). This blocks decrypt() on rsa::{pkcs1v15,oaep}::DecryptingKey." }, { path = "rsa::traits::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." }, { 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

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
use arbiter_client::ArbiterClient;
use arbiter_proto::{ClientMetadata, url::ArbiterUrl};
use std::io::{self, Write}; use std::io::{self, Write};
use arbiter_client::ArbiterClient;
use arbiter_proto::{ClientMetadata, url::ArbiterUrl};
use tonic::ConnectError;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
println!("Testing connection to Arbiter server..."); println!("Testing connection to Arbiter server...");
@@ -21,6 +23,8 @@ async fn main() {
return; return;
} }
let url = match ArbiterUrl::try_from(input) { let url = match ArbiterUrl::try_from(input) {
Ok(url) => url, Ok(url) => url,
Err(err) => { Err(err) => {
@@ -29,16 +33,16 @@ async fn main() {
} }
}; };
println!("{url:#?}"); println!("{:#?}", url);
let metadata = ClientMetadata { let metadata = ClientMetadata {
name: "arbiter-client test_connect".to_owned(), name: "arbiter-client test_connect".to_string(),
description: Some("Manual connection smoke test".to_owned()), description: Some("Manual connection smoke test".to_string()),
version: Some(env!("CARGO_PKG_VERSION").to_owned()), version: Some(env!("CARGO_PKG_VERSION").to_string()),
}; };
match ArbiterClient::connect(url, metadata).await { match ArbiterClient::connect(url, metadata).await {
Ok(_) => println!("Connected and authenticated successfully."), Ok(_) => println!("Connected and authenticated successfully."),
Err(err) => eprintln!("Failed to connect: {err:#?}"), Err(err) => eprintln!("Failed to connect: {:#?}", err),
} }
} }

View File

@@ -1,55 +1,45 @@
#[cfg(feature = "evm")] use arbiter_proto::{ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl};
use crate::wallets::evm::ArbiterEvmWallet;
use crate::{
StorageError,
auth::{AuthError, authenticate},
storage::{FileSigningKeyStorage, SigningKeyStorage},
transport::{BUFFER_LENGTH, ClientTransport},
};
use arbiter_crypto::authn::SigningKey;
use arbiter_proto::{
ClientMetadata, proto::arbiter_service_client::ArbiterServiceClient, url::ArbiterUrl,
};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{Mutex, mpsc}; use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tonic::transport::ClientTlsConfig; use tonic::transport::ClientTlsConfig;
use crate::{
StorageError, auth::{AuthError, authenticate}, storage::{FileSigningKeyStorage, SigningKeyStorage}, transport::{BUFFER_LENGTH, ClientTransport}
};
#[cfg(feature = "evm")]
use crate::wallets::evm::ArbiterEvmWallet;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ArbiterClientError { pub enum Error {
#[error("Authentication error")] #[error("gRPC error")]
Authentication(#[from] AuthError), Grpc(#[from] tonic::Status),
#[error("Could not establish connection")] #[error("Could not establish connection")]
Connection(#[from] tonic::transport::Error), Connection(#[from] tonic::transport::Error),
#[error("gRPC error")] #[error("Invalid server URI")]
Grpc(#[from] tonic::Status), InvalidUri(#[from] http::uri::InvalidUri),
#[error("Invalid CA certificate")] #[error("Invalid CA certificate")]
InvalidCaCert(#[from] webpki::Error), InvalidCaCert(#[from] webpki::Error),
#[error("Invalid server URI")] #[error("Authentication error")]
InvalidUri(#[from] http::uri::InvalidUri), Authentication(#[from] AuthError),
#[error("Storage error")] #[error("Storage error")]
Storage(#[from] StorageError), Storage(#[from] StorageError),
} }
pub struct ArbiterClient { pub struct ArbiterClient {
#[expect( #[allow(dead_code)]
dead_code,
reason = "transport will be used in future methods for sending requests and receiving responses"
)]
transport: Arc<Mutex<ClientTransport>>, transport: Arc<Mutex<ClientTransport>>,
} }
impl ArbiterClient { impl ArbiterClient {
pub async fn connect( pub async fn connect(url: ArbiterUrl, metadata: ClientMetadata) -> Result<Self, Error> {
url: ArbiterUrl,
metadata: ClientMetadata,
) -> Result<Self, ArbiterClientError> {
let storage = FileSigningKeyStorage::from_default_location()?; let storage = FileSigningKeyStorage::from_default_location()?;
Self::connect_with_storage(url, metadata, &storage).await Self::connect_with_storage(url, metadata, &storage).await
} }
@@ -58,7 +48,7 @@ impl ArbiterClient {
url: ArbiterUrl, url: ArbiterUrl,
metadata: ClientMetadata, metadata: ClientMetadata,
storage: &S, storage: &S,
) -> Result<Self, ArbiterClientError> { ) -> Result<Self, Error> {
let key = storage.load_or_create()?; let key = storage.load_or_create()?;
Self::connect_with_key(url, metadata, key).await Self::connect_with_key(url, metadata, key).await
} }
@@ -66,16 +56,15 @@ impl ArbiterClient {
pub async fn connect_with_key( pub async fn connect_with_key(
url: ArbiterUrl, url: ArbiterUrl,
metadata: ClientMetadata, metadata: ClientMetadata,
key: SigningKey, key: ed25519_dalek::SigningKey,
) -> Result<Self, ArbiterClientError> { ) -> Result<Self, Error> {
let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned(); let anchor = webpki::anchor_from_trusted_cert(&url.ca_cert)?.to_owned();
let tls = ClientTlsConfig::new().trust_anchor(anchor); let tls = ClientTlsConfig::new().trust_anchor(anchor);
let channel = let channel = tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))?
tonic::transport::Channel::from_shared(format!("https://{}:{}", url.host, url.port))? .tls_config(tls)?
.tls_config(tls)? .connect()
.connect() .await?;
.await?;
let mut client = ArbiterServiceClient::new(channel); let mut client = ArbiterServiceClient::new(channel);
let (tx, rx) = mpsc::channel(BUFFER_LENGTH); let (tx, rx) = mpsc::channel(BUFFER_LENGTH);
@@ -94,8 +83,7 @@ impl ArbiterClient {
} }
#[cfg(feature = "evm")] #[cfg(feature = "evm")]
#[expect(clippy::unused_async, reason = "false positive")] pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, Error> {
pub async fn evm_wallets(&self) -> Result<Vec<ArbiterEvmWallet>, ArbiterClientError> {
todo!("fetch EVM wallet list from server") todo!("fetch EVM wallet list from server")
} }
} }

View File

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

View File

@@ -1,19 +1,17 @@
use arbiter_crypto::authn::SigningKey;
use arbiter_proto::home_path; use arbiter_proto::home_path;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum StorageError { pub enum StorageError {
#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")]
InvalidKeyLength { expected: usize, actual: usize },
#[error("I/O error")] #[error("I/O error")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("Invalid signing key length in storage: expected {expected} bytes, got {actual} bytes")]
InvalidKeyLength { expected: usize, actual: usize },
} }
pub trait SigningKeyStorage { pub trait SigningKeyStorage {
fn load_or_create(&self) -> Result<SigningKey, StorageError>; fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError>;
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -22,17 +20,17 @@ pub struct FileSigningKeyStorage {
} }
impl FileSigningKeyStorage { impl FileSigningKeyStorage {
pub const DEFAULT_FILE_NAME: &str = "sdk_client_ml_dsa.key"; pub const DEFAULT_FILE_NAME: &str = "sdk_client_ed25519.key";
pub fn new(path: impl Into<PathBuf>) -> Self { pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() } Self { path: path.into() }
} }
pub fn from_default_location() -> Result<Self, StorageError> { pub fn from_default_location() -> std::result::Result<Self, StorageError> {
Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME))) Ok(Self::new(home_path()?.join(Self::DEFAULT_FILE_NAME)))
} }
fn read_key(path: &Path) -> Result<SigningKey, StorageError> { fn read_key(path: &Path) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> {
let bytes = std::fs::read(path)?; let bytes = std::fs::read(path)?;
let raw: [u8; 32] = let raw: [u8; 32] =
bytes bytes
@@ -41,12 +39,12 @@ impl FileSigningKeyStorage {
expected: 32, expected: 32,
actual: v.len(), actual: v.len(),
})?; })?;
Ok(SigningKey::from_seed(raw)) Ok(ed25519_dalek::SigningKey::from_bytes(&raw))
} }
} }
impl SigningKeyStorage for FileSigningKeyStorage { impl SigningKeyStorage for FileSigningKeyStorage {
fn load_or_create(&self) -> Result<SigningKey, StorageError> { fn load_or_create(&self) -> std::result::Result<ed25519_dalek::SigningKey, StorageError> {
if let Some(parent) = self.path.parent() { if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
@@ -55,8 +53,8 @@ impl SigningKeyStorage for FileSigningKeyStorage {
return Self::read_key(&self.path); return Self::read_key(&self.path);
} }
let key = SigningKey::generate(); let key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let raw_key = key.to_seed(); let raw_key = key.to_bytes();
// Use create_new to prevent accidental overwrite if another process creates the key first. // Use create_new to prevent accidental overwrite if another process creates the key first.
match std::fs::OpenOptions::new() match std::fs::OpenOptions::new()
@@ -105,7 +103,7 @@ mod tests {
.load_or_create() .load_or_create()
.expect("second load_or_create should read same key"); .expect("second load_or_create should read same key");
assert_eq!(key_a.to_seed(), key_b.to_seed()); assert_eq!(key_a.to_bytes(), key_b.to_bytes());
assert!(path.exists()); assert!(path.exists());
std::fs::remove_file(path).expect("temp key file should be removable"); std::fs::remove_file(path).expect("temp key file should be removable");
@@ -126,7 +124,7 @@ mod tests {
assert_eq!(expected, 32); assert_eq!(expected, 32);
assert_eq!(actual, 31); assert_eq!(actual, 31);
} }
other @ StorageError::Io(_) => panic!("unexpected error: {other:?}"), other => panic!("unexpected error: {other:?}"),
} }
std::fs::remove_file(path).expect("temp key file should be removable"); std::fs::remove_file(path).expect("temp key file should be removable");

View File

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

View File

@@ -1,21 +1,3 @@
use crate::transport::{ClientTransport, next_request_id};
use arbiter_proto::proto::{
client::{
ClientRequest,
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
evm::{
self as proto_evm, request::Payload as EvmRequestPayload,
response::Payload as EvmResponsePayload,
},
},
evm::{
EvmSignTransactionRequest,
evm_sign_transaction_response::Result as EvmSignTransactionResult,
},
shared::evm::TransactionEvalError,
};
use alloy::{ use alloy::{
consensus::SignableTransaction, consensus::SignableTransaction,
network::TxSigner, network::TxSigner,
@@ -26,30 +8,7 @@ use async_trait::async_trait;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
/// A typed error payload returned by [`ArbiterEvmWallet`] transaction signing. use crate::transport::ClientTransport;
///
/// This is wrapped into `alloy::signers::Error::Other`, so consumers can downcast by [`TryFrom`] and
/// interpret the concrete policy evaluation failure instead of parsing strings.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ArbiterEvmSignTransactionError {
#[error("transaction rejected by policy: {0:?}")]
PolicyEval(TransactionEvalError),
}
impl<'a> TryFrom<&'a Error> for &'a ArbiterEvmSignTransactionError {
type Error = ();
fn try_from(value: &'a Error) -> Result<Self, Self::Error> {
if let Error::Other(inner) = value
&& let Some(eval_error) = inner.downcast_ref()
{
Ok(eval_error)
} else {
Err(())
}
}
}
pub struct ArbiterEvmWallet { pub struct ArbiterEvmWallet {
transport: Arc<Mutex<ClientTransport>>, transport: Arc<Mutex<ClientTransport>>,
@@ -58,11 +17,7 @@ pub struct ArbiterEvmWallet {
} }
impl ArbiterEvmWallet { impl ArbiterEvmWallet {
#[expect( pub(crate) fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
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 { Self {
transport, transport,
address, address,
@@ -70,12 +25,11 @@ impl ArbiterEvmWallet {
} }
} }
pub const fn address(&self) -> Address { pub fn address(&self) -> Address {
self.address self.address
} }
#[must_use] pub fn with_chain_id(mut self, chain_id: ChainId) -> Self {
pub const fn with_chain_id(mut self, chain_id: ChainId) -> Self {
self.chain_id = Some(chain_id); self.chain_id = Some(chain_id);
self self
} }
@@ -125,73 +79,11 @@ impl TxSigner<Signature> for ArbiterEvmWallet {
&self, &self,
tx: &mut dyn SignableTransaction<Signature>, tx: &mut dyn SignableTransaction<Signature>,
) -> Result<Signature> { ) -> Result<Signature> {
let _transport = self.transport.lock().await;
self.validate_chain_id(tx)?; self.validate_chain_id(tx)?;
let mut transport = self.transport.lock().await; Err(Error::other(
let request_id = next_request_id(); "transaction signing is not supported by current arbiter.client protocol",
let rlp_transaction = tx.encoded_for_signing(); ))
transport
.send(ClientRequest {
request_id,
payload: Some(ClientRequestPayload::Evm(proto_evm::Request {
payload: Some(EvmRequestPayload::SignTransaction(
EvmSignTransactionRequest {
wallet_address: self.address.to_vec(),
rlp_transaction,
},
)),
})),
})
.await
.map_err(|_| Error::other("failed to send evm sign transaction request"))?;
let response = transport
.recv()
.await
.map_err(|_| Error::other("failed to receive evm sign transaction response"))?;
drop(transport);
if response.request_id != Some(request_id) {
return Err(Error::other(
"received mismatched response id for evm sign transaction",
));
}
let payload = response
.payload
.ok_or_else(|| Error::other("missing evm sign transaction response payload"))?;
let ClientResponsePayload::Evm(proto_evm::Response {
payload: Some(payload),
}) = payload
else {
return Err(Error::other(
"unexpected response payload for evm sign transaction request",
));
};
let EvmResponsePayload::SignTransaction(response) = payload else {
return Err(Error::other(
"unexpected evm response payload for sign transaction request",
));
};
let result = response
.result
.ok_or_else(|| Error::other("missing evm sign transaction result"))?;
match result {
EvmSignTransactionResult::Signature(signature) => {
Signature::try_from(signature.as_slice())
.map_err(|_| Error::other("invalid signature returned by server"))
}
EvmSignTransactionResult::EvalError(eval_error) => Err(Error::other(
ArbiterEvmSignTransactionError::PolicyEval(eval_error),
)),
EvmSignTransactionResult::Error(code) => Err(Error::other(format!(
"server failed to sign transaction with error code {code}"
))),
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,13 +11,13 @@ tokio.workspace = true
futures.workspace = true futures.workspace = true
hex = "0.4.3" hex = "0.4.3"
tonic-prost = "0.14.5" tonic-prost = "0.14.5"
prost.workspace = true prost = "0.14.3"
kameo.workspace = true kameo.workspace = true
url = "2.5.8" url = "2.5.8"
miette.workspace = true miette.workspace = true
thiserror.workspace = true thiserror.workspace = true
rustls-pki-types.workspace = true rustls-pki-types.workspace = true
base64.workspace = true base64 = "0.22.1"
prost-types.workspace = true prost-types.workspace = true
tracing.workspace = true tracing.workspace = true
async-trait.workspace = true async-trait.workspace = true

View File

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

View File

@@ -1,59 +1,21 @@
pub mod transport; pub mod transport;
pub mod url; pub mod url;
use base64::{Engine, prelude::BASE64_STANDARD};
use std::{
path::PathBuf,
sync::{LazyLock, RwLock},
};
pub mod proto { pub mod proto {
tonic::include_proto!("arbiter"); tonic::include_proto!("arbiter");
pub mod shared { pub mod user_agent {
tonic::include_proto!("arbiter.shared"); tonic::include_proto!("arbiter.user_agent");
pub mod evm {
tonic::include_proto!("arbiter.shared.evm");
}
}
pub mod operator {
tonic::include_proto!("arbiter.operator");
pub mod auth {
tonic::include_proto!("arbiter.operator.auth");
}
pub mod evm {
tonic::include_proto!("arbiter.operator.evm");
}
pub mod sdk_client {
tonic::include_proto!("arbiter.operator.sdk_client");
}
pub mod vault {
tonic::include_proto!("arbiter.operator.vault");
pub mod bootstrap {
tonic::include_proto!("arbiter.operator.vault.bootstrap");
}
pub mod unseal {
tonic::include_proto!("arbiter.operator.vault.unseal");
}
}
} }
pub mod client { pub mod client {
tonic::include_proto!("arbiter.client"); tonic::include_proto!("arbiter.client");
pub mod auth {
tonic::include_proto!("arbiter.client.auth");
}
pub mod evm {
tonic::include_proto!("arbiter.client.evm");
}
pub mod vault {
tonic::include_proto!("arbiter.client.vault");
}
} }
pub mod evm { pub mod evm {
@@ -69,8 +31,27 @@ pub struct ClientMetadata {
} }
pub static BOOTSTRAP_PATH: &str = "bootstrap_token"; pub static BOOTSTRAP_PATH: &str = "bootstrap_token";
pub const DEFAULT_SERVER_PORT: u16 = 50051;
static HOME_OVERRIDE: LazyLock<RwLock<Option<PathBuf>>> = LazyLock::new(|| RwLock::new(None));
pub fn set_home_path_override(path: Option<PathBuf>) -> Result<(), std::io::Error> {
let mut lock = HOME_OVERRIDE
.write()
.map_err(|_| std::io::Error::other("home path override lock poisoned"))?;
*lock = path;
Ok(())
}
pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> { pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> {
if let Some(path) = HOME_OVERRIDE
.read()
.map_err(|_| std::io::Error::other("home path override lock poisoned"))?
.clone()
{
std::fs::create_dir_all(&path)?;
return Ok(path);
}
static ARBITER_HOME: &str = ".arbiter"; static ARBITER_HOME: &str = ".arbiter";
let home_dir = std::env::home_dir().ok_or(std::io::Error::new( let home_dir = std::env::home_dir().ok_or(std::io::Error::new(
std::io::ErrorKind::PermissionDenied, std::io::ErrorKind::PermissionDenied,
@@ -82,3 +63,8 @@ pub fn home_path() -> Result<std::path::PathBuf, std::io::Error> {
Ok(arbiter_home) Ok(arbiter_home)
} }
pub fn format_challenge(nonce: i32, pubkey: &[u8]) -> Vec<u8> {
let concat_form = format!("{}:{}", nonce, BASE64_STANDARD.encode(pubkey));
concat_form.into_bytes()
}

View File

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

View File

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

View File

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

View File

@@ -16,9 +16,8 @@ diesel-async = { version = "0.8.0", features = [
"sqlite", "sqlite",
"tokio", "tokio",
] } ] }
ed25519-dalek.workspace = true
arbiter-proto.path = "../arbiter-proto" arbiter-proto.path = "../arbiter-proto"
arbiter-crypto.path = "../arbiter-crypto"
arbiter-macros.path = "../arbiter-macros"
tracing.workspace = true tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tonic.workspace = true tonic.workspace = true
@@ -26,6 +25,7 @@ tonic.features = ["tls-aws-lc"]
tokio.workspace = true tokio.workspace = true
rustls.workspace = true rustls.workspace = true
smlang.workspace = true smlang.workspace = true
miette.workspace = true
thiserror.workspace = true thiserror.workspace = true
fatality = "0.1.1" fatality = "0.1.1"
diesel_migrations = { version = "2.3.1", features = ["sqlite"] } diesel_migrations = { version = "2.3.1", features = ["sqlite"] }
@@ -37,32 +37,27 @@ dashmap = "6.1.0"
rand.workspace = true rand.workspace = true
rcgen.workspace = true rcgen.workspace = true
chrono.workspace = true chrono.workspace = true
memsafe = "0.4.0"
zeroize = { version = "1.8.2", features = ["std", "simd"] } zeroize = { version = "1.8.2", features = ["std", "simd"] }
kameo.workspace = true kameo.workspace = true
x25519-dalek.workspace = true
chacha20poly1305 = { version = "0.10.1", features = ["std"] } chacha20poly1305 = { version = "0.10.1", features = ["std"] }
argon2 = { version = "0.5.3", features = ["zeroize"] } argon2 = { version = "0.5.3", features = ["zeroize"] }
restructed = "0.2.2" restructed = "0.2.2"
strum = { version = "0.28.0", features = ["derive"] } strum = { version = "0.28.0", features = ["derive"] }
pem = "3.0.6" pem = "3.0.6"
k256.workspace = true
rsa.workspace = true
sha2.workspace = true sha2.workspace = true
hmac.workspace = true
spki.workspace = true spki.workspace = true
alloy.workspace = true alloy.workspace = true
prost-types.workspace = true prost-types.workspace = true
prost.workspace = true
arbiter-tokens-registry.path = "../arbiter-tokens-registry" arbiter-tokens-registry.path = "../arbiter-tokens-registry"
anyhow = "1.0.102" clap = { version = "4.6", features = ["derive"] }
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
[dev-dependencies] [dev-dependencies]
insta = "1.47.2" insta = "1.46.3"
proptest = "1.11.0"
rstest.workspace = true
test-log = { version = "0.2", default-features = false, features = ["trace"] } test-log = { version = "0.2", default-features = false, features = ["trace"] }
[target.'cfg(windows)'.dependencies]
windows-service = "0.8"

View File

@@ -43,13 +43,15 @@ create table if not exists arbiter_settings (
insert into arbiter_settings (id) values (1) on conflict do nothing; insert into arbiter_settings (id) values (1) on conflict do nothing;
-- ensure singleton row exists -- ensure singleton row exists
create table if not exists operator_client ( create table if not exists useragent_client (
id integer not null primary key, id integer not null primary key,
nonce integer not null default(1), -- used for auth challenge
public_key blob not null, public_key blob not null,
key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1)
created_at integer not null default(unixepoch ('now')), created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now')) updated_at integer not null default(unixepoch ('now'))
) STRICT; ) STRICT;
create unique index if not exists uniq_operator_client_public_key on operator_client (public_key); create unique index if not exists uniq_useragent_client_public_key on useragent_client (public_key, key_type);
create table if not exists client_metadata ( create table if not exists client_metadata (
id integer not null primary key, id integer not null primary key,
@@ -71,6 +73,7 @@ create unique index if not exists uniq_metadata_binding_client on client_metadat
create table if not exists program_client ( create table if not exists program_client (
id integer not null primary key, id integer not null primary key,
nonce integer not null default(1), -- used for auth challenge
public_key blob not null, public_key blob not null,
metadata_id integer not null references client_metadata (id) on delete cascade, metadata_id integer not null references client_metadata (id) on delete cascade,
created_at integer not null default(unixepoch ('now')), created_at integer not null default(unixepoch ('now')),
@@ -188,19 +191,3 @@ create table if not exists evm_ether_transfer_grant_target (
) STRICT; ) STRICT;
create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address); create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address);
-- ===============================
-- Integrity Envelopes
-- ===============================
create table if not exists integrity_envelope (
id integer not null primary key,
entity_kind text not null,
entity_id blob not null,
payload_version integer not null,
key_version integer not null,
mac blob not null, -- 20-byte recipient address
signed_at integer not null default(unixepoch ('now')),
created_at integer not null default(unixepoch ('now'))
) STRICT;
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);

View File

@@ -1,20 +1,19 @@
use crate::db::{self, DatabasePool, schema};
use arbiter_proto::{BOOTSTRAP_PATH, home_path}; use arbiter_proto::{BOOTSTRAP_PATH, home_path};
use diesel::QueryDsl; use diesel::QueryDsl;
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use kameo::{Actor, messages}; use kameo::{Actor, messages};
use miette::Diagnostic;
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng}; use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
use subtle::ConstantTimeEq as _;
use thiserror::Error; use thiserror::Error;
use crate::db::{self, DatabasePool, schema};
const TOKEN_LENGTH: usize = 64; const TOKEN_LENGTH: usize = 64;
pub async fn generate_token() -> Result<String, std::io::Error> { pub async fn generate_token() -> Result<String, std::io::Error> {
let rng: StdRng = make_rng(); let rng: StdRng = make_rng();
let token = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold( let token: String = rng.sample_iter(Alphanumeric).take(TOKEN_LENGTH).fold(
String::default(), Default::default(),
|mut accum, char| { |mut accum, char| {
accum += char.to_string().as_str(); accum += char.to_string().as_str();
accum accum
@@ -26,16 +25,19 @@ pub async fn generate_token() -> Result<String, std::io::Error> {
Ok(token) Ok(token)
} }
#[derive(Error, Debug)] #[derive(Error, Debug, Diagnostic)]
pub enum Error { pub enum Error {
#[error("Database error: {0}")] #[error("Database error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::database))]
Database(#[from] db::PoolError), Database(#[from] db::PoolError),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Database query error: {0}")] #[error("Database query error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::database_query))]
Query(#[from] diesel::result::Error), Query(#[from] diesel::result::Error),
#[error("I/O error: {0}")]
#[diagnostic(code(arbiter_server::bootstrap::io))]
Io(#[from] std::io::Error),
} }
#[derive(Actor)] #[derive(Actor)]
@@ -45,14 +47,14 @@ pub struct Bootstrapper {
impl Bootstrapper { impl Bootstrapper {
pub async fn new(db: &DatabasePool) -> Result<Self, Error> { pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
let row_count: i64 = { let mut conn = db.get().await?;
let mut conn = db.get().await?;
schema::operator_client::table let row_count: i64 = schema::useragent_client::table
.count() .count()
.get_result(&mut conn) .get_result(&mut conn)
.await? .await?;
};
drop(conn);
let token = if row_count == 0 { let token = if row_count == 0 {
let token = generate_token().await?; let token = generate_token().await?;
@@ -69,13 +71,10 @@ impl Bootstrapper {
impl Bootstrapper { impl Bootstrapper {
#[message] #[message]
pub fn is_correct_token(&self, token: String) -> bool { pub fn is_correct_token(&self, token: String) -> bool {
self.token.as_ref().is_some_and(|expected| { match &self.token {
let expected_bytes = expected.as_bytes(); Some(expected) => *expected == token,
let token_bytes = token.as_bytes(); None => false,
}
let choice = expected_bytes.ct_eq(token_bytes);
bool::from(choice)
})
} }
#[message] #[message]

View File

@@ -1,40 +1,34 @@
use super::{ClientConnection, ClientCredentials, ClientProfile};
use crate::{
actors::{
GlobalActors,
flow_coordinator::{self, RequestClientApproval},
vault::Vault,
},
crypto::integrity::{self, AttestationStatus},
db::{
self,
models::{ProgramClientMetadata, SqliteTimestamp},
schema::program_client,
},
};
use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT};
use arbiter_proto::{ use arbiter_proto::{
ClientMetadata, ClientMetadata, format_challenge, transport::{Bi, expect_message}
transport::{Bi, expect_message},
}; };
use chrono::Utc; use chrono::Utc;
use diesel::{ use diesel::{
ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, SelectableHelper as _, ExpressionMethods as _, OptionalExtension as _, QueryDsl as _, SelectableHelper as _,
dsl::insert_into, update, dsl::insert_into, update,
}; };
use diesel_async::RunQueryDsl as _; use diesel_async::RunQueryDsl as _;
use kameo::{actor::ActorRef, error::SendError}; use ed25519_dalek::{Signature, VerifyingKey};
use kameo::error::SendError;
use tracing::error; use tracing::error;
use crate::{
actors::{
client::{ClientConnection, ClientProfile},
flow_coordinator::{self, RequestClientApproval},
},
db::{
self,
models::{ProgramClientMetadata, SqliteTimestamp},
schema::program_client,
},
};
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum Error { pub enum Error {
#[error("Database pool unavailable")] #[error("Database pool unavailable")]
DatabasePoolUnavailable, DatabasePoolUnavailable,
#[error("Database operation failed")] #[error("Database operation failed")]
DatabaseOperationFailed, DatabaseOperationFailed,
#[error("Integrity check failed")]
IntegrityCheckFailed,
#[error("Invalid challenge solution")] #[error("Invalid challenge solution")]
InvalidChallengeSolution, InvalidChallengeSolution,
#[error("Client approval request failed")] #[error("Client approval request failed")]
@@ -43,18 +37,11 @@ pub enum Error {
Transport, Transport,
} }
impl From<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
error!(?e, "Database error");
Self::DatabaseOperationFailed
}
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum ApproveError { pub enum ApproveError {
#[error("Internal error")] #[error("Internal error")]
Internal, Internal,
#[error("Client connection denied by operators")] #[error("Client connection denied by user agents")]
Denied, Denied,
#[error("Upstream error: {0}")] #[error("Upstream error: {0}")]
Upstream(flow_coordinator::ApprovalError), Upstream(flow_coordinator::ApprovalError),
@@ -63,79 +50,74 @@ pub enum ApproveError {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Inbound { pub enum Inbound {
AuthChallengeRequest { AuthChallengeRequest {
pubkey: authn::PublicKey, pubkey: VerifyingKey,
metadata: ClientMetadata, metadata: ClientMetadata,
}, },
AuthChallengeSolution { AuthChallengeSolution {
signature: authn::Signature, signature: Signature,
}, },
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Outbound { pub enum Outbound {
AuthChallenge { challenge: AuthChallenge }, AuthChallenge { pubkey: VerifyingKey, nonce: i32 },
AuthSuccess, AuthSuccess,
} }
async fn get_client_id( pub struct ClientInfo {
pub id: i32,
pub current_nonce: i32,
}
/// Atomically reads and increments the nonce for a known client.
/// Returns `None` if the pubkey is not registered.
async fn get_client_and_nonce(
db: &db::DatabasePool, db: &db::DatabasePool,
pubkey: &authn::PublicKey, pubkey: &VerifyingKey,
) -> Result<Option<i32>, Error> { ) -> Result<Option<ClientInfo>, Error> {
let pubkey_bytes = pubkey.to_bytes(); let pubkey_bytes = pubkey.as_bytes().to_vec();
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable Error::DatabasePoolUnavailable
})?; })?;
program_client::table
.filter(program_client::public_key.eq(&pubkey_bytes)) conn.exclusive_transaction(|conn| {
.select(program_client::id) let pubkey_bytes = pubkey_bytes.clone();
.first::<i32>(&mut conn) Box::pin(async move {
.await let Some((client_id, current_nonce)) = program_client::table
.optional() .filter(program_client::public_key.eq(&pubkey_bytes))
.map_err(|e| { .select((program_client::id, program_client::nonce))
error!(error = ?e, "Database error"); .first::<(i32, i32)>(conn)
Error::DatabaseOperationFailed .await
.optional()?
else {
return Result::<_, diesel::result::Error>::Ok(None);
};
update(program_client::table)
.filter(program_client::public_key.eq(&pubkey_bytes))
.set(program_client::nonce.eq(current_nonce + 1))
.execute(conn)
.await?;
Ok(Some(ClientInfo {
id: client_id,
current_nonce,
}))
}) })
} })
async fn verify_integrity(
db: &db::DatabasePool,
vault: &ActorRef<Vault>,
pubkey: &authn::PublicKey,
) -> Result<(), Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
let id = get_client_id(db, pubkey).await?.ok_or_else(|| {
error!("Client not found during integrity verification");
Error::DatabaseOperationFailed
})?;
let attestation = integrity::verify_entity(
&mut db_conn,
vault,
&ClientCredentials {
pubkey: pubkey.clone(),
},
id,
)
.await .await
.map_err(|e| { .map_err(|e| {
error!(?e, "Integrity verification failed"); error!(error = ?e, "Database error");
Error::IntegrityCheckFailed Error::DatabaseOperationFailed
})?; })
if attestation != AttestationStatus::Attested {
error!("Integrity attestation unavailable for client {id}");
return Err(Error::IntegrityCheckFailed);
}
Ok(())
} }
async fn approve_new_client(actors: &GlobalActors, profile: ClientProfile) -> Result<(), Error> { async fn approve_new_client(
actors: &crate::actors::GlobalActors,
profile: ClientProfile,
) -> Result<(), Error> {
let result = actors let result = actors
.flow_coordinator .flow_coordinator
.ask(RequestClientApproval { client: profile }) .ask(RequestClientApproval { client: profile })
@@ -157,62 +139,45 @@ async fn approve_new_client(actors: &GlobalActors, profile: ClientProfile) -> Re
async fn insert_client( async fn insert_client(
db: &db::DatabasePool, db: &db::DatabasePool,
vault: &ActorRef<Vault>, pubkey: &VerifyingKey,
pubkey: &authn::PublicKey,
metadata: &ClientMetadata, metadata: &ClientMetadata,
) -> Result<i32, Error> { ) -> Result<i32, Error> {
use crate::db::schema::client_metadata; use crate::db::schema::{client_metadata, program_client};
let pubkey = pubkey.clone();
let metadata = metadata.clone();
let mut conn = db.get().await.map_err(|e| { let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable Error::DatabasePoolUnavailable
})?; })?;
conn.exclusive_transaction(|conn| { let metadata_id = insert_into(client_metadata::table)
let vault = vault.clone(); .values((
let pubkey = pubkey.clone(); client_metadata::name.eq(&metadata.name),
Box::pin(async move { client_metadata::description.eq(&metadata.description),
let metadata_id = insert_into(client_metadata::table) client_metadata::version.eq(&metadata.version),
.values(( ))
client_metadata::name.eq(&metadata.name), .returning(client_metadata::id)
client_metadata::description.eq(&metadata.description), .get_result::<i32>(&mut conn)
client_metadata::version.eq(&metadata.version), .await
)) .map_err(|e| {
.returning(client_metadata::id) error!(error = ?e, "Failed to insert client metadata");
.get_result::<i32>(conn) Error::DatabaseOperationFailed
.await?; })?;
let client_id = insert_into(program_client::table) let client_id = insert_into(program_client::table)
.values(( .values((
program_client::public_key.eq(pubkey.to_bytes()), program_client::public_key.eq(pubkey.as_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id), program_client::metadata_id.eq(metadata_id),
)) program_client::nonce.eq(1), // pre-incremented; challenge uses 0
.on_conflict_do_nothing() ))
.returning(program_client::id) .on_conflict_do_nothing()
.get_result::<i32>(conn) .returning(program_client::id)
.await?; .get_result::<i32>(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Failed to insert client metadata");
Error::DatabaseOperationFailed
})?;
integrity::sign_entity( Ok(client_id)
conn,
&vault,
&ClientCredentials {
pubkey: pubkey.clone(),
},
client_id,
)
.await
.map_err(|e| {
error!(error = ?e, "Failed to sign integrity tag for new client key");
Error::DatabaseOperationFailed
})?;
Ok(client_id)
})
})
.await
} }
async fn sync_client_metadata( async fn sync_client_metadata(
@@ -288,16 +253,14 @@ async fn sync_client_metadata(
async fn challenge_client<T>( async fn challenge_client<T>(
transport: &mut T, transport: &mut T,
pubkey: authn::PublicKey, pubkey: VerifyingKey,
challenge: AuthChallenge, nonce: i32,
) -> Result<(), Error> ) -> Result<(), Error>
where where
T: Bi<Inbound, Result<Outbound, Error>> + ?Sized, T: Bi<Inbound, Result<Outbound, Error>> + ?Sized,
{ {
transport transport
.send(Ok(Outbound::AuthChallenge { .send(Ok(Outbound::AuthChallenge { pubkey, nonce }))
challenge: challenge.clone(),
}))
.await .await
.map_err(|e| { .map_err(|e| {
error!(error = ?e, "Failed to send auth challenge"); error!(error = ?e, "Failed to send auth challenge");
@@ -314,15 +277,20 @@ where
Error::Transport Error::Transport
})?; })?;
if !pubkey.verify(&challenge, CLIENT_CONTEXT, &signature) { let formatted = format_challenge(nonce, pubkey.as_bytes());
pubkey.verify_strict(&formatted, &signature).map_err(|_| {
error!("Challenge solution verification failed"); error!("Challenge solution verification failed");
return Err(Error::InvalidChallengeSolution); Error::InvalidChallengeSolution
} })?;
Ok(()) Ok(())
} }
pub async fn authenticate<T>(props: &mut ClientConnection, transport: &mut T) -> Result<i32, Error> pub async fn authenticate<T>(
props: &mut ClientConnection,
transport: &mut T,
) -> Result<VerifyingKey, Error>
where where
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized, T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
{ {
@@ -330,26 +298,29 @@ where
return Err(Error::Transport); return Err(Error::Transport);
}; };
let client_id = if let Some(id) = get_client_id(&props.db, &pubkey).await? { let info = match get_client_and_nonce(&props.db, &pubkey).await? {
verify_integrity(&props.db, &props.actors.vault, &pubkey).await?; Some(nonce) => nonce,
id None => {
} else { approve_new_client(
approve_new_client( &props.actors,
&props.actors, ClientProfile {
ClientProfile { pubkey,
pubkey: pubkey.clone(), metadata: metadata.clone(),
metadata: metadata.clone(), },
}, )
) .await?;
.await?; let client_id = insert_client(&props.db, &pubkey, &metadata).await?;
insert_client(&props.db, &props.actors.vault, &pubkey, &metadata).await? ClientInfo {
id: client_id,
current_nonce: 0,
}
}
}; };
sync_client_metadata(&props.db, client_id, &metadata).await?; sync_client_metadata(&props.db, info.id, &metadata).await?;
let challenge = AuthChallenge::generate(&mut rand::rng());
challenge_client(transport, pubkey, challenge).await?;
challenge_client(transport, pubkey, info.current_nonce).await?;
transport transport
.send(Ok(Outbound::AuthSuccess)) .send(Ok(Outbound::AuthSuccess))
.await .await
@@ -358,5 +329,5 @@ where
Error::Transport Error::Transport
})?; })?;
Ok(client_id) Ok(pubkey)
} }

View File

@@ -1,35 +1,25 @@
use crate::{
actors::GlobalActors, crypto::integrity::Integrable, db, peers::client::session::ClientSession,
};
use arbiter_crypto::authn;
use arbiter_macros::Hashable;
use arbiter_proto::{ClientMetadata, transport::Bi}; use arbiter_proto::{ClientMetadata, transport::Bi};
use kameo::actor::Spawn; use kameo::actor::Spawn;
use tracing::{error, info}; use tracing::{error, info};
use crate::{
actors::{GlobalActors, client::{ session::ClientSession}},
db,
};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ClientProfile { pub struct ClientProfile {
pub pubkey: authn::PublicKey, pub pubkey: ed25519_dalek::VerifyingKey,
pub metadata: ClientMetadata, pub metadata: ClientMetadata,
} }
#[derive(Hashable)]
pub struct ClientCredentials {
pub pubkey: authn::PublicKey,
}
impl Integrable for ClientCredentials {
const KIND: &'static str = "client_credentials";
}
pub struct ClientConnection { pub struct ClientConnection {
pub(crate) db: db::DatabasePool, pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors, pub(crate) actors: GlobalActors,
} }
impl ClientConnection { impl ClientConnection {
pub const fn new(db: db::DatabasePool, actors: GlobalActors) -> Self { pub fn new(db: db::DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors } Self { db, actors }
} }
} }
@@ -41,11 +31,9 @@ pub async fn connect_client<T>(mut props: ClientConnection, transport: &mut T)
where where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized, T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send + ?Sized,
{ {
let fut = auth::authenticate(&mut props, transport); match auth::authenticate(&mut props, transport).await {
println!("authenticate future size: {}", size_of_val(&fut)); Ok(_pubkey) => {
match fut.await { ClientSession::spawn(ClientSession::new(props));
Ok(client_id) => {
ClientSession::spawn(ClientSession::new(props, client_id));
info!("Client authenticated, session started"); info!("Client authenticated, session started");
} }
Err(err) => { Err(err) => {

View File

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

View File

@@ -1,24 +1,4 @@
use crate::{ use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
actors::vault::{CreateNew, Decrypt, Vault},
crypto::integrity,
db::{
DatabaseError, DatabasePool,
models::{self},
schema,
},
evm::{
self, ListError, RunKind,
policies::{
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
},
},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use alloy::{
consensus::TxEip1559, network::TxSignerSync as _, primitives::Address, signers::Signature,
};
use diesel::{ use diesel::{
ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into, ExpressionMethods, OptionalExtension as _, QueryDsl, SelectableHelper as _, dsl::insert_into,
}; };
@@ -26,60 +6,83 @@ use diesel_async::RunQueryDsl;
use kameo::{Actor, actor::ActorRef, messages}; use kameo::{Actor, actor::ActorRef, messages};
use rand::{SeedableRng, rng, rngs::StdRng}; use rand::{SeedableRng, rng, rngs::StdRng};
use crate::{
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
db::{
self, DatabaseError, DatabasePool,
models::{self, SqliteTimestamp},
schema,
},
evm::{
self, RunKind,
policies::{
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
},
},
safe_cell::{SafeCell, SafeCellHandle as _},
};
pub use crate::evm::safe_signer; pub use crate::evm::safe_signer;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum SignTransactionError { pub enum SignTransactionError {
#[error("Wallet not found")] #[error("Wallet not found")]
#[diagnostic(code(arbiter::evm::sign::wallet_not_found))]
WalletNotFound, WalletNotFound,
#[error("Database error: {0}")] #[error("Database error: {0}")]
#[diagnostic(code(arbiter::evm::sign::database))]
Database(#[from] DatabaseError), Database(#[from] DatabaseError),
#[error("Vault error: {0}")] #[error("Keyholder error: {0}")]
Vault(#[from] crate::actors::vault::Error), #[diagnostic(code(arbiter::evm::sign::keyholder))]
Keyholder(#[from] crate::actors::keyholder::Error),
#[error("Vault mailbox error")] #[error("Keyholder mailbox error")]
VaultSend, #[diagnostic(code(arbiter::evm::sign::keyholder_send))]
KeyholderSend,
#[error("Signing error: {0}")] #[error("Signing error: {0}")]
#[diagnostic(code(arbiter::evm::sign::signing))]
Signing(#[from] alloy::signers::Error), Signing(#[from] alloy::signers::Error),
#[error("Policy error: {0}")] #[error("Policy error: {0}")]
#[diagnostic(code(arbiter::evm::sign::vet))]
Vet(#[from] evm::VetError), Vet(#[from] evm::VetError),
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error { pub enum Error {
#[error("Vault error: {0}")] #[error("Keyholder error: {0}")]
Vault(#[from] crate::actors::vault::Error), #[diagnostic(code(arbiter::evm::keyholder))]
Keyholder(#[from] crate::actors::keyholder::Error),
#[error("Vault mailbox error")] #[error("Keyholder mailbox error")]
VaultSend, #[diagnostic(code(arbiter::evm::keyholder_send))]
KeyholderSend,
#[error("Database error: {0}")] #[error("Database error: {0}")]
#[diagnostic(code(arbiter::evm::database))]
Database(#[from] DatabaseError), Database(#[from] DatabaseError),
#[error("Integrity violation: {0}")]
Integrity(#[from] integrity::Error),
} }
#[derive(Actor)] #[derive(Actor)]
pub struct EvmActor { pub struct EvmActor {
pub vault: ActorRef<Vault>, pub keyholder: ActorRef<KeyHolder>,
pub db: DatabasePool, pub db: DatabasePool,
pub rng: StdRng, pub rng: StdRng,
pub engine: evm::Engine, pub engine: evm::Engine,
} }
impl EvmActor { impl EvmActor {
pub fn new(vault: ActorRef<Vault>, db: DatabasePool) -> Self { pub fn new(keyholder: ActorRef<KeyHolder>, db: DatabasePool) -> Self {
// is it safe to seed rng from system once? // is it safe to seed rng from system once?
// todo: audit // todo: audit
let rng = StdRng::from_rng(&mut rng()); let rng = StdRng::from_rng(&mut rng());
let engine = evm::Engine::new(db.clone(), vault.clone()); let engine = evm::Engine::new(db.clone());
Self { Self {
vault, keyholder,
db, db,
rng, rng,
engine, engine,
@@ -96,10 +99,10 @@ impl EvmActor {
let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec())); let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec()));
let aead_id: i32 = self let aead_id: i32 = self
.vault .keyholder
.ask(CreateNew { plaintext }) .ask(CreateNew { plaintext })
.await .await
.map_err(|_| Error::VaultSend)?; .map_err(|_| Error::KeyholderSend)?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet_id = insert_into(schema::evm_wallet::table) let wallet_id = insert_into(schema::evm_wallet::table)
@@ -134,64 +137,50 @@ impl EvmActor {
#[messages] #[messages]
impl EvmActor { impl EvmActor {
#[message] #[message]
pub async fn operator_create_grant( pub async fn useragent_create_grant(
&mut self, &mut self,
basic: SharedGrantSettings, basic: SharedGrantSettings,
grant: SpecificGrant, grant: SpecificGrant,
) -> Result<i32, Error> { ) -> Result<i32, DatabaseError> {
match grant { match grant {
SpecificGrant::EtherTransfer(settings) => self SpecificGrant::EtherTransfer(settings) => {
.engine self.engine
.create_grant::<EtherTransfer>(CombinedSettings { .create_grant::<EtherTransfer>(FullGrant {
shared: basic, basic,
specific: settings, specific: settings,
}) })
.await .await
.map_err(Error::from), }
SpecificGrant::TokenTransfer(settings) => self SpecificGrant::TokenTransfer(settings) => {
.engine self.engine
.create_grant::<TokenTransfer>(CombinedSettings { .create_grant::<TokenTransfer>(FullGrant {
shared: basic, basic,
specific: settings, specific: settings,
}) })
.await .await
.map_err(Error::from), }
} }
} }
#[message] #[message]
#[expect(clippy::unused_async, reason = "reserved for impl")] pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
pub async fn operator_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> { let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
// let mut conn = self.db.get().await.map_err(DatabaseError::from)?; diesel::update(schema::evm_basic_grant::table)
// let vault = self.vault.clone(); .filter(schema::evm_basic_grant::id.eq(grant_id))
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
// diesel_async::AsyncConnection::transaction(&mut conn, |conn| { .execute(&mut conn)
// Box::pin(async move { .await
// diesel::update(schema::evm_basic_grant::table) .map_err(DatabaseError::from)?;
// .filter(schema::evm_basic_grant::id.eq(grant_id)) Ok(())
// .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
// .execute(conn)
// .await?;
// let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?;
// diesel::result::QueryResult::Ok(())
// })
// })
// .await
// .map_err(DatabaseError::from)?;
// Ok(())
todo!()
} }
#[message] #[message]
pub async fn operator_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> { pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.engine.list_all_grants().await { Ok(self
Ok(grants) => Ok(grants), .engine
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)), .list_all_grants()
Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)), .await
} .map_err(DatabaseError::from)?)
} }
#[message] #[message]
@@ -257,12 +246,12 @@ impl EvmActor {
drop(conn); drop(conn);
let raw_key: SafeCell<Vec<u8>> = self let raw_key: SafeCell<Vec<u8>> = self
.vault .keyholder
.ask(Decrypt { .ask(Decrypt {
aead_id: wallet.aead_encrypted_id, aead_id: wallet.aead_encrypted_id,
}) })
.await .await
.map_err(|_| SignTransactionError::VaultSend)?; .map_err(|_| SignTransactionError::KeyholderSend)?;
let signer = safe_signer::SafeSigner::from_cell(raw_key)?; let signer = safe_signer::SafeSigner::from_cell(raw_key)?;
@@ -270,6 +259,7 @@ impl EvmActor {
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution) .evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.await?; .await?;
use alloy::network::TxSignerSync as _;
Ok(signer.sign_transaction_sync(&mut transaction)?) Ok(signer.sign_transaction_sync(&mut transaction)?)
} }
} }

View File

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

View File

@@ -1,10 +1,4 @@
use crate::{ use std::{collections::HashMap, ops::ControlFlow};
actors::{
flow_coordinator::client_connect_approval::ClientApprovalController,
operator_registry::{GetConnected, OperatorRegistry},
},
peers::client::{ClientProfile, session::ClientSession},
};
use kameo::{ use kameo::{
Actor, Actor,
@@ -13,23 +7,20 @@ use kameo::{
prelude::{ActorStopReason, Context, WeakActorRef}, prelude::{ActorStopReason, Context, WeakActorRef},
reply::DelegatedReply, reply::DelegatedReply,
}; };
use std::{collections::HashMap, ops::ControlFlow};
use tracing::info; use tracing::info;
use crate::actors::{
client::{ClientProfile, session::ClientSession},
flow_coordinator::client_connect_approval::ClientApprovalController,
user_agent::session::UserAgentSession,
};
pub mod client_connect_approval; pub mod client_connect_approval;
#[derive(Default)]
pub struct FlowCoordinator { pub struct FlowCoordinator {
pub user_agents: HashMap<ActorId, ActorRef<UserAgentSession>>,
pub clients: HashMap<ActorId, ActorRef<ClientSession>>, pub clients: HashMap<ActorId, ActorRef<ClientSession>>,
operator_registry: ActorRef<OperatorRegistry>,
}
impl FlowCoordinator {
pub fn new(operator_registry: ActorRef<OperatorRegistry>) -> Self {
Self {
clients: HashMap::default(),
operator_registry,
}
}
} }
impl Actor for FlowCoordinator { impl Actor for FlowCoordinator {
@@ -47,7 +38,13 @@ impl Actor for FlowCoordinator {
id: ActorId, id: ActorId,
_: ActorStopReason, _: ActorStopReason,
) -> Result<ControlFlow<ActorStopReason>, Self::Error> { ) -> Result<ControlFlow<ActorStopReason>, Self::Error> {
if self.clients.remove(&id).is_some() { if self.user_agents.remove(&id).is_some() {
info!(
?id,
actor = "FlowCoordinator",
event = "useragent.disconnected"
);
} else if self.clients.remove(&id).is_some() {
info!( info!(
?id, ?id,
actor = "FlowCoordinator", actor = "FlowCoordinator",
@@ -66,12 +63,23 @@ impl Actor for FlowCoordinator {
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)] #[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Hash)]
pub enum ApprovalError { pub enum ApprovalError {
#[error("No operators connected")] #[error("No user agents connected")]
NoOperatorsConnected, NoUserAgentsConnected,
} }
#[messages] #[messages]
impl FlowCoordinator { impl FlowCoordinator {
#[message(ctx)]
pub async fn register_user_agent(
&mut self,
actor: ActorRef<UserAgentSession>,
ctx: &mut Context<Self, ()>,
) {
info!(id = %actor.id(), actor = "FlowCoordinator", event = "useragent.connected");
ctx.actor_ref().link(&actor).await;
self.user_agents.insert(actor.id(), actor);
}
#[message(ctx)] #[message(ctx)]
pub async fn register_client( pub async fn register_client(
&mut self, &mut self,
@@ -93,19 +101,15 @@ impl FlowCoordinator {
unreachable!("Expected `request_client_approval` to have callback channel"); unreachable!("Expected `request_client_approval` to have callback channel");
}; };
let Ok(refs) = self.operator_registry.ask(GetConnected).await else { let refs: Vec<_> = self.user_agents.values().cloned().collect();
reply_sender.send(Err(ApprovalError::NoOperatorsConnected));
return reply;
};
if refs.is_empty() { if refs.is_empty() {
reply_sender.send(Err(ApprovalError::NoOperatorsConnected)); reply_sender.send(Err(ApprovalError::NoUserAgentsConnected));
return reply; return reply;
} }
ClientApprovalController::spawn(client_connect_approval::Args { ClientApprovalController::spawn(client_connect_approval::Args {
client, client,
operators: refs, user_agents: refs,
reply: reply_sender, reply: reply_sender,
}); });

View File

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

View File

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

View File

@@ -1,98 +1,85 @@
use crate::{
crypto::{
KeyCell, derive_key,
encryption::v1::{self, Nonce},
integrity::v1::HmacSha256,
},
db::{
self,
models::{self, RootKeyHistory},
schema::{self},
},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use chrono::Utc; use chrono::Utc;
use diesel::{ use diesel::{
ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper, ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper,
dsl::{insert_into, update}, dsl::{insert_into, update},
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::{KeyInit as _, Mac as _}; use kameo::{Actor, Reply, messages};
use kameo::{Actor, Reply, actor::ActorRef, messages};
use kameo_actors::message_bus::{MessageBus, Publish};
use strum::{EnumDiscriminants, IntoDiscriminant}; use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info}; use tracing::{error, info};
pub mod events { use crate::safe_cell::SafeCell;
use crate::{
db::{
self,
models::{self, RootKeyHistory},
schema::{self},
},
safe_cell::SafeCellHandle as _,
};
use encryption::v1::{self, KeyCell, Nonce};
#[derive(Clone, Copy)] pub mod encryption;
pub struct Bootstrapped;
#[derive(Clone, Copy)]
pub struct Unsealed;
#[derive(Clone, Copy)]
pub struct VaultResealed;
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Vault is already bootstrapped")]
AlreadyBootstrapped,
#[error("Vault is not bootstrapped")]
NotBootstrapped,
#[error("Vault is sealed")]
Sealed,
#[error("Invalid key provided")]
InvalidKey,
#[error("Requested aead entry not found")]
NotFound,
#[error("Encryption error: {0}")]
Encryption(#[from] chacha20poly1305::aead::Error),
#[error("Database error: {0}")]
DatabaseConnection(#[from] db::PoolError),
#[error("Database transaction error: {0}")]
DatabaseTransaction(#[from] diesel::result::Error),
#[error("Broken database")]
BrokenDatabase,
}
struct Unsealed {
root_key_history_id: i32,
root_key: KeyCell,
}
#[derive(Default, EnumDiscriminants)] #[derive(Default, EnumDiscriminants)]
#[strum_discriminants(derive(Reply), vis(pub), name(VaultState))] #[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))]
enum State { enum State {
#[default] #[default]
Unbootstrapped, Unbootstrapped,
Sealed { Sealed {
root_key_history_id: i32, root_key_history_id: i32,
}, },
Unsealed(Unsealed), Unsealed {
root_key_history_id: i32,
root_key: KeyCell,
},
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error {
#[error("Keyholder is already bootstrapped")]
#[diagnostic(code(arbiter::keyholder::already_bootstrapped))]
AlreadyBootstrapped,
#[error("Keyholder is not bootstrapped")]
#[diagnostic(code(arbiter::keyholder::not_bootstrapped))]
NotBootstrapped,
#[error("Invalid key provided")]
#[diagnostic(code(arbiter::keyholder::invalid_key))]
InvalidKey,
#[error("Requested aead entry not found")]
#[diagnostic(code(arbiter::keyholder::aead_not_found))]
NotFound,
#[error("Encryption error: {0}")]
#[diagnostic(code(arbiter::keyholder::encryption_error))]
Encryption(#[from] chacha20poly1305::aead::Error),
#[error("Database error: {0}")]
#[diagnostic(code(arbiter::keyholder::database_error))]
DatabaseConnection(#[from] db::PoolError),
#[error("Database transaction error: {0}")]
#[diagnostic(code(arbiter::keyholder::database_transaction_error))]
DatabaseTransaction(#[from] diesel::result::Error),
#[error("Broken database")]
#[diagnostic(code(arbiter::keyholder::broken_database))]
BrokenDatabase,
} }
/// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed). /// Manages vault root key and tracks current state of the vault (bootstrapped/unbootstrapped, sealed/unsealed).
///
/// Provides API for encrypting and decrypting data using the vault root key. /// Provides API for encrypting and decrypting data using the vault root key.
/// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor. /// Abstraction over database to make sure nonces are never reused and encryption keys are never exposed in plaintext outside of this actor.
#[derive(Actor)] #[derive(Actor)]
pub struct Vault { pub struct KeyHolder {
db: db::DatabasePool, db: db::DatabasePool,
state: State, state: State,
events: ActorRef<MessageBus>,
} }
#[messages] #[messages]
impl Vault { impl KeyHolder {
pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> { pub async fn new(db: db::DatabasePool) -> Result<Self, Error> {
let state = { let state = {
let mut conn = db.get().await?; let mut conn = db.get().await?;
@@ -110,10 +97,10 @@ impl Vault {
} }
}; };
Ok(Self { db, state, events }) Ok(Self { db, state })
} }
// Exclusive transaction to avoid race condtions if multiple vaults write // Exclusive transaction to avoid race condtions if multiple keyholders write
// additional layer of protection against nonce-reuse // additional layer of protection against nonce-reuse
async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> { async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> {
let mut conn = pool.get().await?; let mut conn = pool.get().await?;
@@ -127,13 +114,14 @@ impl Vault {
.first(conn) .first(conn)
.await?; .await?;
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| { let mut nonce =
error!( v1::Nonce::try_from(current_nonce.as_slice()).map_err(|_| {
"Broken database: invalid nonce for root key history id={}", error!(
root_key_id "Broken database: invalid nonce for root key history id={}",
); root_key_id
Error::BrokenDatabase );
})?; Error::BrokenDatabase
})?;
nonce.increment(); nonce.increment();
update(schema::root_key_history::table) update(schema::root_key_history::table)
@@ -150,26 +138,18 @@ impl Vault {
Ok(nonce) Ok(nonce)
} }
const fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
match state {
State::Unsealed(unsealed) => Ok(unsealed),
State::Unbootstrapped => Err(Error::NotBootstrapped),
State::Sealed { .. } => Err(Error::Sealed),
}
}
#[message] #[message]
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> { pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if !matches!(self.state, State::Unbootstrapped) { if !matches!(self.state, State::Unbootstrapped) {
return Err(Error::AlreadyBootstrapped); return Err(Error::AlreadyBootstrapped);
} }
let salt = v1::generate_salt(); let salt = v1::generate_salt();
let mut seal_key = derive_key(seal_key_raw, &salt); let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt);
let mut root_key = KeyCell::new_secure_random(); let mut root_key = KeyCell::new_secure_random();
// Zero nonces are fine because they are one-time // Zero nonces are fine because they are one-time
let root_key_nonce = Nonce::default(); let root_key_nonce = v1::Nonce::default();
let data_encryption_nonce = Nonce::default(); let data_encryption_nonce = v1::Nonce::default();
let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| { let root_key_ciphertext: Vec<u8> = root_key.0.read_inline(|reader| {
let root_key_reader = reader.as_slice(); let root_key_reader = reader.as_slice();
@@ -210,13 +190,12 @@ impl Vault {
}) })
.await?; .await?;
self.state = State::Unsealed(Unsealed { self.state = State::Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
}); };
info!("Vault bootstrapped successfully"); info!("Keyholder bootstrapped successfully");
let _ = self.events.tell(Publish(events::Bootstrapped)).await;
Ok(()) Ok(())
} }
@@ -235,6 +214,7 @@ impl Vault {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
schema::root_key_history::table 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(schema::root_key_history::data_encryption_nonce)
.select(RootKeyHistory::as_select()) .select(RootKeyHistory::as_select())
.first(&mut conn) .first(&mut conn)
.await? .await?
@@ -245,15 +225,16 @@ impl Vault {
error!("Broken database: invalid salt for root key"); error!("Broken database: invalid salt for root key");
Error::BrokenDatabase Error::BrokenDatabase
})?; })?;
let mut seal_key = derive_key(seal_key_raw, &salt); let mut seal_key = v1::derive_seal_key(seal_key_raw, &salt);
let mut root_key = SafeCell::new(current_key.ciphertext.clone()); let mut root_key = SafeCell::new(current_key.ciphertext.clone());
let nonce = let nonce = v1::Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(
Nonce::try_from(current_key.root_key_encryption_nonce.as_slice()).map_err(|()| { |_| {
error!("Broken database: invalid nonce for root key"); error!("Broken database: invalid nonce for root key");
Error::BrokenDatabase Error::BrokenDatabase
})?; },
)?;
seal_key seal_key
.decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key) .decrypt_in_place(&nonce, v1::ROOT_KEY_TAG, &mut root_key)
@@ -262,23 +243,25 @@ impl Vault {
Error::InvalidKey Error::InvalidKey
})?; })?;
self.state = State::Unsealed(Unsealed { self.state = State::Unsealed {
root_key_history_id: current_key.id, root_key_history_id: current_key.id,
root_key: KeyCell::try_from(root_key).map_err(|err| { root_key: v1::KeyCell::try_from(root_key).map_err(|err| {
error!(?err, "Broken database: invalid encryption key size"); error!(?err, "Broken database: invalid encryption key size");
Error::BrokenDatabase Error::BrokenDatabase
})?, })?,
}); };
info!("Vault unsealed successfully"); info!("Keyholder unsealed successfully");
let _ = self.events.tell(Publish(events::Unsealed)).await;
Ok(()) Ok(())
} }
// Decrypts the `aead_encrypted` entry with the given ID and returns the plaintext
#[message] #[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> { pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?; let State::Unsealed { root_key, .. } = &mut self.state else {
return Err(Error::NotBootstrapped);
};
let row: models::AeadEncrypted = { let row: models::AeadEncrypted = {
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
@@ -291,7 +274,7 @@ impl Vault {
.ok_or(Error::NotFound)? .ok_or(Error::NotFound)?
}; };
let nonce = Nonce::try_from(row.current_nonce.as_slice()).map_err(|()| { let nonce = v1::Nonce::try_from(row.current_nonce.as_slice()).map_err(|_| {
error!( error!(
"Broken database: invalid nonce for aead_encrypted id={}", "Broken database: invalid nonce for aead_encrypted id={}",
aead_id aead_id
@@ -306,10 +289,13 @@ impl Vault {
// Creates new `aead_encrypted` entry in the database and returns it's ID // Creates new `aead_encrypted` entry in the database and returns it's ID
#[message] #[message]
pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> { pub async fn create_new(&mut self, mut plaintext: SafeCell<Vec<u8>>) -> Result<i32, Error> {
let Unsealed { let State::Unsealed {
root_key, root_key,
root_key_history_id, root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?; } = &mut self.state
else {
return Err(Error::NotBootstrapped);
};
// Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire // Order matters here - `get_new_nonce` acquires connection, so we need to call it before next acquire
// Borrow checker note: &mut borrow a few lines above is disjoint from this field // Borrow checker note: &mut borrow a few lines above is disjoint from this field
@@ -339,84 +325,41 @@ impl Vault {
} }
#[message] #[message]
pub fn get_state(&self) -> VaultState { pub fn get_state(&self) -> KeyHolderState {
self.state.discriminant() self.state.discriminant()
} }
#[message] #[message]
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> { pub fn seal(&mut self) -> Result<(), Error> {
let Unsealed { let State::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());
hmac.update(&mac_input);
let mac = hmac.finalize().into_bytes().to_vec();
Ok((*root_key_history_id, mac))
}
#[message]
pub fn verify_integrity(
&mut self,
mac_input: Vec<u8>,
expected_mac: Vec<u8>,
key_version: i32,
) -> Result<bool, Error> {
let Unsealed {
root_key,
root_key_history_id,
} = Self::expect_unsealed(&mut self.state)?;
if *root_key_history_id != key_version {
return Ok(false);
}
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
hmac.update(&key_version.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, root_key_history_id,
.. ..
} = Self::expect_unsealed(&mut self.state)?; } = &self.state
else {
return Err(Error::NotBootstrapped);
};
self.state = State::Sealed { self.state = State::Sealed {
root_key_history_id: *root_key_history_id, root_key_history_id: *root_key_history_id,
}; };
let _ = self.events.tell(Publish(events::VaultResealed)).await;
Ok(()) Ok(())
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::actors::GlobalActors; use diesel::SelectableHelper;
use arbiter_crypto::safecell::SafeCellHandle as _;
use diesel_async::RunQueryDsl;
use crate::{
db::{self},
safe_cell::SafeCell,
};
use super::*; use super::*;
async fn bootstrapped_actor(db: &db::DatabasePool) -> Vault { async fn bootstrapped_actor(db: &db::DatabasePool) -> KeyHolder {
let mut actor = Vault::new(db.clone(), GlobalActors::spawn_message_bus()) let mut actor = KeyHolder::new(db.clone()).await.unwrap();
.await
.unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec()); let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.bootstrap(seal_key).await.unwrap(); actor.bootstrap(seal_key).await.unwrap();
actor actor
@@ -428,17 +371,17 @@ mod tests {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut actor = bootstrapped_actor(&db).await; let mut actor = bootstrapped_actor(&db).await;
let root_key_history_id = match actor.state { let root_key_history_id = match actor.state {
State::Unsealed(Unsealed { State::Unsealed {
root_key_history_id, root_key_history_id,
.. ..
}) => root_key_history_id, } => root_key_history_id,
_ => panic!("expected unsealed state"), _ => panic!("expected unsealed state"),
}; };
let n1 = Vault::get_new_nonce(&db, root_key_history_id) let n1 = KeyHolder::get_new_nonce(&db, root_key_history_id)
.await .await
.unwrap(); .unwrap();
let n2 = Vault::get_new_nonce(&db, root_key_history_id) let n2 = KeyHolder::get_new_nonce(&db, root_key_history_id)
.await .await
.unwrap(); .unwrap();
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase"); assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");

View File

@@ -1,59 +1,50 @@
use kameo::actor::{ActorRef, Spawn};
use miette::Diagnostic;
use thiserror::Error;
use crate::{ use crate::{
actors::{ actors::{
bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator, bootstrap::Bootstrapper, evm::EvmActor, flow_coordinator::FlowCoordinator,
operator_registry::OperatorRegistry, vault::Vault, keyholder::KeyHolder,
}, },
db, db,
}; };
use kameo::actor::{ActorRef, Spawn};
use kameo_actors::{DeliveryStrategy, message_bus::MessageBus};
use thiserror::Error;
pub mod bootstrap; pub mod bootstrap;
pub mod evm; pub mod client;
mod evm;
pub mod flow_coordinator; pub mod flow_coordinator;
pub mod operator_registry; pub mod keyholder;
pub mod vault; pub mod user_agent;
#[derive(Error, Debug)] #[derive(Error, Debug, Diagnostic)]
pub enum SpawnError { pub enum SpawnError {
#[error("Failed to spawn Bootstrapper actor")] #[error("Failed to spawn Bootstrapper actor")]
#[diagnostic(code(SpawnError::Bootstrapper))]
Bootstrapper(#[from] bootstrap::Error), Bootstrapper(#[from] bootstrap::Error),
#[error("Failed to spawn Vault actor")] #[error("Failed to spawn KeyHolder actor")]
Vault(#[from] vault::Error), #[diagnostic(code(SpawnError::KeyHolder))]
KeyHolder(#[from] keyholder::Error),
} }
/// Long-lived actors that are shared across all connections and handle global state and operations /// Long-lived actors that are shared across all connections and handle global state and operations
#[derive(Clone)] #[derive(Clone)]
pub struct GlobalActors { pub struct GlobalActors {
pub vault: ActorRef<Vault>, pub key_holder: ActorRef<KeyHolder>,
pub bootstrapper: ActorRef<Bootstrapper>, pub bootstrapper: ActorRef<Bootstrapper>,
pub flow_coordinator: ActorRef<FlowCoordinator>, pub flow_coordinator: ActorRef<FlowCoordinator>,
pub operator_registry: ActorRef<OperatorRegistry>,
pub evm: ActorRef<EvmActor>, pub evm: ActorRef<EvmActor>,
pub events: ActorRef<MessageBus>,
} }
impl GlobalActors { impl GlobalActors {
pub fn spawn_message_bus() -> ActorRef<MessageBus> {
MessageBus::spawn(MessageBus::new(DeliveryStrategy::Guaranteed))
}
pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> { pub async fn spawn(db: db::DatabasePool) -> Result<Self, SpawnError> {
let message_bus = Self::spawn_message_bus(); let key_holder = KeyHolder::spawn(KeyHolder::new(db.clone()).await?);
let key_holder = Vault::spawn(Vault::new(db.clone(), message_bus.clone()).await?);
let operator_registry = OperatorRegistry::spawn(OperatorRegistry::default());
Ok(Self { Ok(Self {
bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?), bootstrapper: Bootstrapper::spawn(Bootstrapper::new(&db).await?),
evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)), evm: EvmActor::spawn(EvmActor::new(key_holder.clone(), db)),
vault: key_holder, key_holder,
flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::new( flow_coordinator: FlowCoordinator::spawn(FlowCoordinator::default()),
operator_registry.clone(),
)),
operator_registry,
events: message_bus,
}) })
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,21 @@
use super::{OutOfBand, OperatorConnection};
use crate::{
actors::{
flow_coordinator::client_connect_approval::ClientApprovalController,
operator_registry::ConnectOperator,
},
peers::client::ClientProfile,
};
use arbiter_crypto::authn;
use arbiter_proto::transport::Sender;
use kameo::{Actor, actor::ActorRef, messages};
use std::{borrow::Cow, collections::HashMap}; use std::{borrow::Cow, collections::HashMap};
use arbiter_proto::transport::Sender;
use async_trait::async_trait;
use ed25519_dalek::VerifyingKey;
use kameo::{Actor, actor::ActorRef, messages};
use thiserror::Error; use thiserror::Error;
use tracing::error; use tracing::error;
use crate::actors::{
client::ClientProfile,
flow_coordinator::{RegisterUserAgent, client_connect_approval::ClientApprovalController},
user_agent::{OutOfBand, UserAgentConnection},
};
mod state;
use state::{DummyContext, UserAgentEvents, UserAgentStateMachine};
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum Error {
#[error("State transition failed")] #[error("State transition failed")]
@@ -45,31 +47,56 @@ impl Error {
} }
pub struct PendingClientApproval { pub struct PendingClientApproval {
pubkey: authn::PublicKey,
controller: ActorRef<ClientApprovalController>, controller: ActorRef<ClientApprovalController>,
} }
pub struct OperatorSession { pub struct UserAgentSession {
props: OperatorConnection, props: UserAgentConnection,
state: UserAgentStateMachine<DummyContext>,
sender: Box<dyn Sender<OutOfBand>>, sender: Box<dyn Sender<OutOfBand>>,
pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>, pending_client_approvals: HashMap<VerifyingKey, PendingClientApproval>,
} }
pub mod handlers; pub mod connection;
impl OperatorSession { impl UserAgentSession {
pub(crate) fn new(props: OperatorConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self { pub(crate) fn new(props: UserAgentConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self {
Self { Self {
props, props,
state: UserAgentStateMachine::new(DummyContext),
sender, sender,
pending_client_approvals: Default::default(), pending_client_approvals: Default::default(),
} }
} }
pub fn new_test(db: crate::db::DatabasePool, actors: crate::actors::GlobalActors) -> Self {
struct DummySender;
#[async_trait]
impl Sender<OutOfBand> for DummySender {
async fn send(
&mut self,
_item: OutOfBand,
) -> Result<(), arbiter_proto::transport::Error> {
Ok(())
}
}
Self::new(UserAgentConnection::new(db, actors), Box::new(DummySender))
}
fn transition(&mut self, event: UserAgentEvents) -> Result<(), Error> {
self.state.process_event(event).map_err(|e| {
error!(?e, "State transition failed");
Error::State
})?;
Ok(())
}
} }
#[messages] #[messages]
impl OperatorSession { impl UserAgentSession {
#[message] #[message]
pub async fn begin_new_client_approval( pub async fn begin_new_client_approval(
&mut self, &mut self,
@@ -85,41 +112,39 @@ impl OperatorSession {
{ {
error!( error!(
?e, ?e,
actor = "operator", actor = "user_agent",
event = "failed to announce new client connection" event = "failed to announce new client connection"
); );
return; return;
} }
self.pending_client_approvals.insert( self.pending_client_approvals
client.pubkey.to_bytes(), .insert(client.pubkey, PendingClientApproval { controller });
PendingClientApproval {
pubkey: client.pubkey,
controller,
},
);
} }
} }
impl Actor for OperatorSession { impl Actor for UserAgentSession {
type Args = Self; type Args = Self;
type Error = Error; type Error = Error;
async fn on_start(args: Self::Args, this: ActorRef<Self>) -> Result<Self, Self::Error> { async fn on_start(
args: Self::Args,
this: kameo::prelude::ActorRef<Self>,
) -> Result<Self, Self::Error> {
args.props args.props
.actors .actors
.operator_registry .flow_coordinator
.ask(ConnectOperator { .ask(RegisterUserAgent {
actor: this.clone(), actor: this.clone(),
}) })
.await .await
.map_err(|err| { .map_err(|err| {
error!( error!(
?err, ?err,
"Failed to register operator connection with operator registry" "Failed to register user agent connection with flow coordinator"
); );
Error::internal("Failed to register operator connection with operator registry") Error::internal("Failed to register user agent connection with flow coordinator")
})?; })?;
Ok(args) Ok(args)
} }
@@ -133,23 +158,19 @@ impl Actor for OperatorSession {
let cancelled_pubkey = self let cancelled_pubkey = self
.pending_client_approvals .pending_client_approvals
.iter() .iter()
.find_map(|(k, v)| (v.controller.id() == id).then_some(k.clone())); .find_map(|(k, v)| (v.controller.id() == id).then_some(*k));
if let Some(pubkey_bytes) = cancelled_pubkey { if let Some(pubkey) = cancelled_pubkey {
let Some(approval) = self.pending_client_approvals.remove(&pubkey_bytes) else { self.pending_client_approvals.remove(&pubkey);
return Ok(std::ops::ControlFlow::Continue(()));
};
if let Err(e) = self if let Err(e) = self
.sender .sender
.send(OutOfBand::ClientConnectionCancel { .send(OutOfBand::ClientConnectionCancel { pubkey })
pubkey: approval.pubkey,
})
.await .await
{ {
error!( error!(
?e, ?e,
actor = "operator", actor = "user_agent",
event = "failed to announce client connection cancellation" event = "failed to announce client connection cancellation"
); );
} }

View File

@@ -0,0 +1,473 @@
use std::sync::Mutex;
use alloy::primitives::Address;
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::sql_types::ops::Add;
use diesel::{BoolExpressionMethods as _, ExpressionMethods as _, QueryDsl as _, SelectableHelper};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::error::SendError;
use kameo::prelude::Context;
use kameo::{message, messages};
use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
use crate::actors::keyholder::KeyHolderState;
use crate::actors::user_agent::session::Error;
use crate::db::models::{
CoreEvmWalletAccess, EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
};
use crate::db::schema::evm_wallet_access;
use crate::evm::policies::{Grant, SpecificGrant};
use crate::safe_cell::SafeCell;
use crate::{
actors::{
evm::{
Generate, ListWallets, UseragentCreateGrant, UseragentDeleteGrant, UseragentListGrants,
},
keyholder::{self, Bootstrap, TryUnseal},
user_agent::session::{
UserAgentSession,
state::{UnsealContext, UserAgentEvents, UserAgentStates},
},
},
safe_cell::SafeCellHandle as _,
};
impl UserAgentSession {
fn take_unseal_secret(&mut self) -> Result<(EphemeralSecret, PublicKey), Error> {
let UserAgentStates::WaitingForUnsealKey(unseal_context) = self.state.state() else {
error!("Received encrypted key in invalid state");
return Err(Error::internal("Invalid state for unseal encrypted key"));
};
let ephemeral_secret = {
#[allow(
clippy::unwrap_used,
reason = "Mutex poison is unrecoverable and should panic"
)]
let mut secret_lock = unseal_context.secret.lock().unwrap();
let secret = secret_lock.take();
match secret {
Some(secret) => secret,
None => {
drop(secret_lock);
error!("Ephemeral secret already taken");
return Err(Error::internal("Ephemeral secret already taken"));
}
}
};
Ok((ephemeral_secret, unseal_context.client_public_key))
}
fn decrypt_client_key_material(
ephemeral_secret: EphemeralSecret,
client_public_key: PublicKey,
nonce: &[u8],
ciphertext: &[u8],
associated_data: &[u8],
) -> Result<SafeCell<Vec<u8>>, ()> {
let nonce = XNonce::from_slice(nonce);
let shared_secret = ephemeral_secret.diffie_hellman(&client_public_key);
let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into());
let mut key_buffer = SafeCell::new(ciphertext.to_vec());
let decryption_result = key_buffer.write_inline(|write_handle| {
cipher.decrypt_in_place(nonce, associated_data, write_handle)
});
match decryption_result {
Ok(_) => Ok(key_buffer),
Err(err) => {
error!(?err, "Failed to decrypt encrypted key material");
Err(())
}
}
}
}
pub struct UnsealStartResponse {
pub server_pubkey: PublicKey,
}
#[derive(Debug, Error)]
pub enum UnsealError {
#[error("Invalid key provided for unsealing")]
InvalidKey,
#[error("Internal error during unsealing process")]
General(#[from] super::Error),
}
#[derive(Debug, Error)]
pub enum BootstrapError {
#[error("Invalid key provided for bootstrapping")]
InvalidKey,
#[error("Vault is already bootstrapped")]
AlreadyBootstrapped,
#[error("Internal error during bootstrapping process")]
General(#[from] super::Error),
}
#[messages]
impl UserAgentSession {
#[message]
pub async fn handle_unseal_request(
&mut self,
client_pubkey: x25519_dalek::PublicKey,
) -> Result<UnsealStartResponse, Error> {
let secret = EphemeralSecret::random();
let public_key = PublicKey::from(&secret);
self.transition(UserAgentEvents::UnsealRequest(UnsealContext {
secret: Mutex::new(Some(secret)),
client_public_key: client_pubkey,
}))?;
Ok(UnsealStartResponse {
server_pubkey: public_key,
})
}
#[message]
pub async fn handle_unseal_encrypted_key(
&mut self,
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> Result<(), UnsealError> {
let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() {
Ok(values) => values,
Err(Error::State) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(UnsealError::InvalidKey);
}
Err(_err) => {
return Err(Error::internal("Failed to take unseal secret").into());
}
};
let seal_key_buffer = match Self::decrypt_client_key_material(
ephemeral_secret,
client_public_key,
&nonce,
&ciphertext,
&associated_data,
) {
Ok(buffer) => buffer,
Err(()) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(UnsealError::InvalidKey);
}
};
match self
.props
.actors
.key_holder
.ask(TryUnseal {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(_) => {
info!("Successfully unsealed key with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
}
Err(SendError::HandlerError(keyholder::Error::InvalidKey)) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(UnsealError::InvalidKey)
}
Err(SendError::HandlerError(err)) => {
error!(?err, "Keyholder failed to unseal key");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(UnsealError::InvalidKey)
}
Err(err) => {
error!(?err, "Failed to send unseal request to keyholder");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(Error::internal("Vault actor error").into())
}
}
}
#[message]
pub(crate) async fn handle_bootstrap_encrypted_key(
&mut self,
nonce: Vec<u8>,
ciphertext: Vec<u8>,
associated_data: Vec<u8>,
) -> Result<(), BootstrapError> {
let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() {
Ok(values) => values,
Err(Error::State) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(BootstrapError::InvalidKey);
}
Err(err) => return Err(err.into()),
};
let seal_key_buffer = match Self::decrypt_client_key_material(
ephemeral_secret,
client_public_key,
&nonce,
&ciphertext,
&associated_data,
) {
Ok(buffer) => buffer,
Err(()) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
return Err(BootstrapError::InvalidKey);
}
};
match self
.props
.actors
.key_holder
.ask(Bootstrap {
seal_key_raw: seal_key_buffer,
})
.await
{
Ok(_) => {
info!("Successfully bootstrapped vault with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
}
Err(SendError::HandlerError(keyholder::Error::AlreadyBootstrapped)) => {
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::AlreadyBootstrapped)
}
Err(SendError::HandlerError(err)) => {
error!(?err, "Keyholder failed to bootstrap vault");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::InvalidKey)
}
Err(err) => {
error!(?err, "Failed to send bootstrap request to keyholder");
self.transition(UserAgentEvents::ReceivedInvalidKey)?;
Err(BootstrapError::General(Error::internal(
"Vault actor error",
)))
}
}
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<KeyHolderState, Error> {
use crate::actors::keyholder::GetState;
let vault_state = match self.props.actors.key_holder.ask(GetState {}).await {
Ok(state) => state,
Err(err) => {
error!(?err, actor = "useragent", "keyholder.query.failed");
return Err(Error::internal("Vault is in broken state"));
}
};
Ok(vault_state)
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
match self.props.actors.evm.ask(Generate {}).await {
Ok(address) => Ok(address),
Err(SendError::HandlerError(err)) => Err(Error::internal(format!(
"EVM wallet generation failed: {err}"
))),
Err(err) => {
error!(?err, "EVM actor unreachable during wallet create");
Err(Error::internal("EVM actor unreachable"))
}
}
}
#[message]
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<(i32, Address)>, Error> {
match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => Ok(wallets),
Err(err) => {
error!(?err, "EVM wallet list failed");
Err(Error::internal("Failed to list EVM wallets"))
}
}
}
}
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.props.actors.evm.ask(UseragentListGrants {}).await {
Ok(grants) => Ok(grants),
Err(err) => {
error!(?err, "EVM grant list failed");
Err(Error::internal("Failed to list EVM grants"))
}
}
}
#[message]
pub(crate) async fn handle_grant_create(
&mut self,
basic: crate::evm::policies::SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant,
) -> Result<i32, Error> {
match self
.props
.actors
.evm
.ask(UseragentCreateGrant { basic, grant })
.await
{
Ok(grant_id) => Ok(grant_id),
Err(err) => {
error!(?err, "EVM grant create failed");
Err(Error::internal("Failed to create EVM grant"))
}
}
}
#[message]
pub(crate) async fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), Error> {
match self
.props
.actors
.evm
.ask(UseragentDeleteGrant { grant_id })
.await
{
Ok(()) => Ok(()),
Err(err) => {
error!(?err, "EVM grant delete failed");
Err(Error::internal("Failed to delete EVM grant"))
}
}
}
#[message]
pub(crate) async fn handle_grant_evm_wallet_access(
&mut self,
entries: Vec<NewEvmWalletAccess>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::insert_into(evm_wallet_access::table)
.values(&entry)
.on_conflict_do_nothing()
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_revoke_evm_wallet_access(
&mut self,
entries: Vec<i32>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
use crate::db::schema::evm_wallet_access;
for entry in entries {
diesel::delete(evm_wallet_access::table)
.filter(evm_wallet_access::wallet_id.eq(entry))
.execute(conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
#[message]
pub(crate) async fn handle_list_wallet_access(
&mut self,
) -> Result<Vec<EvmWalletAccess>, Error> {
let mut conn = self.props.db.get().await?;
use crate::db::schema::evm_wallet_access;
let access_entries = evm_wallet_access::table
.select(EvmWalletAccess::as_select())
.load::<_>(&mut conn)
.await?;
Ok(access_entries)
}
}
#[messages]
impl UserAgentSession {
#[message(ctx)]
pub(crate) async fn handle_new_client_approve(
&mut self,
approved: bool,
pubkey: ed25519_dalek::VerifyingKey,
ctx: &mut Context<Self, Result<(), Error>>,
) -> Result<(), Error> {
let pending_approval = match self.pending_client_approvals.remove(&pubkey) {
Some(approval) => approval,
None => {
error!("Received client connection response for unknown client");
return Err(Error::internal("Unknown client in connection response"));
}
};
pending_approval
.controller
.tell(ClientApprovalAnswer { approved })
.await
.map_err(|err| {
error!(
?err,
"Failed to send client approval response to controller"
);
Error::internal("Failed to send client approval response to controller")
})?;
ctx.actor_ref().unlink(&pending_approval.controller).await;
Ok(())
}
#[message]
pub(crate) async fn handle_sdk_client_list(
&mut self,
) -> Result<Vec<(ProgramClient, ProgramClientMetadata)>, Error> {
use crate::db::schema::{client_metadata, program_client};
let mut conn = self.props.db.get().await?;
let clients = program_client::table
.inner_join(client_metadata::table)
.select((
ProgramClient::as_select(),
ProgramClientMetadata::as_select(),
))
.load::<(ProgramClient, ProgramClientMetadata)>(&mut conn)
.await?;
Ok(clients)
}
}

View File

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

View File

@@ -0,0 +1,72 @@
use std::{
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
path::PathBuf,
};
use clap::{Args, Parser, Subcommand};
const DEFAULT_LISTEN_ADDR: SocketAddr = SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::LOCALHOST,
arbiter_proto::DEFAULT_SERVER_PORT,
));
#[derive(Debug, Parser)]
#[command(name = "arbiter-server")]
#[command(about = "Arbiter gRPC server")]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Run server in foreground mode.
Run(RunArgs),
/// Manage service lifecycle.
Service {
#[command(subcommand)]
command: ServiceCommand,
},
}
#[derive(Debug, Clone, Args)]
pub struct RunArgs {
#[arg(long, default_value_t = DEFAULT_LISTEN_ADDR)]
pub listen_addr: SocketAddr,
#[arg(long)]
pub data_dir: Option<PathBuf>,
}
impl Default for RunArgs {
fn default() -> Self {
Self {
listen_addr: DEFAULT_LISTEN_ADDR,
data_dir: None,
}
}
}
#[derive(Debug, Subcommand)]
pub enum ServiceCommand {
/// Install Windows service in Service Control Manager.
Install(ServiceInstallArgs),
/// Internal service entrypoint. SCM only.
#[command(hide = true)]
Run(ServiceRunArgs),
}
#[derive(Debug, Clone, Args)]
pub struct ServiceInstallArgs {
#[arg(long)]
pub start: bool,
#[arg(long)]
pub data_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Args)]
pub struct ServiceRunArgs {
#[arg(long, default_value_t = DEFAULT_LISTEN_ADDR)]
pub listen_addr: SocketAddr,
#[arg(long)]
pub data_dir: Option<PathBuf>,
}

View File

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

View File

@@ -1,3 +1,17 @@
use std::{net::IpAddr, string::FromUtf8Error};
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _};
use diesel_async::{AsyncConnection, RunQueryDsl};
use miette::Diagnostic;
use pem::Pem;
use rcgen::{
BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType,
IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
};
use rustls::pki_types::pem::PemObject;
use thiserror::Error;
use tonic::transport::CertificateDer;
use crate::db::{ use crate::db::{
self, self,
models::{NewTlsHistory, TlsHistory}, models::{NewTlsHistory, TlsHistory},
@@ -7,58 +21,48 @@ use crate::db::{
}, },
}; };
use diesel::{ExpressionMethods as _, QueryDsl, SelectableHelper as _};
use diesel_async::{AsyncConnection, RunQueryDsl};
use pem::Pem;
use rcgen::{
BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType,
IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
};
use rustls::pki_types::pem::PemObject;
use std::{net::Ipv4Addr, string::FromUtf8Error};
use thiserror::Error;
use tonic::transport::CertificateDer;
const ENCODE_CONFIG: pem::EncodeConfig = { const ENCODE_CONFIG: pem::EncodeConfig = {
let line_ending = if cfg!(target_family = "windows") { let line_ending = match cfg!(target_family = "windows") {
pem::LineEnding::CRLF true => pem::LineEnding::CRLF,
} else { false => pem::LineEnding::LF,
pem::LineEnding::LF
}; };
pem::EncodeConfig::new().set_line_ending(line_ending) pem::EncodeConfig::new().set_line_ending(line_ending)
}; };
#[derive(Error, Debug)] #[derive(Error, Debug, Diagnostic)]
pub enum InitError { pub enum InitError {
#[error("Key generation error during TLS initialization: {0}")] #[error("Key generation error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_generation))]
KeyGeneration(#[from] rcgen::Error), KeyGeneration(#[from] rcgen::Error),
#[error("Key invalid format: {0}")] #[error("Key invalid format: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_invalid_format))]
KeyInvalidFormat(#[from] FromUtf8Error), KeyInvalidFormat(#[from] FromUtf8Error),
#[error("Key deserialization error: {0}")] #[error("Key deserialization error: {0}")]
#[diagnostic(code(arbiter_server::tls_init::key_deserialization))]
KeyDeserializationError(rcgen::Error), KeyDeserializationError(rcgen::Error),
#[error("Database error during TLS initialization: {0}")] #[error("Database error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::database_error))]
DatabaseError(#[from] diesel::result::Error), DatabaseError(#[from] diesel::result::Error),
#[error("Pem deserialization error during TLS initialization: {0}")] #[error("Pem deserialization error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::pem_deserialization))]
PemDeserializationError(#[from] rustls::pki_types::pem::Error), PemDeserializationError(#[from] rustls::pki_types::pem::Error),
#[error("Database pool acquire error during TLS initialization: {0}")] #[error("Database pool acquire error during TLS initialization: {0}")]
#[diagnostic(code(arbiter_server::tls_init::database_pool_acquire))]
DatabasePoolAcquire(#[from] db::PoolError), DatabasePoolAcquire(#[from] db::PoolError),
} }
pub type PemCert = String; 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) pem::encode_config(&Pem::new("CERTIFICATE", cert.to_vec()), ENCODE_CONFIG)
} }
#[expect( #[allow(unused)]
unused,
reason = "may be needed for future cert rotation implementation"
)]
struct SerializedTls { struct SerializedTls {
cert_pem: PemCert, cert_pem: PemCert,
cert_key_pem: String, cert_key_pem: String,
@@ -87,7 +91,7 @@ impl TlsCa {
let cert_key_pem = certified_issuer.key().serialize_pem(); let cert_key_pem = certified_issuer.key().serialize_pem();
#[expect( #[allow(
clippy::unwrap_used, clippy::unwrap_used,
reason = "Broken cert couldn't bootstrap server anyway" reason = "Broken cert couldn't bootstrap server anyway"
)] )]
@@ -112,7 +116,9 @@ impl TlsCa {
]; ];
params params
.subject_alt_names .subject_alt_names
.push(SanType::IpAddress(Ipv4Addr::LOCALHOST.into())); .push(SanType::IpAddress(IpAddr::from([
127, 0, 0, 1,
])));
let mut dn = DistinguishedName::new(); let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Arbiter Instance Leaf"); dn.push(DnType::CommonName, "Arbiter Instance Leaf");
@@ -126,11 +132,7 @@ impl TlsCa {
}) })
} }
#[expect( #[allow(unused)]
unused,
clippy::unnecessary_wraps,
reason = "may be needed for future cert rotation implementation"
)]
fn serialize(&self) -> Result<SerializedTls, InitError> { fn serialize(&self) -> Result<SerializedTls, InitError> {
let cert_key_pem = self.issuer.key().serialize_pem(); let cert_key_pem = self.issuer.key().serialize_pem();
Ok(SerializedTls { Ok(SerializedTls {
@@ -139,10 +141,7 @@ impl TlsCa {
}) })
} }
#[expect( #[allow(unused)]
unused,
reason = "may be needed for future cert rotation implementation"
)]
fn try_deserialize(cert_pem: &str, cert_key_pem: &str) -> Result<Self, InitError> { fn try_deserialize(cert_pem: &str, cert_key_pem: &str) -> Result<Self, InitError> {
let keypair = let keypair =
KeyPair::from_pem(cert_key_pem).map_err(InitError::KeyDeserializationError)?; KeyPair::from_pem(cert_key_pem).map_err(InitError::KeyDeserializationError)?;
@@ -243,10 +242,10 @@ impl TlsManager {
} }
} }
pub const fn cert(&self) -> &CertificateDer<'static> { pub fn cert(&self) -> &CertificateDer<'static> {
&self.cert &self.cert
} }
pub const fn ca_cert(&self) -> &CertificateDer<'static> { pub fn ca_cert(&self) -> &CertificateDer<'static> {
&self.ca_cert &self.ca_cert
} }

View File

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

View File

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

View File

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

View File

@@ -1,334 +0,0 @@
use crate::{
actors::vault::{self, GetState, SignIntegrity, Vault, VerifyIntegrity},
db::{
self,
models::{IntegrityEnvelope, NewIntegrityEnvelope},
schema::integrity_envelope,
},
};
use arbiter_crypto::hashing::Hashable;
use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::Hmac;
use kameo::{actor::ActorRef, error::SendError};
use sha2::{Digest as _, Sha256};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Database error: {0}")]
Database(#[from] db::DatabaseError),
#[error("Vault error: {0}")]
Vault(#[from] vault::Error),
#[error("Vault mailbox error")]
VaultSend,
#[error("Integrity envelope is missing for entity {entity_kind}")]
MissingEnvelope { entity_kind: &'static str },
#[error(
"Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}"
)]
PayloadVersionMismatch {
entity_kind: &'static str,
expected: i32,
found: i32,
},
#[error("Integrity MAC mismatch for entity {entity_kind}")]
MacMismatch { entity_kind: &'static str },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttestationStatus {
Attested,
Unavailable,
}
pub const CURRENT_PAYLOAD_VERSION: i32 = 1;
pub const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1";
pub type HmacSha256 = Hmac<Sha256>;
pub trait Integrable: Hashable {
const KIND: &'static str;
const VERSION: i32 = 1;
}
fn payload_hash(payload: &impl Hashable) -> [u8; 32] {
let mut hasher = Sha256::new();
payload.hash(&mut hasher);
hasher.finalize().into()
}
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);
}
fn build_mac_input(
entity_kind: &str,
entity_id: &[u8],
payload_version: i32,
payload_hash: &[u8; 32],
) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + entity_kind.len() + entity_id.len() + 32);
push_len_prefixed(&mut out, entity_kind.as_bytes());
push_len_prefixed(&mut out, entity_id);
out.extend_from_slice(&payload_version.to_be_bytes());
out.extend_from_slice(payload_hash);
out
}
pub trait IntoId {
fn into_id(self) -> Vec<u8>;
}
impl IntoId for i32 {
fn into_id(self) -> Vec<u8> {
self.to_be_bytes().to_vec()
}
}
impl IntoId for &'_ [u8] {
fn into_id(self) -> Vec<u8> {
self.to_vec()
}
}
pub async fn sign_entity<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
vault: &ActorRef<Vault>,
entity: &E,
entity_id: impl IntoId,
) -> Result<(), Error> {
let payload_hash = payload_hash(&entity);
let entity_id = entity_id.into_id();
let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash);
let (key_version, mac) =
vault
.ask(SignIntegrity { mac_input })
.await
.map_err(|err| match err {
SendError::HandlerError(inner) => Error::Vault(inner),
_ => Error::VaultSend,
})?;
insert_into(integrity_envelope::table)
.values(NewIntegrityEnvelope {
entity_kind: E::KIND.to_owned(),
entity_id,
payload_version: E::VERSION,
key_version,
mac: mac.clone(),
})
.on_conflict((
integrity_envelope::entity_id,
integrity_envelope::entity_kind,
))
.do_update()
.set((
integrity_envelope::payload_version.eq(E::VERSION),
integrity_envelope::key_version.eq(key_version),
integrity_envelope::mac.eq(mac),
))
.execute(conn)
.await
.map_err(db::DatabaseError::from)?;
Ok(())
}
pub async fn verify_entity<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
vault: &ActorRef<Vault>,
entity: &E,
entity_id: impl IntoId,
) -> Result<AttestationStatus, Error> {
let entity_id = entity_id.into_id();
let envelope: IntegrityEnvelope = integrity_envelope::table
.filter(integrity_envelope::entity_kind.eq(E::KIND))
.filter(integrity_envelope::entity_id.eq(&entity_id))
.first(conn)
.await
.map_err(|err| match err {
diesel::result::Error::NotFound => Error::MissingEnvelope {
entity_kind: E::KIND,
},
other => Error::Database(db::DatabaseError::from(other)),
})?;
if envelope.payload_version != E::VERSION {
return Err(Error::PayloadVersionMismatch {
entity_kind: E::KIND,
expected: E::VERSION,
found: envelope.payload_version,
});
}
let payload_hash = payload_hash(&entity);
let mac_input = build_mac_input(E::KIND, &entity_id, envelope.payload_version, &payload_hash);
let result = vault
.ask(VerifyIntegrity {
mac_input,
expected_mac: envelope.mac,
key_version: envelope.key_version,
})
.await;
match result {
Ok(true) => Ok(AttestationStatus::Attested),
Ok(false) => Err(Error::MacMismatch {
entity_kind: E::KIND,
}),
Err(SendError::HandlerError(vault::Error::Sealed)) => Ok(AttestationStatus::Unavailable),
Err(_) => Err(Error::VaultSend),
}
}
pub async fn is_signing_available(vault: &ActorRef<Vault>) -> Result<bool, Error> {
let state = vault.ask(GetState).await.map_err(|_| Error::VaultSend)?;
Ok(matches!(state, vault::VaultState::Unsealed))
}
#[cfg(test)]
mod tests {
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
use crate::{
actors::{
GlobalActors,
vault::{Bootstrap, Vault},
},
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 {
payload_version: i32,
payload: Vec<u8>,
}
impl Integrable for DummyEntity {
const KIND: &'static str = "dummy_entity";
}
async fn bootstrapped_vault(db: &db::DatabasePool) -> ActorRef<Vault> {
let actor = Vault::spawn(
Vault::new(db.clone(), GlobalActors::spawn_message_bus())
.await
.unwrap(),
);
actor
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
})
.await
.unwrap();
actor
}
#[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();
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap();
let count: i64 = schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(count, 1, "envelope row must be created exactly once");
verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap();
}
#[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();
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap();
diesel::update(schema::integrity_envelope::table)
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
.set(schema::integrity_envelope::mac.eq(vec![0u8; 32]))
.execute(&mut conn)
.await
.unwrap();
let err = verify_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));
}
#[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();
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &vault, &entity, ENTITY_ID)
.await
.unwrap();
let tampered = DummyEntity {
payload: b"payload-v1-but-tampered".to_vec(),
..entity
};
let err = verify_entity(&mut conn, &vault, &tampered, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));
}
}

View File

@@ -1,155 +0,0 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use encryption::v1::{Nonce, Salt};
use argon2::{Algorithm, Argon2};
use chacha20poly1305::{
AeadInPlace, Key, KeyInit as _, XChaCha20Poly1305, XNonce,
aead::{AeadMut, Error, Payload},
};
use rand::{
Rng as _, SeedableRng as _,
rngs::{StdRng, SysRng},
};
pub mod encryption;
pub mod integrity;
pub struct KeyCell(pub SafeCell<Key>);
impl From<SafeCell<Key>> for KeyCell {
fn from(value: SafeCell<Key>) -> Self {
Self(value)
}
}
impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
type Error = ();
fn try_from(mut value: SafeCell<Vec<u8>>) -> Result<Self, Self::Error> {
let value = value.read();
if value.len() != size_of::<Key>() {
return Err(());
}
let cell = SafeCell::new_inline(|cell_write: &mut Key| {
cell_write.copy_from_slice(&value);
});
Ok(Self(cell))
}
}
impl KeyCell {
pub fn new_secure_random() -> Self {
let key = SafeCell::new_inline(|key_buffer: &mut Key| {
let mut rng = StdRng::try_from_rng(&mut SysRng)
.expect("Rng failure is unrecoverable and should panic");
rng.fill_bytes(key_buffer);
});
key.into()
}
pub fn encrypt_in_place(
&mut self,
nonce: &Nonce,
associated_data: &[u8],
mut buffer: impl AsMut<Vec<u8>>,
) -> Result<(), Error> {
let key_reader = self.0.read();
let 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)
}
pub fn decrypt_in_place(
&mut self,
nonce: &Nonce,
associated_data: &[u8],
buffer: &mut SafeCell<Vec<u8>>,
) -> Result<(), Error> {
let key_reader = self.0.read();
let 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();
cipher.decrypt_in_place(nonce, associated_data, buffer)
}
pub fn encrypt(
&mut self,
nonce: &Nonce,
associated_data: &[u8],
plaintext: impl AsRef<[u8]>,
) -> Result<Vec<u8>, Error> {
let key_reader = self.0.read();
let mut cipher = XChaCha20Poly1305::new(&key_reader);
let nonce = XNonce::from_slice(nonce.0.as_ref());
let ciphertext = cipher.encrypt(
nonce,
Payload {
msg: plaintext.as_ref(),
aad: associated_data,
},
)?;
Ok(ciphertext)
}
}
/// 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 {
let params = {
#[cfg(debug_assertions)]
{
argon2::Params::new(8, 1, 1, None).unwrap()
}
#[cfg(not(debug_assertions))]
{
argon2::Params::new(262_144, 3, 4, None).unwrap()
}
};
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = SafeCell::new(Key::default());
password.read_inline(|password_source| {
let mut key_buffer = key.write();
let key_buffer: &mut [u8] = key_buffer.as_mut();
hasher
.hash_password_into(password_source, salt, key_buffer)
.expect("Better fail completely than return a weak key");
});
key.into()
}
#[cfg(test)]
mod tests {
use super::{
derive_key,
encryption::v1::{Nonce, generate_salt},
};
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
#[test]
fn encrypt_decrypt() {
static PASSWORD: &[u8] = b"password";
let password = SafeCell::new(PASSWORD.to_vec());
let salt = generate_salt();
let mut key = derive_key(password, &salt);
let nonce = Nonce(*b"unique nonce 123 1231233"); // 24 bytes for XChaCha20Poly1305
let associated_data = b"associated data";
let mut buffer = b"secret data".to_vec();
key.encrypt_in_place(&nonce, associated_data, &mut buffer)
.unwrap();
assert_ne!(buffer, b"secret data");
let mut buffer = SafeCell::new(buffer);
key.decrypt_in_place(&nonce, associated_data, &mut buffer)
.unwrap();
let buffer = buffer.read();
assert_eq!(*buffer, b"secret data");
}
}

View File

@@ -5,6 +5,7 @@ use diesel_async::{
sync_connection_wrapper::SyncConnectionWrapper, sync_connection_wrapper::SyncConnectionWrapper,
}; };
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use miette::Diagnostic;
use thiserror::Error; use thiserror::Error;
use tracing::info; use tracing::info;
@@ -20,31 +21,35 @@ static DB_FILE: &str = "arbiter.sqlite";
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
#[derive(Error, Debug)] #[derive(Error, Diagnostic, Debug)]
pub enum DatabaseSetupError { pub enum DatabaseSetupError {
#[error(transparent)]
ConcurrencySetup(diesel::result::Error),
#[error(transparent)]
Connection(diesel::ConnectionError),
#[error("Failed to determine home directory")] #[error("Failed to determine home directory")]
#[diagnostic(code(arbiter::db::home_dir))]
HomeDir(std::io::Error), HomeDir(std::io::Error),
#[error(transparent)] #[error(transparent)]
#[diagnostic(code(arbiter::db::connection))]
Connection(diesel::ConnectionError),
#[error(transparent)]
#[diagnostic(code(arbiter::db::concurrency))]
ConcurrencySetup(diesel::result::Error),
#[error(transparent)]
#[diagnostic(code(arbiter::db::migration))]
Migration(Box<dyn std::error::Error + Send + Sync>), Migration(Box<dyn std::error::Error + Send + Sync>),
#[error(transparent)] #[error(transparent)]
#[diagnostic(code(arbiter::db::pool))]
Pool(#[from] PoolInitError), Pool(#[from] PoolInitError),
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum DatabaseError { pub enum DatabaseError {
#[error("Database query error")]
Connection(#[from] diesel::result::Error),
#[error("Database connection error")] #[error("Database connection error")]
Pool(#[from] PoolError), Pool(#[from] PoolError),
#[error("Database query error")]
Connection(#[from] diesel::result::Error),
} }
#[tracing::instrument(level = "info")] #[tracing::instrument(level = "info")]
@@ -93,16 +98,13 @@ fn initialize_database(url: &str) -> Result<(), DatabaseSetupError> {
} }
#[tracing::instrument(level = "info")] #[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> { pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetupError> {
let database_url = url.map(String::from).unwrap_or( let database_url = url.map(String::from).unwrap_or(
#[allow(clippy::expect_used)]
database_path()? database_path()?
.to_str() .to_str()
.expect("database path is not valid UTF-8") .expect("database path is not valid UTF-8")
.to_owned(), .to_string(),
); );
initialize_database(&database_url)?; initialize_database(&database_url)?;
@@ -136,20 +138,19 @@ pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetu
Ok(pool) Ok(pool)
} }
#[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 { pub async fn create_test_pool() -> DatabasePool {
use rand::distr::{Alphanumeric, SampleString as _}; use rand::distr::{Alphanumeric, SampleString as _};
let tempfile_name = Alphanumeric.sample_string(&mut rand::rng(), 16); let tempfile_name = Alphanumeric.sample_string(&mut rand::rng(), 16);
let file = std::env::temp_dir().join(tempfile_name); let file = std::env::temp_dir().join(tempfile_name);
#[allow(clippy::expect_used)]
let url = file let url = file
.to_str() .to_str()
.expect("temp file path is not valid UTF-8") .expect("temp file path is not valid UTF-8")
.to_owned(); .to_string();
#[allow(clippy::expect_used)]
create_pool(Some(&url)) create_pool(Some(&url))
.await .await
.expect("Failed to create test database pool") .expect("Failed to create test database pool")

View File

@@ -1,14 +1,13 @@
#![allow( #![allow(unused)]
clippy::duplicated_attributes, #![allow(clippy::all)]
reason = "restructed's #[view] causes false positives"
)]
use crate::db::schema::{ use crate::db::schema::{
self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant, 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, evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant,
evm_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet, evm_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet,
integrity_envelope, root_key_history, tls_history, root_key_history, tls_history,
}; };
use chrono::{DateTime, Utc};
use diesel::{prelude::*, sqlite::Sqlite}; use diesel::{prelude::*, sqlite::Sqlite};
use restructed::Models; use restructed::Models;
@@ -28,16 +27,16 @@ pub mod types {
pub struct SqliteTimestamp(pub DateTime<Utc>); pub struct SqliteTimestamp(pub DateTime<Utc>);
impl SqliteTimestamp { impl SqliteTimestamp {
pub fn now() -> Self { pub fn now() -> Self {
Self(Utc::now()) SqliteTimestamp(Utc::now())
} }
} }
impl From<DateTime<Utc>> for SqliteTimestamp { impl From<chrono::DateTime<Utc>> for SqliteTimestamp {
fn from(dt: DateTime<Utc>) -> Self { fn from(dt: chrono::DateTime<Utc>) -> Self {
Self(dt) SqliteTimestamp(dt)
} }
} }
impl From<SqliteTimestamp> for DateTime<Utc> { impl From<SqliteTimestamp> for chrono::DateTime<Utc> {
fn from(ts: SqliteTimestamp) -> Self { fn from(ts: SqliteTimestamp) -> Self {
ts.0 ts.0
} }
@@ -48,11 +47,6 @@ pub mod types {
&'b self, &'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>, out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result { ) -> 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; let unix_timestamp = self.0.timestamp() as i32;
out.set_value(unix_timestamp); out.set_value(unix_timestamp);
Ok(IsNull::No) Ok(IsNull::No)
@@ -75,47 +69,41 @@ pub mod types {
let datetime = let datetime =
DateTime::from_timestamp(unix_timestamp, 0).ok_or("Timestamp is out of bounds")?; DateTime::from_timestamp(unix_timestamp, 0).ok_or("Timestamp is out of bounds")?;
Ok(Self(datetime)) Ok(SqliteTimestamp(datetime))
} }
} }
#[derive(Debug, FromSqlRow, AsExpression, Clone)] /// Key algorithm stored in the `useragent_client.key_type` column.
/// Values must stay stable — they are persisted in the database.
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromSqlRow, AsExpression, strum::FromRepr)]
#[diesel(sql_type = Integer)] #[diesel(sql_type = Integer)]
#[repr(transparent)] // hint compiler to optimize the wrapper struct away #[repr(i32)]
pub struct ChainId(pub i32); pub enum KeyType {
Ed25519 = 1,
#[expect( EcdsaSecp256k1 = 2,
clippy::cast_sign_loss, Rsa = 3,
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 _)
}
}
};
impl FromSql<Integer, Sqlite> for ChainId {
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 ChainId {
impl ToSql<Integer, Sqlite> for KeyType {
fn to_sql<'b>( fn to_sql<'b>(
&'b self, &'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>, out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result { ) -> diesel::serialize::Result {
ToSql::<Integer, Sqlite>::to_sql(&self.0, out) out.set_value(*self as i32);
Ok(IsNull::No)
}
}
impl FromSql<Integer, Sqlite> for KeyType {
fn from_sql(
mut bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
let Some(SqliteType::Long) = bytes.value_type() else {
return Err("Expected Integer for KeyType".into());
};
let discriminant = bytes.read_long();
KeyType::from_repr(discriminant as i32)
.ok_or_else(|| format!("Unknown KeyType discriminant: {discriminant}").into())
} }
} }
} }
@@ -241,6 +229,7 @@ pub struct ProgramClientMetadataHistory {
#[diesel(table_name = schema::program_client, check_for_backend(Sqlite))] #[diesel(table_name = schema::program_client, check_for_backend(Sqlite))]
pub struct ProgramClient { pub struct ProgramClient {
pub id: i32, pub id: i32,
pub nonce: i32,
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub metadata_id: i32, pub metadata_id: i32,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
@@ -248,12 +237,14 @@ pub struct ProgramClient {
} }
#[derive(Queryable, Debug)] #[derive(Queryable, Debug)]
#[diesel(table_name = schema::operator_client, check_for_backend(Sqlite))] #[diesel(table_name = schema::useragent_client, check_for_backend(Sqlite))]
pub struct OperatorClient { pub struct UseragentClient {
pub id: i32, pub id: i32,
pub nonce: i32,
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp, pub updated_at: SqliteTimestamp,
pub key_type: KeyType,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)] #[derive(Models, Queryable, Debug, Insertable, Selectable)]
@@ -281,7 +272,7 @@ pub struct EvmEtherTransferLimit {
pub struct EvmBasicGrant { pub struct EvmBasicGrant {
pub id: i32, pub id: i32,
pub wallet_access_id: i32, // references evm_wallet_access.id pub wallet_access_id: i32, // references evm_wallet_access.id
pub chain_id: ChainId, pub chain_id: i32,
pub valid_from: Option<SqliteTimestamp>, pub valid_from: Option<SqliteTimestamp>,
pub valid_until: Option<SqliteTimestamp>, pub valid_until: Option<SqliteTimestamp>,
pub max_gas_fee_per_gas: Option<Vec<u8>>, pub max_gas_fee_per_gas: Option<Vec<u8>>,
@@ -304,7 +295,7 @@ pub struct EvmTransactionLog {
pub id: i32, pub id: i32,
pub grant_id: i32, pub grant_id: i32,
pub wallet_access_id: i32, pub wallet_access_id: i32,
pub chain_id: ChainId, pub chain_id: i32,
pub eth_value: Vec<u8>, pub eth_value: Vec<u8>,
pub signed_at: SqliteTimestamp, pub signed_at: SqliteTimestamp,
} }
@@ -379,28 +370,9 @@ pub struct EvmTokenTransferLog {
pub id: i32, pub id: i32,
pub grant_id: i32, pub grant_id: i32,
pub log_id: i32, pub log_id: i32,
pub chain_id: ChainId, pub chain_id: i32,
pub token_contract: Vec<u8>, pub token_contract: Vec<u8>,
pub recipient_address: Vec<u8>, pub recipient_address: Vec<u8>,
pub value: Vec<u8>, pub value: Vec<u8>,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = integrity_envelope, check_for_backend(Sqlite))]
#[view(
NewIntegrityEnvelope,
derive(Insertable),
omit(id, signed_at, created_at),
attributes_with = "deriveless"
)]
pub struct IntegrityEnvelope {
pub id: i32,
pub entity_kind: String,
pub entity_id: Vec<u8>,
pub payload_version: i32,
pub key_version: i32,
pub mac: Vec<u8>,
pub signed_at: SqliteTimestamp,
pub created_at: SqliteTimestamp,
}

View File

@@ -139,22 +139,10 @@ diesel::table! {
} }
} }
diesel::table! {
integrity_envelope (id) {
id -> Integer,
entity_kind -> Text,
entity_id -> Binary,
payload_version -> Integer,
key_version -> Integer,
mac -> Binary,
signed_at -> Integer,
created_at -> Integer,
}
}
diesel::table! { diesel::table! {
program_client (id) { program_client (id) {
id -> Integer, id -> Integer,
nonce -> Integer,
public_key -> Binary, public_key -> Binary,
metadata_id -> Integer, metadata_id -> Integer,
created_at -> Integer, created_at -> Integer,
@@ -186,9 +174,11 @@ diesel::table! {
} }
diesel::table! { diesel::table! {
operator_client (id) { useragent_client (id) {
id -> Integer, id -> Integer,
nonce -> Integer,
public_key -> Binary, public_key -> Binary,
key_type -> Integer,
created_at -> Integer, created_at -> Integer,
updated_at -> Integer, updated_at -> Integer,
} }
@@ -229,9 +219,8 @@ diesel::allow_tables_to_appear_in_same_query!(
evm_transaction_log, evm_transaction_log,
evm_wallet, evm_wallet,
evm_wallet_access, evm_wallet_access,
integrity_envelope,
program_client, program_client,
root_key_history, root_key_history,
tls_history, tls_history,
operator_client, useragent_client,
); );

View File

@@ -45,7 +45,7 @@ sol! {
sol! { sol! {
/// Permit2 — Uniswap's canonical token approval manager. /// 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)] #[derive(Debug)]
interface IPermit2 { interface IPermit2 {
struct TokenPermissions { struct TokenPermissions {

View File

@@ -1,19 +1,5 @@
use crate::{ pub mod abi;
actors::vault::Vault, pub mod safe_signer;
crypto::integrity,
db::{
self, DatabaseError,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{self, evm_transaction_log},
},
evm::policies::{
CombinedSettings, DatabaseID, EvalContext, EvalViolation, Grant, Policy,
SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
token_transfers::TokenTransfer,
},
};
use alloy::{ use alloy::{
consensus::TxEip1559, consensus::TxEip1559,
@@ -22,56 +8,63 @@ use alloy::{
use chrono::Utc; use chrono::Utc;
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite}; use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef; use tracing_subscriber::registry::Data;
pub mod abi; use crate::{
pub mod safe_signer; db::{
self, DatabaseError,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{self, evm_transaction_log},
},
evm::policies::{
DatabaseID, EvalContext, EvalViolation, FullGrant, Grant, Policy, SharedGrantSettings,
SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
token_transfers::TokenTransfer,
},
};
pub mod policies; pub mod policies;
mod utils; mod utils;
/// Errors that can only occur once the transaction meaning is known (during policy evaluation) /// Errors that can only occur once the transaction meaning is known (during policy evaluation)
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum PolicyError { pub enum PolicyError {
#[error("Database error")] #[error("Database error")]
Database(#[from] DatabaseError), Error(#[from] crate::db::DatabaseError),
#[error("Transaction violates policy: {0:?}")] #[error("Transaction violates policy: {0:?}")]
#[diagnostic(code(arbiter_server::evm::policy_error::violation))]
Violations(Vec<EvalViolation>), Violations(Vec<EvalViolation>),
#[error("No matching grant found")] #[error("No matching grant found")]
#[diagnostic(code(arbiter_server::evm::policy_error::no_matching_grant))]
NoMatchingGrant, NoMatchingGrant,
#[error("Integrity error: {0}")]
Integrity(#[from] integrity::Error),
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum VetError { pub enum VetError {
#[error("Contract creation transactions are not supported")] #[error("Contract creation transactions are not supported")]
#[diagnostic(code(arbiter_server::evm::vet_error::contract_creation_unsupported))]
ContractCreationNotSupported, ContractCreationNotSupported,
#[error("Engine can't classify this transaction")] #[error("Engine can't classify this transaction")]
#[diagnostic(code(arbiter_server::evm::vet_error::unsupported))]
UnsupportedTransactionType, UnsupportedTransactionType,
#[error("Policy evaluation failed: {1}")] #[error("Policy evaluation failed: {1}")]
#[diagnostic(code(arbiter_server::evm::vet_error::evaluated))]
Evaluated(SpecificMeaning, #[source] PolicyError), Evaluated(SpecificMeaning, #[source] PolicyError),
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum AnalyzeError { pub enum AnalyzeError {
#[error("Engine doesn't support granting permissions for contract creation")] #[error("Engine doesn't support granting permissions for contract creation")]
#[diagnostic(code(arbiter_server::evm::analyze_error::contract_creation_not_supported))]
ContractCreationNotSupported, ContractCreationNotSupported,
#[error("Unsupported transaction type")] #[error("Unsupported transaction type")]
#[diagnostic(code(arbiter_server::evm::analyze_error::unsupported_transaction_type))]
UnsupportedTransactionType, UnsupportedTransactionType,
} }
#[derive(Debug, thiserror::Error)]
pub enum ListError {
#[error("Database error")]
Database(#[from] DatabaseError),
#[error("Integrity verification failed for grant")]
Integrity(#[from] integrity::Error),
}
/// Controls whether a transaction should be executed or only validated /// Controls whether a transaction should be executed or only validated
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunKind { pub enum RunKind {
@@ -90,14 +83,6 @@ async fn check_shared_constraints(
let mut violations = Vec::new(); let mut violations = Vec::new();
let now = Utc::now(); let now = Utc::now();
if shared.chain != context.chain {
violations.push(EvalViolation::MismatchingChainId {
expected: shared.chain,
actual: context.chain,
});
return Ok(violations);
}
// Validity window // Validity window
if shared.valid_from.is_some_and(|t| now < t) || shared.valid_until.is_some_and(|t| now > t) { if shared.valid_from.is_some_and(|t| now < t) || shared.valid_until.is_some_and(|t| now > t) {
violations.push(EvalViolation::InvalidTime); violations.push(EvalViolation::InvalidTime);
@@ -127,7 +112,7 @@ async fn check_shared_constraints(
.get_result(conn) .get_result(conn)
.await?; .await?;
if count >= rate_limit.count.into() { if count >= rate_limit.count as i64 {
violations.push(EvalViolation::RateLimitExceeded); violations.push(EvalViolation::RateLimitExceeded);
} }
} }
@@ -138,7 +123,6 @@ async fn check_shared_constraints(
// Supporting only EIP-1559 transactions for now, but we can easily extend this to support legacy transactions if needed // Supporting only EIP-1559 transactions for now, but we can easily extend this to support legacy transactions if needed
pub struct Engine { pub struct Engine {
db: db::DatabasePool, db: db::DatabasePool,
vault: ActorRef<Vault>,
} }
impl Engine { impl Engine {
@@ -147,10 +131,7 @@ impl Engine {
context: EvalContext, context: EvalContext,
meaning: &P::Meaning, meaning: &P::Meaning,
run_kind: RunKind, run_kind: RunKind,
) -> Result<(), PolicyError> ) -> Result<(), PolicyError> {
where
P::Settings: Clone,
{
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let grant = P::try_find_grant(&context, &mut conn) let grant = P::try_find_grant(&context, &mut conn)
@@ -158,16 +139,10 @@ impl Engine {
.map_err(DatabaseError::from)? .map_err(DatabaseError::from)?
.ok_or(PolicyError::NoMatchingGrant)?; .ok_or(PolicyError::NoMatchingGrant)?;
integrity::verify_entity(&mut conn, &self.vault, &grant.settings, grant.id).await?; let mut violations =
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
let mut violations = check_shared_constraints( .await
&context, .map_err(DatabaseError::from)?;
&grant.settings.shared,
grant.common_settings_id,
&mut conn,
)
.await
.map_err(DatabaseError::from)?;
violations.extend( violations.extend(
P::evaluate(&context, meaning, &grant, &mut conn) P::evaluate(&context, meaning, &grant, &mut conn)
.await .await
@@ -176,16 +151,14 @@ impl Engine {
if !violations.is_empty() { if !violations.is_empty() {
return Err(PolicyError::Violations(violations)); return Err(PolicyError::Violations(violations));
} } else if run_kind == RunKind::Execution {
if run_kind == RunKind::Execution {
conn.transaction(|conn| { conn.transaction(|conn| {
Box::pin(async move { Box::pin(async move {
let log_id: i32 = insert_into(evm_transaction_log::table) let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog { .values(&NewEvmTransactionLog {
grant_id: grant.common_settings_id, grant_id: grant.shared_grant_id,
wallet_access_id: context.target.id, wallet_access_id: context.target.id,
chain_id: context.chain.into(), chain_id: context.chain as i32,
eth_value: utils::u256_to_bytes(context.value).to_vec(), eth_value: utils::u256_to_bytes(context.value).to_vec(),
signed_at: Utc::now().into(), signed_at: Utc::now().into(),
}) })
@@ -207,52 +180,42 @@ impl Engine {
} }
impl Engine { impl Engine {
pub const fn new(db: db::DatabasePool, vault: ActorRef<Vault>) -> Self { pub fn new(db: db::DatabasePool) -> Self {
Self { db, vault } Self { db }
} }
pub async fn create_grant<P: Policy>( pub async fn create_grant<P: Policy>(
&self, &self,
full_grant: CombinedSettings<P::Settings>, full_grant: FullGrant<P::Settings>,
) -> Result<i32, DatabaseError> ) -> Result<i32, DatabaseError> {
where
P::Settings: Clone,
{
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
let vault = self.vault.clone();
let id = conn let id = conn
.transaction(|conn| { .transaction(|conn| {
Box::pin(async move { Box::pin(async move {
use schema::evm_basic_grant; use schema::evm_basic_grant;
#[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) let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant { .values(&NewEvmBasicGrant {
chain_id: full_grant.shared.chain.into(), chain_id: full_grant.basic.chain as i32,
wallet_access_id: full_grant.shared.wallet_access_id, wallet_access_id: full_grant.basic.wallet_access_id,
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp), valid_from: full_grant.basic.valid_from.map(SqliteTimestamp),
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp), valid_until: full_grant.basic.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant max_gas_fee_per_gas: full_grant
.shared .basic
.max_gas_fee_per_gas .max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()), .map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant max_priority_fee_per_gas: full_grant
.shared .basic
.max_priority_fee_per_gas .max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()), .map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant rate_limit_count: full_grant
.shared .basic
.rate_limit .rate_limit
.as_ref() .as_ref()
.map(|rl| rl.count as i32), .map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant rate_limit_window_secs: full_grant
.shared .basic
.rate_limit .rate_limit
.as_ref() .as_ref()
.map(|rl| rl.window.num_seconds() as i32), .map(|rl| rl.window.num_seconds() as i32),
@@ -262,13 +225,7 @@ impl Engine {
.get_result(conn) .get_result(conn)
.await?; .await?;
P::create_grant(&basic_grant, &full_grant.specific, conn).await?; P::create_grant(&basic_grant, &full_grant.specific, conn).await
integrity::sign_entity(conn, &vault, &full_grant, basic_grant.id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
QueryResult::Ok(basic_grant.id)
}) })
}) })
.await?; .await?;
@@ -276,36 +233,33 @@ impl Engine {
Ok(id) Ok(id)
} }
async fn list_one_kind<Kind: Policy, Y>( pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, DatabaseError> {
&self, let mut conn = self.db.get().await?;
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> Result<impl Iterator<Item = Grant<Y>>, ListError>
where
Y: From<Kind::Settings>,
{
let all_grants = Kind::find_all_grants(conn)
.await
.map_err(DatabaseError::from)?;
// Verify integrity of all grants before returning any results
for grant in &all_grants {
integrity::verify_entity(conn, &self.vault, &grant.settings, grant.id).await?;
}
Ok(all_grants.into_iter().map(|g| Grant {
id: g.id,
common_settings_id: g.common_settings_id,
settings: g.settings.generalize(),
}))
}
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, ListError> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let mut grants: Vec<Grant<SpecificGrant>> = Vec::new(); let mut grants: Vec<Grant<SpecificGrant>> = Vec::new();
grants.extend(self.list_one_kind::<EtherTransfer, _>(&mut conn).await?); grants.extend(
grants.extend(self.list_one_kind::<TokenTransfer, _>(&mut conn).await?); EtherTransfer::find_all_grants(&mut conn)
.await?
.into_iter()
.map(|g| Grant {
id: g.id,
shared_grant_id: g.shared_grant_id,
shared: g.shared,
settings: SpecificGrant::EtherTransfer(g.settings),
}),
);
grants.extend(
TokenTransfer::find_all_grants(&mut conn)
.await?
.into_iter()
.map(|g| Grant {
id: g.id,
shared_grant_id: g.shared_grant_id,
shared: g.shared,
settings: SpecificGrant::TokenTransfer(g.settings),
}),
);
Ok(grants) Ok(grants)
} }
@@ -319,7 +273,7 @@ impl Engine {
let TxKind::Call(to) = transaction.to else { let TxKind::Call(to) = transaction.to else {
return Err(VetError::ContractCreationNotSupported); return Err(VetError::ContractCreationNotSupported);
}; };
let context = EvalContext { let context = policies::EvalContext {
target, target,
chain: transaction.chain_id, chain: transaction.chain_id,
to, to,
@@ -351,261 +305,3 @@ impl Engine {
Err(VetError::UnsupportedTransactionType) Err(VetError::UnsupportedTransactionType)
} }
} }
#[cfg(test)]
mod tests {
use alloy::primitives::{Address, Bytes, U256, address};
use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
use rstest::rstest;
use crate::db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::policies::{
EvalContext, EvalViolation, SharedGrantSettings, TransactionRateLimit,
};
use super::check_shared_constraints;
const WALLET_ACCESS_ID: i32 = 1;
const CHAIN_ID: u64 = 1;
const RECIPIENT: Address = address!("1111111111111111111111111111111111111111");
fn context() -> EvalContext {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},
chain: CHAIN_ID,
to: RECIPIENT,
value: U256::ZERO,
calldata: Bytes::new(),
max_fee_per_gas: 100,
max_priority_fee_per_gas: 10,
}
}
fn shared_settings() -> SharedGrantSettings {
SharedGrantSettings {
wallet_access_id: WALLET_ACCESS_ID,
chain: CHAIN_ID,
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
}
}
async fn insert_basic_grant(
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.into(),
valid_from: shared.valid_from.map(SqliteTimestamp),
valid_until: shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: shared
.max_gas_fee_per_gas
.map(|fee| super::utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: shared
.max_priority_fee_per_gas
.map(|fee| super::utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: shared.rate_limit.as_ref().map(|limit| limit.count as i32),
rate_limit_window_secs: shared
.rate_limit
.as_ref()
.map(|limit| limit.window.num_seconds() as i32),
revoked_at: None,
})
.returning(EvmBasicGrant::as_select())
.get_result(conn)
.await
.unwrap()
}
#[rstest]
#[case::matching_chain(CHAIN_ID, false)]
#[case::mismatching_chain(CHAIN_ID + 1, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_chain_id(
#[case] context_chain: u64,
#[case] expect_mismatch: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let context = EvalContext {
chain: context_chain,
..context()
};
let violations = check_shared_constraints(&context, &shared_settings(), 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::MismatchingChainId { .. })),
expect_mismatch
);
if expect_mismatch {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::valid_from_in_bounds(Some(Utc::now() - Duration::hours(1)), None, false)]
#[case::valid_from_out_of_bounds(Some(Utc::now() + Duration::hours(1)), None, true)]
#[case::valid_until_in_bounds(None, Some(Utc::now() + Duration::hours(1)), false)]
#[case::valid_until_out_of_bounds(None, Some(Utc::now() - Duration::hours(1)), true)]
#[tokio::test]
async fn check_shared_constraints_enforces_validity_window(
#[case] valid_from: Option<chrono::DateTime<Utc>>,
#[case] valid_until: Option<chrono::DateTime<Utc>>,
#[case] expect_invalid_time: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let shared = SharedGrantSettings {
valid_from,
valid_until,
..shared_settings()
};
let violations = check_shared_constraints(&context(), &shared, 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::InvalidTime)),
expect_invalid_time
);
if expect_invalid_time {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::max_fee_within_limit(Some(U256::from(100u64)), None, 100, 10, false)]
#[case::max_fee_exceeded(Some(U256::from(99u64)), None, 100, 10, true)]
#[case::priority_fee_within_limit(None, Some(U256::from(10u64)), 100, 10, false)]
#[case::priority_fee_exceeded(None, Some(U256::from(9u64)), 100, 10, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_gas_fee_caps(
#[case] max_gas_fee_per_gas: Option<U256>,
#[case] max_priority_fee_per_gas: Option<U256>,
#[case] actual_max_fee_per_gas: u128,
#[case] actual_max_priority_fee_per_gas: u128,
#[case] expect_gas_limit_violation: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let context = EvalContext {
max_fee_per_gas: actual_max_fee_per_gas,
max_priority_fee_per_gas: actual_max_priority_fee_per_gas,
..context()
};
let shared = SharedGrantSettings {
max_gas_fee_per_gas,
max_priority_fee_per_gas,
..shared_settings()
};
let violations = check_shared_constraints(&context, &shared, 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::GasLimitExceeded { .. })),
expect_gas_limit_violation
);
if expect_gas_limit_violation {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::under_rate_limit(2, false)]
#[case::at_rate_limit(1, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_rate_limit(
#[case] rate_limit_count: u32,
#[case] expect_rate_limit_violation: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let shared = SharedGrantSettings {
rate_limit: Some(TransactionRateLimit {
count: rate_limit_count,
window: Duration::hours(1),
}),
..shared_settings()
};
let basic_grant = insert_basic_grant(&mut conn, &shared).await;
insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog {
grant_id: basic_grant.id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(),
eth_value: super::utils::u256_to_bytes(U256::ZERO).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
.execute(&mut *conn)
.await
.unwrap();
let violations = check_shared_constraints(&context(), &shared, basic_grant.id, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::RateLimitExceeded)),
expect_rate_limit_violation
);
if expect_rate_limit_violation {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
}

View File

@@ -1,8 +1,4 @@
use crate::{ use std::fmt::Display;
crypto::integrity::v1::Integrable,
db::models::{EvmBasicGrant, EvmWalletAccess},
evm::utils,
};
use alloy::primitives::{Address, Bytes, ChainId, U256}; use alloy::primitives::{Address, Bytes, ChainId, U256};
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
@@ -10,9 +6,14 @@ use diesel::{
ExpressionMethods as _, QueryDsl, SelectableHelper, result::QueryResult, sqlite::Sqlite, ExpressionMethods as _, QueryDsl, SelectableHelper, result::QueryResult, sqlite::Sqlite,
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use std::fmt::Display; use miette::Diagnostic;
use thiserror::Error; use thiserror::Error;
use crate::{
db::models::{self, EvmBasicGrant, EvmWalletAccess},
evm::utils,
};
pub mod ether_transfer; pub mod ether_transfer;
pub mod token_transfers; pub mod token_transfers;
@@ -32,31 +33,34 @@ pub struct EvalContext {
pub max_priority_fee_per_gas: u128, pub max_priority_fee_per_gas: u128,
} }
#[derive(Debug, Error)] #[derive(Debug, Error, Diagnostic)]
pub enum EvalViolation { pub enum EvalViolation {
#[error("This grant doesn't allow transactions to the target address {target}")] #[error("This grant doesn't allow transactions to the target address {target}")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_target))]
InvalidTarget { target: Address }, InvalidTarget { target: Address },
#[error("Gas limit exceeded for this grant")] #[error("Gas limit exceeded for this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::gas_limit_exceeded))]
GasLimitExceeded { GasLimitExceeded {
max_gas_fee_per_gas: Option<U256>, max_gas_fee_per_gas: Option<U256>,
max_priority_fee_per_gas: Option<U256>, max_priority_fee_per_gas: Option<U256>,
}, },
#[error("Rate limit exceeded for this grant")] #[error("Rate limit exceeded for this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::rate_limit_exceeded))]
RateLimitExceeded, RateLimitExceeded,
#[error("Transaction exceeds volumetric limits of the grant")] #[error("Transaction exceeds volumetric limits of the grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::volumetric_limit_exceeded))]
VolumetricLimitExceeded, VolumetricLimitExceeded,
#[error("Transaction is outside of the grant's validity period")] #[error("Transaction is outside of the grant's validity period")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_time))]
InvalidTime, InvalidTime,
#[error("Transaction type is not allowed by this grant")] #[error("Transaction type is not allowed by this grant")]
#[diagnostic(code(arbiter_server::evm::eval_violation::invalid_transaction_type))]
InvalidTransactionType, InvalidTransactionType,
#[error("Mismatching chain ID")]
MismatchingChainId { expected: ChainId, actual: ChainId },
} }
pub type DatabaseID = i32; pub type DatabaseID = i32;
@@ -64,12 +68,13 @@ pub type DatabaseID = i32;
#[derive(Debug)] #[derive(Debug)]
pub struct Grant<PolicySettings> { pub struct Grant<PolicySettings> {
pub id: DatabaseID, pub id: DatabaseID,
pub common_settings_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods pub shared_grant_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods
pub settings: CombinedSettings<PolicySettings>, pub shared: SharedGrantSettings,
pub settings: PolicySettings,
} }
pub trait Policy: Sized { pub trait Policy: Sized {
type Settings: Send + Sync + 'static + Into<SpecificGrant> + Integrable; type Settings: Send + Sync + 'static + Into<SpecificGrant>;
type Meaning: Display + std::fmt::Debug + Send + Sync + 'static + Into<SpecificMeaning>; type Meaning: Display + std::fmt::Debug + Send + Sync + 'static + Into<SpecificMeaning>;
fn analyze(context: &EvalContext) -> Option<Self::Meaning>; fn analyze(context: &EvalContext) -> Option<Self::Meaning>;
@@ -85,10 +90,10 @@ pub trait Policy: Sized {
// Create a new grant in the database based on the provided grant details, and return its ID // Create a new grant in the database based on the provided grant details, and return its ID
fn create_grant( fn create_grant(
basic: &EvmBasicGrant, basic: &models::EvmBasicGrant,
grant: &Self::Settings, grant: &Self::Settings,
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> impl Future<Output = QueryResult<DatabaseID>> + Send; ) -> impl std::future::Future<Output = QueryResult<DatabaseID>> + Send;
// Try to find an existing grant that matches the transaction context, and return its details if found // 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 // Additionally, return ID of basic grant for shared-logic checks like rate limits and validity periods
@@ -125,19 +130,19 @@ pub enum SpecificMeaning {
TokenTransfer(token_transfers::Meaning), TokenTransfer(token_transfers::Meaning),
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, arbiter_macros::Hashable)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TransactionRateLimit { pub struct TransactionRateLimit {
pub count: u32, pub count: u32,
pub window: Duration, pub window: Duration,
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, arbiter_macros::Hashable)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct VolumeRateLimit { pub struct VolumeRateLimit {
pub max_volume: U256, pub max_volume: U256,
pub window: Duration, pub window: Duration,
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash, arbiter_macros::Hashable)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SharedGrantSettings { pub struct SharedGrantSettings {
pub wallet_access_id: i32, pub wallet_access_id: i32,
pub chain: ChainId, pub chain: ChainId,
@@ -152,10 +157,10 @@ pub struct SharedGrantSettings {
} }
impl SharedGrantSettings { impl SharedGrantSettings {
pub(crate) fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> { fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
Ok(Self { Ok(Self {
wallet_access_id: model.wallet_access_id, wallet_access_id: model.wallet_access_id,
chain: model.chain_id.into(), 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
valid_from: model.valid_from.map(Into::into), valid_from: model.valid_from.map(Into::into),
valid_until: model.valid_until.map(Into::into), valid_until: model.valid_until.map(Into::into),
max_gas_fee_per_gas: model max_gas_fee_per_gas: model
@@ -166,11 +171,10 @@ impl SharedGrantSettings {
.max_priority_fee_per_gas .max_priority_fee_per_gas
.map(|b| utils::try_bytes_to_u256(&b)) .map(|b| utils::try_bytes_to_u256(&b))
.transpose()?, .transpose()?,
#[expect(clippy::cast_sign_loss, clippy::as_conversions, reason = "fixme! #86")]
rate_limit: match (model.rate_limit_count, model.rate_limit_window_secs) { rate_limit: match (model.rate_limit_count, model.rate_limit_window_secs) {
(Some(count), Some(window_secs)) => Some(TransactionRateLimit { (Some(count), Some(window_secs)) => Some(TransactionRateLimit {
count: count as u32, count: count as u32,
window: Duration::seconds(window_secs.into()), window: Duration::seconds(window_secs as i64),
}), }),
_ => None, _ => None,
}, },
@@ -180,7 +184,7 @@ impl SharedGrantSettings {
pub async fn query_by_id( pub async fn query_by_id(
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
id: i32, id: i32,
) -> QueryResult<Self> { ) -> diesel::result::QueryResult<Self> {
use crate::db::schema::evm_basic_grant; use crate::db::schema::evm_basic_grant;
let basic_grant: EvmBasicGrant = evm_basic_grant::table let basic_grant: EvmBasicGrant = evm_basic_grant::table
@@ -199,22 +203,7 @@ pub enum SpecificGrant {
TokenTransfer(token_transfers::Settings), TokenTransfer(token_transfers::Settings),
} }
#[derive(Debug, arbiter_macros::Hashable)] pub struct FullGrant<PolicyGrant> {
pub struct CombinedSettings<PolicyGrant> { pub basic: SharedGrantSettings,
pub shared: SharedGrantSettings,
pub specific: PolicyGrant, pub specific: PolicyGrant,
} }
impl<P> CombinedSettings<P> {
pub fn generalize<Y: From<P>>(self) -> CombinedSettings<Y> {
CombinedSettings {
shared: self.shared,
specific: self.specific.into(),
}
}
}
impl<P: Integrable> Integrable for CombinedSettings<P> {
const KIND: &'static str = P::KIND;
const VERSION: i32 = P::VERSION;
}

View File

@@ -1,31 +1,28 @@
use super::{DatabaseID, EvalContext, EvalViolation}; use std::collections::HashMap;
use crate::{ use std::fmt::Display;
crypto::integrity::v1::Integrable,
db::models::{
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit,
NewEvmEtherTransferLimit, SqliteTimestamp,
},
db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log},
db::{
models::{NewEvmEtherTransferGrant, NewEvmEtherTransferGrantTarget},
schema::{evm_ether_transfer_grant, evm_ether_transfer_grant_target},
},
evm::policies::{
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
VolumeRateLimit,
},
evm::{policies::Policy, utils},
};
use alloy::primitives::{Address, U256}; use alloy::primitives::{Address, U256};
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use diesel::{ use diesel::dsl::{auto_type, insert_into};
dsl::{auto_type, insert_into}, use diesel::sqlite::Sqlite;
prelude::*, use diesel::{ExpressionMethods, JoinOnDsl, prelude::*};
sqlite::Sqlite,
};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use std::{collections::HashMap, fmt::Display};
use crate::db::models::{
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit,
NewEvmEtherTransferLimit, SqliteTimestamp,
};
use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log};
use crate::evm::policies::{
Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
};
use crate::{
db::{
models::{self, NewEvmEtherTransferGrant, NewEvmEtherTransferGrantTarget},
schema::{evm_ether_transfer_grant, evm_ether_transfer_grant_target},
},
evm::{policies::Policy, utils},
};
#[auto_type] #[auto_type]
fn grant_join() -> _ { fn grant_join() -> _ {
@@ -34,11 +31,13 @@ fn grant_join() -> _ {
) )
} }
use super::{DatabaseID, EvalContext, EvalViolation};
// Plain ether transfer // Plain ether transfer
#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning { pub struct Meaning {
pub(crate) to: Address, to: Address,
pub(crate) value: U256, value: U256,
} }
impl Display for Meaning { impl Display for Meaning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -46,24 +45,21 @@ impl Display for Meaning {
} }
} }
impl From<Meaning> for SpecificMeaning { impl From<Meaning> for SpecificMeaning {
fn from(val: Meaning) -> Self { fn from(val: Meaning) -> SpecificMeaning {
Self::EtherTransfer(val) SpecificMeaning::EtherTransfer(val)
} }
} }
// A grant for ether transfers, which can be scoped to specific target addresses and volume limits // A grant for ether transfers, which can be scoped to specific target addresses and volume limits
#[derive(Debug, Clone, arbiter_macros::Hashable)] #[derive(Debug, Clone)]
pub struct Settings { pub struct Settings {
pub target: Vec<Address>, pub target: Vec<Address>,
pub limit: VolumeRateLimit, pub limit: VolumeRateLimit,
} }
impl Integrable for Settings {
const KIND: &'static str = "EtherTransfer";
}
impl From<Settings> for SpecificGrant { impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> Self { fn from(val: Settings) -> SpecificGrant {
Self::EtherTransfer(val) SpecificGrant::EtherTransfer(val)
} }
} }
@@ -74,7 +70,9 @@ async fn query_relevant_past_transaction(
) -> QueryResult<Vec<(U256, DateTime<Utc>)>> { ) -> QueryResult<Vec<(U256, DateTime<Utc>)>> {
let past_transactions: Vec<(Vec<u8>, SqliteTimestamp)> = evm_transaction_log::table let past_transactions: Vec<(Vec<u8>, SqliteTimestamp)> = evm_transaction_log::table
.filter(evm_transaction_log::grant_id.eq(grant_id)) .filter(evm_transaction_log::grant_id.eq(grant_id))
.filter(evm_transaction_log::signed_at.ge(SqliteTimestamp(Utc::now() - longest_window))) .filter(
evm_transaction_log::signed_at.ge(SqliteTimestamp(chrono::Utc::now() - longest_window)),
)
.select(( .select((
evm_transaction_log::eth_value, evm_transaction_log::eth_value,
evm_transaction_log::signed_at, evm_transaction_log::signed_at,
@@ -93,21 +91,20 @@ async fn query_relevant_past_transaction(
async fn check_rate_limits( async fn check_rate_limits(
grant: &Grant<Settings>, grant: &Grant<Settings>,
current_transfer_value: U256,
db: &mut impl AsyncConnection<Backend = Sqlite>, db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> { ) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new(); let mut violations = Vec::new();
let window = grant.settings.specific.limit.window; let window = grant.settings.limit.window;
let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?; let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?;
let window_start = Utc::now() - grant.settings.specific.limit.window; let window_start = chrono::Utc::now() - grant.settings.limit.window;
let prospective_cumulative_volume: U256 = past_transaction let cumulative_volume: U256 = past_transaction
.iter() .iter()
.filter(|(_, timestamp)| timestamp >= &window_start) .filter(|(_, timestamp)| timestamp >= &window_start)
.fold(current_transfer_value, |acc, (value, _)| acc + *value); .fold(U256::default(), |acc, (value, _)| acc + *value);
if prospective_cumulative_volume > grant.settings.specific.limit.max_volume { if cumulative_volume > grant.settings.limit.max_volume {
violations.push(EvalViolation::VolumetricLimitExceeded); violations.push(EvalViolation::VolumetricLimitExceeded);
} }
@@ -140,26 +137,21 @@ impl Policy for EtherTransfer {
let mut violations = Vec::new(); let mut violations = Vec::new();
// Check if the target address is within the grant's allowed targets // Check if the target address is within the grant's allowed targets
if !grant.settings.specific.target.contains(&meaning.to) { if !grant.settings.target.contains(&meaning.to) {
violations.push(EvalViolation::InvalidTarget { target: meaning.to }); violations.push(EvalViolation::InvalidTarget { target: meaning.to });
} }
let rate_violations = check_rate_limits(grant, meaning.value, db).await?; let rate_violations = check_rate_limits(grant, db).await?;
violations.extend(rate_violations); violations.extend(rate_violations);
Ok(violations) Ok(violations)
} }
async fn create_grant( async fn create_grant(
basic: &EvmBasicGrant, basic: &models::EvmBasicGrant,
grant: &Self::Settings, grant: &Self::Settings,
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<DatabaseID> { ) -> diesel::result::QueryResult<DatabaseID> {
#[expect(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "fixme! #86"
)]
let limit_id: i32 = insert_into(evm_ether_transfer_limit::table) let limit_id: i32 = insert_into(evm_ether_transfer_limit::table)
.values(NewEvmEtherTransferLimit { .values(NewEvmEtherTransferLimit {
window_secs: grant.limit.window.num_seconds() as i32, window_secs: grant.limit.window.num_seconds() as i32,
@@ -194,7 +186,7 @@ impl Policy for EtherTransfer {
async fn try_find_grant( async fn try_find_grant(
context: &EvalContext, context: &EvalContext,
conn: &mut impl AsyncConnection<Backend = Sqlite>, conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Option<Grant<Self::Settings>>> { ) -> diesel::result::QueryResult<Option<Grant<Self::Settings>>> {
let target_bytes = context.to.to_vec(); let target_bytes = context.to.to_vec();
// Find a grant where: // Find a grant where:
@@ -248,17 +240,15 @@ impl Policy for EtherTransfer {
limit: VolumeRateLimit { limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume) max_volume: utils::try_bytes_to_u256(&limit.max_volume)
.map_err(|err| diesel::result::Error::DeserializationError(Box::new(err)))?, .map_err(|err| diesel::result::Error::DeserializationError(Box::new(err)))?,
window: Duration::seconds(limit.window_secs.into()), window: chrono::Duration::seconds(limit.window_secs as i64),
}, },
}; };
Ok(Some(Grant { Ok(Some(Grant {
id: grant.id, id: grant.id,
common_settings_id: grant.basic_grant_id, shared_grant_id: grant.basic_grant_id,
settings: CombinedSettings { shared: SharedGrantSettings::try_from_model(basic_grant)?,
shared: SharedGrantSettings::try_from_model(basic_grant)?, settings,
specific: settings,
},
})) }))
} }
@@ -268,7 +258,7 @@ impl Policy for EtherTransfer {
_log_id: i32, _log_id: i32,
_grant: &Grant<Self::Settings>, _grant: &Grant<Self::Settings>,
_conn: &mut impl AsyncConnection<Backend = Sqlite>, _conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<()> { ) -> diesel::result::QueryResult<()> {
// Basic log is sufficient // Basic log is sufficient
Ok(()) Ok(())
@@ -321,7 +311,7 @@ impl Policy for EtherTransfer {
.map(|(basic, specific)| { .map(|(basic, specific)| {
let targets: Vec<Address> = targets_by_grant let targets: Vec<Address> = targets_by_grant
.get(&specific.id) .get(&specific.id)
.map(Vec::as_slice) .map(|v| v.as_slice())
.unwrap_or_default() .unwrap_or_default()
.iter() .iter()
.filter_map(|t| { .filter_map(|t| {
@@ -336,17 +326,15 @@ impl Policy for EtherTransfer {
Ok(Grant { Ok(Grant {
id: specific.id, id: specific.id,
common_settings_id: specific.basic_grant_id, shared_grant_id: specific.basic_grant_id,
settings: CombinedSettings { shared: SharedGrantSettings::try_from_model(basic)?,
shared: SharedGrantSettings::try_from_model(basic)?, settings: Settings {
specific: Settings { target: targets,
target: targets, limit: VolumeRateLimit {
limit: VolumeRateLimit { max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err( |e| diesel::result::Error::DeserializationError(Box::new(e)),
|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

@@ -1,28 +1,24 @@
use super::{EtherTransfer, Settings};
use crate::{
db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
},
evm::{
policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils,
},
};
use alloy::primitives::{Address, Bytes, U256, address}; use alloy::primitives::{Address, Bytes, U256, address};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into}; use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use crate::db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::{
policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit},
utils,
};
use super::{EtherTransfer, Settings};
const WALLET_ACCESS_ID: i32 = 1; const WALLET_ACCESS_ID: i32 = 1;
const CHAIN_ID: alloy::primitives::ChainId = 1; const CHAIN_ID: u64 = 1;
const ALLOWED: Address = address!("1111111111111111111111111111111111111111"); const ALLOWED: Address = address!("1111111111111111111111111111111111111111");
const OTHER: Address = address!("2222222222222222222222222222222222222222"); const OTHER: Address = address!("2222222222222222222222222222222222222222");
@@ -48,7 +44,7 @@ async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicG
insert_into(evm_basic_grant::table) insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant { .values(NewEvmBasicGrant {
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID as i32,
valid_from: None, valid_from: None,
valid_until: None, valid_until: None,
max_gas_fee_per_gas: None, max_gas_fee_per_gas: None,
@@ -85,6 +81,8 @@ fn shared() -> SharedGrantSettings {
} }
} }
// ── analyze ─────────────────────────────────────────────────────────────
#[test] #[test]
fn analyze_matches_empty_calldata() { fn analyze_matches_empty_calldata() {
let m = EtherTransfer::analyze(&ctx(ALLOWED, U256::from(1_000u64))).unwrap(); let m = EtherTransfer::analyze(&ctx(ALLOWED, U256::from(1_000u64))).unwrap();
@@ -101,6 +99,8 @@ fn analyze_rejects_nonempty_calldata() {
assert!(EtherTransfer::analyze(&context).is_none()); assert!(EtherTransfer::analyze(&context).is_none());
} }
// ── evaluate ────────────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn evaluate_passes_for_allowed_target() { async fn evaluate_passes_for_allowed_target() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
@@ -108,11 +108,9 @@ async fn evaluate_passes_for_allowed_target() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, shared_grant_id: 999,
settings: CombinedSettings { shared: shared(),
shared: shared(), settings: make_settings(vec![ALLOWED], 1_000_000),
specific: make_settings(vec![ALLOWED], 1_000_000),
},
}; };
let context = ctx(ALLOWED, U256::from(100u64)); let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
@@ -129,11 +127,9 @@ async fn evaluate_rejects_disallowed_target() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, shared_grant_id: 999,
settings: CombinedSettings { shared: shared(),
shared: shared(), settings: make_settings(vec![ALLOWED], 1_000_000),
specific: make_settings(vec![ALLOWED], 1_000_000),
},
}; };
let context = ctx(OTHER, U256::from(100u64)); let context = ctx(OTHER, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
@@ -161,7 +157,7 @@ async fn evaluate_passes_when_volume_within_limit() {
.values(NewEvmTransactionLog { .values(NewEvmTransactionLog {
grant_id, grant_id,
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID as i32,
eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(), eth_value: utils::u256_to_bytes(U256::from(500u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()), signed_at: SqliteTimestamp(Utc::now()),
}) })
@@ -171,11 +167,9 @@ async fn evaluate_passes_when_volume_within_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
common_settings_id: basic.id, shared_grant_id: basic.id,
settings: CombinedSettings { shared: shared(),
shared: shared(), settings,
specific: settings,
},
}; };
let context = ctx(ALLOWED, U256::from(100u64)); let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
@@ -203,8 +197,8 @@ async fn evaluate_rejects_volume_over_limit() {
.values(NewEvmTransactionLog { .values(NewEvmTransactionLog {
grant_id, grant_id,
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID as i32,
eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(), eth_value: utils::u256_to_bytes(U256::from(1_001u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()), signed_at: SqliteTimestamp(Utc::now()),
}) })
.execute(&mut *conn) .execute(&mut *conn)
@@ -213,13 +207,11 @@ async fn evaluate_rejects_volume_over_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
common_settings_id: basic.id, shared_grant_id: basic.id,
settings: CombinedSettings { shared: shared(),
shared: shared(), settings,
specific: settings,
},
}; };
let context = ctx(ALLOWED, U256::from(1u64)); let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn) let v = EtherTransfer::evaluate(&context, &m, &grant, &mut *conn)
.await .await
@@ -241,13 +233,13 @@ async fn evaluate_passes_at_exactly_volume_limit() {
.await .await
.unwrap(); .unwrap();
// Exactly at the limit including current transfer — check is `>`, so this should not violate // Exactly at the limit — the check is `>`, so this should not violate
insert_into(evm_transaction_log::table) insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog { .values(NewEvmTransactionLog {
grant_id, grant_id,
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID as i32,
eth_value: utils::u256_to_bytes(U256::from(900u64)).to_vec(), eth_value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(),
signed_at: SqliteTimestamp(Utc::now()), signed_at: SqliteTimestamp(Utc::now()),
}) })
.execute(&mut *conn) .execute(&mut *conn)
@@ -256,11 +248,9 @@ async fn evaluate_passes_at_exactly_volume_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
common_settings_id: basic.id, shared_grant_id: basic.id,
settings: CombinedSettings { shared: shared(),
shared: shared(), settings,
specific: settings,
},
}; };
let context = ctx(ALLOWED, U256::from(100u64)); let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
@@ -273,6 +263,8 @@ async fn evaluate_passes_at_exactly_volume_limit() {
); );
} }
// ── try_find_grant ───────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn try_find_grant_roundtrip() { async fn try_find_grant_roundtrip() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
@@ -290,11 +282,8 @@ async fn try_find_grant_roundtrip() {
assert!(found.is_some()); assert!(found.is_some());
let g = found.unwrap(); let g = found.unwrap();
assert_eq!(g.settings.specific.target, vec![ALLOWED]); assert_eq!(g.settings.target, vec![ALLOWED]);
assert_eq!( assert_eq!(g.settings.limit.max_volume, U256::from(1_000_000u64));
g.settings.specific.limit.max_volume,
U256::from(1_000_000u64)
);
} }
#[tokio::test] #[tokio::test]
@@ -331,36 +320,7 @@ async fn try_find_grant_wrong_target_returns_none() {
assert!(found.is_none()); assert!(found.is_none());
} }
proptest::proptest! { // ── find_all_grants ──────────────────────────────────────────────────────
#[test]
fn target_order_does_not_affect_hash(
raw_addrs in proptest::collection::vec(proptest::prelude::any::<[u8; 20]>(), 0..8),
seed in proptest::prelude::any::<u64>(),
max_volume in proptest::prelude::any::<u64>(),
window_secs in 1i64..=86400,
) {
use rand::{SeedableRng, seq::SliceRandom};
use sha2::Digest;
use arbiter_crypto::hashing::Hashable;
let addrs: Vec<Address> = raw_addrs.iter().map(|b| Address::from(*b)).collect();
let mut shuffled = addrs.clone();
shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed));
let limit = VolumeRateLimit {
max_volume: U256::from(max_volume),
window: Duration::seconds(window_secs),
};
let mut h1 = sha2::Sha256::new();
Settings { target: addrs, limit: limit.clone() }.hash(&mut h1);
let mut h2 = sha2::Sha256::new();
Settings { target: shuffled, limit }.hash(&mut h2);
proptest::prop_assert_eq!(h1.finalize(), h2.finalize());
}
}
#[tokio::test] #[tokio::test]
async fn find_all_grants_empty_db() { async fn find_all_grants_empty_db() {
@@ -387,7 +347,7 @@ async fn find_all_grants_excludes_revoked() {
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap(); let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1); assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.specific.target, vec![ALLOWED]); assert_eq!(all[0].settings.target, vec![ALLOWED]);
} }
#[tokio::test] #[tokio::test]
@@ -403,11 +363,8 @@ async fn find_all_grants_multiple_targets() {
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap(); let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1); assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.specific.target.len(), 2); assert_eq!(all[0].settings.target.len(), 2);
assert_eq!( assert_eq!(all[0].settings.limit.max_volume, U256::from(1_000_000u64));
all[0].settings.specific.limit.max_volume,
U256::from(1_000_000u64)
);
} }
#[tokio::test] #[tokio::test]

View File

@@ -1,38 +1,33 @@
use super::{DatabaseID, EvalContext, EvalViolation}; use std::collections::HashMap;
use crate::{
crypto::integrity::Integrable,
db::models::{
EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit,
NewEvmTokenTransferGrant, NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit,
SqliteTimestamp,
},
db::schema::{
evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log,
evm_token_transfer_volume_limit,
},
evm::policies::CombinedSettings,
evm::{
abi::IERC20::transferCall,
policies::{
Grant, Policy, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
},
utils,
},
};
use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo};
use alloy::{ use alloy::{
primitives::{Address, U256}, primitives::{Address, U256},
sol_types::SolCall, sol_types::SolCall,
}; };
use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo};
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use diesel::{ use diesel::dsl::{auto_type, insert_into};
dsl::{auto_type, insert_into}, use diesel::sqlite::Sqlite;
prelude::*, use diesel::{ExpressionMethods, prelude::*};
sqlite::Sqlite,
};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use std::collections::HashMap;
use crate::db::models::{
EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit, NewEvmTokenTransferGrant,
NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit, SqliteTimestamp,
};
use crate::db::schema::{
evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log,
evm_token_transfer_volume_limit,
};
use crate::evm::{
abi::IERC20::transferCall,
policies::{
Grant, Policy, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
},
utils,
};
use super::{DatabaseID, EvalContext, EvalViolation};
#[auto_type] #[auto_type]
fn grant_join() -> _ { fn grant_join() -> _ {
@@ -43,9 +38,9 @@ fn grant_join() -> _ {
#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning { pub struct Meaning {
pub token: &'static TokenInfo, token: &'static TokenInfo,
pub to: Address, to: Address,
pub value: U256, value: U256,
} }
impl std::fmt::Display for Meaning { impl std::fmt::Display for Meaning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -57,25 +52,21 @@ impl std::fmt::Display for Meaning {
} }
} }
impl From<Meaning> for SpecificMeaning { impl From<Meaning> for SpecificMeaning {
fn from(val: Meaning) -> Self { fn from(val: Meaning) -> SpecificMeaning {
Self::TokenTransfer(val) SpecificMeaning::TokenTransfer(val)
} }
} }
// A grant for token transfers, which can be scoped to specific target addresses and volume limits // A grant for token transfers, which can be scoped to specific target addresses and volume limits
#[derive(Debug, Clone, arbiter_macros::Hashable)] #[derive(Debug, Clone)]
pub struct Settings { pub struct Settings {
pub token_contract: Address, pub token_contract: Address,
pub target: Option<Address>, pub target: Option<Address>,
pub volume_limits: Vec<VolumeRateLimit>, pub volume_limits: Vec<VolumeRateLimit>,
} }
impl Integrable for Settings {
const KIND: &'static str = "TokenTransfer";
}
impl From<Settings> for SpecificGrant { impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> Self { fn from(val: Settings) -> SpecificGrant {
Self::TokenTransfer(val) SpecificGrant::TokenTransfer(val)
} }
} }
@@ -86,7 +77,10 @@ async fn query_relevant_past_transfers(
) -> QueryResult<Vec<(U256, DateTime<Utc>)>> { ) -> QueryResult<Vec<(U256, DateTime<Utc>)>> {
let past_logs: Vec<(Vec<u8>, SqliteTimestamp)> = evm_token_transfer_log::table 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::grant_id.eq(grant_id))
.filter(evm_token_transfer_log::created_at.ge(SqliteTimestamp(Utc::now() - longest_window))) .filter(
evm_token_transfer_log::created_at
.ge(SqliteTimestamp(chrono::Utc::now() - longest_window)),
)
.select(( .select((
evm_token_transfer_log::value, evm_token_transfer_log::value,
evm_token_transfer_log::created_at, evm_token_transfer_log::created_at,
@@ -107,32 +101,24 @@ async fn query_relevant_past_transfers(
async fn check_volume_rate_limits( async fn check_volume_rate_limits(
grant: &Grant<Settings>, grant: &Grant<Settings>,
current_transfer_value: U256,
db: &mut impl AsyncConnection<Backend = Sqlite>, db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> { ) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new(); let mut violations = Vec::new();
let Some(longest_window) = grant let Some(longest_window) = grant.settings.volume_limits.iter().map(|l| l.window).max() else {
.settings
.specific
.volume_limits
.iter()
.map(|l| l.window)
.max()
else {
return Ok(violations); return Ok(violations);
}; };
let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?; let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?;
for limit in &grant.settings.specific.volume_limits { for limit in &grant.settings.volume_limits {
let window_start = Utc::now() - limit.window; let window_start = chrono::Utc::now() - limit.window;
let prospective_cumulative_volume: U256 = past_transfers let cumulative_volume: U256 = past_transfers
.iter() .iter()
.filter(|(_, timestamp)| timestamp >= &window_start) .filter(|(_, timestamp)| timestamp >= &window_start)
.fold(current_transfer_value, |acc, (value, _)| acc + *value); .fold(U256::default(), |acc, (value, _)| acc + *value);
if prospective_cumulative_volume > limit.max_volume { if cumulative_volume > limit.max_volume {
violations.push(EvalViolation::VolumetricLimitExceeded); violations.push(EvalViolation::VolumetricLimitExceeded);
break; break;
} }
@@ -171,13 +157,13 @@ impl Policy for TokenTransfer {
return Ok(violations); return Ok(violations);
} }
if let Some(allowed) = grant.settings.specific.target if let Some(allowed) = grant.settings.target
&& allowed != meaning.to && allowed != meaning.to
{ {
violations.push(EvalViolation::InvalidTarget { target: meaning.to }); violations.push(EvalViolation::InvalidTarget { target: meaning.to });
} }
let rate_violations = check_volume_rate_limits(grant, meaning.value, db).await?; let rate_violations = check_volume_rate_limits(grant, db).await?;
violations.extend(rate_violations); violations.extend(rate_violations);
Ok(violations) Ok(violations)
@@ -202,11 +188,6 @@ impl Policy for TokenTransfer {
.await?; .await?;
for limit in &grant.volume_limits { 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) insert_into(evm_token_transfer_volume_limit::table)
.values(NewEvmTokenTransferVolumeLimit { .values(NewEvmTokenTransferVolumeLimit {
grant_id, grant_id,
@@ -256,7 +237,7 @@ impl Policy for TokenTransfer {
max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(|err| { max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(|err| {
diesel::result::Error::DeserializationError(Box::new(err)) diesel::result::Error::DeserializationError(Box::new(err))
})?, })?,
window: Duration::seconds(row.window_secs.into()), window: Duration::seconds(row.window_secs as i64),
}) })
}) })
.collect::<QueryResult<Vec<_>>>()?; .collect::<QueryResult<Vec<_>>>()?;
@@ -287,11 +268,9 @@ impl Policy for TokenTransfer {
Ok(Some(Grant { Ok(Some(Grant {
id: token_grant.id, id: token_grant.id,
common_settings_id: token_grant.basic_grant_id, shared_grant_id: token_grant.basic_grant_id,
settings: CombinedSettings { shared: SharedGrantSettings::try_from_model(basic_grant)?,
shared: SharedGrantSettings::try_from_model(basic_grant)?, settings,
specific: settings,
},
})) }))
} }
@@ -306,7 +285,7 @@ impl Policy for TokenTransfer {
.values(NewEvmTokenTransferLog { .values(NewEvmTokenTransferLog {
grant_id: grant.id, grant_id: grant.id,
log_id, log_id,
chain_id: context.chain.into(), chain_id: context.chain as i32,
token_contract: context.to.to_vec(), token_contract: context.to.to_vec(),
recipient_address: meaning.to.to_vec(), recipient_address: meaning.to.to_vec(),
value: utils::u256_to_bytes(meaning.value).to_vec(), value: utils::u256_to_bytes(meaning.value).to_vec(),
@@ -355,7 +334,7 @@ impl Policy for TokenTransfer {
.map(|(basic, specific)| { .map(|(basic, specific)| {
let volume_limits: Vec<VolumeRateLimit> = limits_by_grant let volume_limits: Vec<VolumeRateLimit> = limits_by_grant
.get(&specific.id) .get(&specific.id)
.map(Vec::as_slice) .map(|v| v.as_slice())
.unwrap_or_default() .unwrap_or_default()
.iter() .iter()
.map(|row| { .map(|row| {
@@ -363,7 +342,7 @@ impl Policy for TokenTransfer {
max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(|e| { max_volume: utils::try_bytes_to_u256(&row.max_volume).map_err(|e| {
diesel::result::Error::DeserializationError(Box::new(e)) diesel::result::Error::DeserializationError(Box::new(e))
})?, })?,
window: Duration::seconds(row.window_secs.into()), window: Duration::seconds(row.window_secs as i64),
}) })
}) })
.collect::<QueryResult<Vec<_>>>()?; .collect::<QueryResult<Vec<_>>>()?;
@@ -389,14 +368,12 @@ impl Policy for TokenTransfer {
Ok(Grant { Ok(Grant {
id: specific.id, id: specific.id,
common_settings_id: specific.basic_grant_id, shared_grant_id: specific.basic_grant_id,
settings: CombinedSettings { shared: SharedGrantSettings::try_from_model(basic)?,
shared: SharedGrantSettings::try_from_model(basic)?, settings: Settings {
specific: Settings { token_contract: Address::from(token_contract),
token_contract: Address::from(token_contract), target,
target, volume_limits,
volume_limits,
},
}, },
}) })
}) })

View File

@@ -1,28 +1,22 @@
use super::{Settings, TokenTransfer}; use alloy::primitives::{Address, Bytes, U256, address};
use crate::{ use alloy::sol_types::SolCall;
db::{
self, DatabaseConnection,
models::{EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, SqliteTimestamp},
schema::evm_basic_grant,
},
evm::{
abi::IERC20::transferCall,
policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils,
},
};
use alloy::{
primitives::{Address, Bytes, U256, address},
sol_types::SolCall,
};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into}; use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use crate::db::{
self, DatabaseConnection,
models::{EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, SqliteTimestamp},
schema::evm_basic_grant,
};
use crate::evm::{
abi::IERC20::transferCall,
policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit},
utils,
};
use super::{Settings, TokenTransfer};
// DAI on Ethereum mainnet — present in the static token registry // DAI on Ethereum mainnet — present in the static token registry
const CHAIN_ID: u64 = 1; const CHAIN_ID: u64 = 1;
const DAI: Address = address!("6B175474E89094C44Da98b954EedeAC495271d0F"); const DAI: Address = address!("6B175474E89094C44Da98b954EedeAC495271d0F");
@@ -62,7 +56,7 @@ async fn insert_basic(conn: &mut DatabaseConnection, revoked: bool) -> EvmBasicG
insert_into(evm_basic_grant::table) insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant { .values(NewEvmBasicGrant {
wallet_access_id: WALLET_ACCESS_ID, wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID as i32,
valid_from: None, valid_from: None,
valid_until: None, valid_until: None,
max_gas_fee_per_gas: None, max_gas_fee_per_gas: None,
@@ -104,6 +98,8 @@ fn shared() -> SharedGrantSettings {
} }
} }
// ── analyze ─────────────────────────────────────────────────────────────
#[test] #[test]
fn analyze_known_token_valid_calldata() { fn analyze_known_token_valid_calldata() {
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
@@ -129,6 +125,8 @@ fn analyze_empty_calldata_returns_none() {
assert!(TokenTransfer::analyze(&ctx(DAI, Bytes::new())).is_none()); assert!(TokenTransfer::analyze(&ctx(DAI, Bytes::new())).is_none());
} }
// ── evaluate ────────────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn evaluate_rejects_nonzero_eth_value() { async fn evaluate_rejects_nonzero_eth_value() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
@@ -136,11 +134,9 @@ async fn evaluate_rejects_nonzero_eth_value() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, shared_grant_id: 999,
settings: CombinedSettings { shared: shared(),
shared: shared(), settings: make_settings(None, None),
specific: make_settings(None, None),
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let mut context = ctx(DAI, calldata); let mut context = ctx(DAI, calldata);
@@ -167,11 +163,9 @@ async fn evaluate_passes_any_recipient_when_no_restriction() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, shared_grant_id: 999,
settings: CombinedSettings { shared: shared(),
shared: shared(), settings: make_settings(None, None),
specific: make_settings(None, None),
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -189,11 +183,9 @@ async fn evaluate_passes_matching_restricted_recipient() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, shared_grant_id: 999,
settings: CombinedSettings { shared: shared(),
shared: shared(), settings: make_settings(Some(RECIPIENT), None),
specific: make_settings(Some(RECIPIENT), None),
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -211,11 +203,9 @@ async fn evaluate_rejects_wrong_restricted_recipient() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, shared_grant_id: 999,
settings: CombinedSettings { shared: shared(),
shared: shared(), settings: make_settings(Some(RECIPIENT), None),
specific: make_settings(Some(RECIPIENT), None),
},
}; };
let calldata = transfer_calldata(OTHER, U256::from(100u64)); let calldata = transfer_calldata(OTHER, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -230,7 +220,7 @@ async fn evaluate_rejects_wrong_restricted_recipient() {
} }
#[tokio::test] #[tokio::test]
async fn evaluate_passes_volume_at_exact_limit() { async fn evaluate_passes_volume_within_limit() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
@@ -240,15 +230,16 @@ async fn evaluate_passes_volume_at_exact_limit() {
.await .await
.unwrap(); .unwrap();
// Record a past transfer of 900, with current transfer 100 => exactly 1000 limit // Record a past transfer of 500 (within 1000 limit)
insert_into(db::schema::evm_token_transfer_log::table) use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log};
.values(db::models::NewEvmTokenTransferLog { insert_into(evm_token_transfer_log::table)
.values(NewEvmTokenTransferLog {
grant_id, grant_id,
log_id: 0, log_id: 0,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID as i32,
token_contract: DAI.to_vec(), token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(), recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(900u64)).to_vec(), value: utils::u256_to_bytes(U256::from(500u64)).to_vec(),
}) })
.execute(&mut *conn) .execute(&mut *conn)
.await .await
@@ -256,11 +247,9 @@ async fn evaluate_passes_volume_at_exact_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
common_settings_id: basic.id, shared_grant_id: basic.id,
settings: CombinedSettings { shared: shared(),
shared: shared(), settings,
specific: settings,
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -285,14 +274,15 @@ async fn evaluate_rejects_volume_over_limit() {
.await .await
.unwrap(); .unwrap();
insert_into(db::schema::evm_token_transfer_log::table) use crate::db::{models::NewEvmTokenTransferLog, schema::evm_token_transfer_log};
.values(db::models::NewEvmTokenTransferLog { insert_into(evm_token_transfer_log::table)
.values(NewEvmTokenTransferLog {
grant_id, grant_id,
log_id: 0, log_id: 0,
chain_id: CHAIN_ID.into(), chain_id: CHAIN_ID as i32,
token_contract: DAI.to_vec(), token_contract: DAI.to_vec(),
recipient_address: RECIPIENT.to_vec(), recipient_address: RECIPIENT.to_vec(),
value: utils::u256_to_bytes(U256::from(1_000u64)).to_vec(), value: utils::u256_to_bytes(U256::from(1_001u64)).to_vec(),
}) })
.execute(&mut *conn) .execute(&mut *conn)
.await .await
@@ -300,13 +290,11 @@ async fn evaluate_rejects_volume_over_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
common_settings_id: basic.id, shared_grant_id: basic.id,
settings: CombinedSettings { shared: shared(),
shared: shared(), settings,
specific: settings,
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(1u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
let m = TokenTransfer::analyze(&context).unwrap(); let m = TokenTransfer::analyze(&context).unwrap();
let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn) let v = TokenTransfer::evaluate(&context, &m, &grant, &mut *conn)
@@ -325,11 +313,9 @@ async fn evaluate_no_volume_limits_always_passes() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
common_settings_id: 999, shared_grant_id: 999,
settings: CombinedSettings { shared: shared(),
shared: shared(), settings: make_settings(None, None), // no volume limits
specific: make_settings(None, None), // no volume limits
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(u64::MAX)); let calldata = transfer_calldata(RECIPIENT, U256::from(u64::MAX));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -363,13 +349,10 @@ async fn try_find_grant_roundtrip() {
assert!(found.is_some()); assert!(found.is_some());
let g = found.unwrap(); let g = found.unwrap();
assert_eq!(g.settings.specific.token_contract, DAI); assert_eq!(g.settings.token_contract, DAI);
assert_eq!(g.settings.specific.target, Some(RECIPIENT)); assert_eq!(g.settings.target, Some(RECIPIENT));
assert_eq!(g.settings.specific.volume_limits.len(), 1); assert_eq!(g.settings.volume_limits.len(), 1);
assert_eq!( assert_eq!(g.settings.volume_limits[0].max_volume, U256::from(5_000u64));
g.settings.specific.volume_limits[0].max_volume,
U256::from(5_000u64)
);
} }
#[tokio::test] #[tokio::test]
@@ -409,39 +392,7 @@ async fn try_find_grant_unknown_token_returns_none() {
assert!(found.is_none()); assert!(found.is_none());
} }
proptest::proptest! { // ── find_all_grants ──────────────────────────────────────────────────────
#[test]
fn volume_limits_order_does_not_affect_hash(
raw_limits in proptest::collection::vec(
(proptest::prelude::any::<u64>(), 1i64..=86400),
0..8,
),
seed in proptest::prelude::any::<u64>(),
) {
use rand::{SeedableRng, seq::SliceRandom};
use sha2::Digest;
use arbiter_crypto::hashing::Hashable;
let limits: Vec<VolumeRateLimit> = raw_limits
.iter()
.map(|(max_vol, window_secs)| VolumeRateLimit {
max_volume: U256::from(*max_vol),
window: Duration::seconds(*window_secs),
})
.collect();
let mut shuffled = limits.clone();
shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed));
let mut h1 = sha2::Sha256::new();
Settings { token_contract: DAI, target: None, volume_limits: limits }.hash(&mut h1);
let mut h2 = sha2::Sha256::new();
Settings { token_contract: DAI, target: None, volume_limits: shuffled }.hash(&mut h2);
proptest::prop_assert_eq!(h1.finalize(), h2.finalize());
}
}
#[tokio::test] #[tokio::test]
async fn find_all_grants_empty_db() { async fn find_all_grants_empty_db() {
@@ -483,9 +434,9 @@ async fn find_all_grants_loads_volume_limits() {
let all = TokenTransfer::find_all_grants(&mut *conn).await.unwrap(); let all = TokenTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1); assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.specific.volume_limits.len(), 1); assert_eq!(all[0].settings.volume_limits.len(), 1);
assert_eq!( assert_eq!(
all[0].settings.specific.volume_limits[0].max_volume, all[0].settings.volume_limits[0].max_volume,
U256::from(9_999u64) U256::from(9_999u64)
); );
} }

View File

@@ -1,5 +1,6 @@
use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; use std::sync::Mutex;
use crate::safe_cell::{SafeCell, SafeCellHandle as _};
use alloy::{ use alloy::{
consensus::SignableTransaction, consensus::SignableTransaction,
network::{TxSigner, TxSignerSync}, network::{TxSigner, TxSignerSync},
@@ -8,7 +9,6 @@ use alloy::{
}; };
use async_trait::async_trait; use async_trait::async_trait;
use k256::ecdsa::{self, RecoveryId, SigningKey, signature::hazmat::PrehashSigner}; use k256::ecdsa::{self, RecoveryId, SigningKey, signature::hazmat::PrehashSigner};
use std::sync::Mutex;
/// An Ethereum signer that stores its secp256k1 secret key inside a /// An Ethereum signer that stores its secp256k1 secret key inside a
/// hardware-protected [`MemSafe`] cell. /// hardware-protected [`MemSafe`] cell.
@@ -82,8 +82,8 @@ impl SafeSigner {
}) })
} }
#[expect(clippy::significant_drop_tightening, reason = "false positive")]
fn sign_hash_inner(&self, hash: &B256) -> Result<Signature> { fn sign_hash_inner(&self, hash: &B256) -> Result<Signature> {
#[allow(clippy::expect_used)]
let mut cell = self.key.lock().expect("SafeSigner mutex poisoned"); let mut cell = self.key.lock().expect("SafeSigner mutex poisoned");
let reader = cell.read(); let reader = cell.read();
let sig: (ecdsa::Signature, RecoveryId) = reader.sign_prehash(hash.as_ref())?; let sig: (ecdsa::Signature, RecoveryId) = reader.sign_prehash(hash.as_ref())?;
@@ -96,6 +96,7 @@ impl SafeSigner {
{ {
return Err(Error::TransactionChainIdMismatch { return Err(Error::TransactionChainIdMismatch {
signer: chain_id, signer: chain_id,
#[allow(clippy::expect_used)]
tx: tx.chain_id().expect("Chain ID is guaranteed to be set"), tx: tx.chain_id().expect("Chain ID is guaranteed to be set"),
}); });
} }

View File

@@ -2,20 +2,20 @@ use alloy::primitives::U256;
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
#[error("Expected {expected} bytes but got {actual} bytes")] #[error("Expected {expected} bytes but got {actual} bytes")]
pub(super) struct LengthError { pub struct LengthError {
pub(super) expected: usize, pub expected: usize,
pub(super) actual: usize, pub actual: usize,
} }
pub const fn u256_to_bytes(value: U256) -> [u8; 32] { pub fn u256_to_bytes(value: U256) -> [u8; 32] {
value.to_le_bytes() value.to_le_bytes()
} }
pub(super) fn bytes_to_u256(bytes: &[u8]) -> Option<U256> { pub fn bytes_to_u256(bytes: &[u8]) -> Option<U256> {
let bytes: [u8; 32] = bytes.try_into().ok()?; let bytes: [u8; 32] = bytes.try_into().ok()?;
Some(U256::from_le_bytes(bytes)) Some(U256::from_le_bytes(bytes))
} }
pub(super) fn try_bytes_to_u256(bytes: &[u8]) -> diesel::result::QueryResult<U256> { pub fn try_bytes_to_u256(bytes: &[u8]) -> diesel::result::QueryResult<U256> {
let bytes: [u8; 32] = bytes.try_into().map_err(|_| { let bytes: [u8; 32] = bytes.try_into().map_err(|_| {
diesel::result::Error::DeserializationError(Box::new(LengthError { diesel::result::Error::DeserializationError(Box::new(LengthError {
expected: 32, expected: 32,

View File

@@ -1,24 +1,32 @@
use crate::{
grpc::request_tracker::RequestTracker,
peers::client::{ClientConnection, session::ClientSession},
};
use arbiter_proto::{ use arbiter_proto::{
proto::client::{ proto::client::{
ClientRequest, ClientResponse, client_request::Payload as ClientRequestPayload, ClientRequest, ClientResponse, VaultState as ProtoVaultState,
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload, client_response::Payload as ClientResponsePayload,
}, },
transport::{Receiver, Sender, grpc::GrpcBi}, transport::{Receiver, Sender, grpc::GrpcBi},
}; };
use kameo::{
use kameo::actor::{ActorRef, Spawn as _}; actor::{ActorRef, Spawn as _},
error::SendError,
};
use tonic::Status; use tonic::Status;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::{
actors::{
client::{
self, ClientConnection,
session::{ClientSession, Error, HandleQueryVaultState},
},
keyholder::KeyHolderState,
},
grpc::request_tracker::RequestTracker,
};
mod auth; mod auth;
mod evm;
mod inbound; mod inbound;
mod outbound; mod outbound;
mod vault;
async fn dispatch_loop( async fn dispatch_loop(
mut bi: GrpcBi<ClientRequest, ClientResponse>, mut bi: GrpcBi<ClientRequest, ClientResponse>,
@@ -26,9 +34,7 @@ async fn dispatch_loop(
mut request_tracker: RequestTracker, mut request_tracker: RequestTracker,
) { ) {
loop { loop {
let Some(message) = bi.recv().await else { let Some(message) = bi.recv().await else { return };
return;
};
let conn = match message { let conn = match message {
Ok(conn) => conn, Ok(conn) => conn,
@@ -47,24 +53,16 @@ async fn dispatch_loop(
}; };
let Some(payload) = conn.payload else { let Some(payload) = conn.payload else {
let _ = bi let _ = bi.send(Err(Status::invalid_argument("Missing client request payload"))).await;
.send(Err(Status::invalid_argument(
"Missing client request payload",
)))
.await;
return; return;
}; };
match dispatch_inner(&actor, payload).await { match dispatch_inner(&actor, payload).await {
Ok(response) => { Ok(response) => {
if bi if bi.send(Ok(ClientResponse {
.send(Ok(ClientResponse { request_id: Some(request_id),
request_id: Some(request_id), payload: Some(response),
payload: Some(response), })).await.is_err() {
}))
.await
.is_err()
{
return; return;
} }
} }
@@ -81,10 +79,21 @@ async fn dispatch_inner(
payload: ClientRequestPayload, payload: ClientRequestPayload,
) -> Result<ClientResponsePayload, Status> { ) -> Result<ClientResponsePayload, Status> {
match payload { match payload {
ClientRequestPayload::Vault(req) => vault::dispatch(actor, req).await, ClientRequestPayload::QueryVaultState(_) => {
ClientRequestPayload::Evm(req) => evm::dispatch(actor, req).await, let state = match actor.ask(HandleQueryVaultState {}).await {
ClientRequestPayload::Auth(..) => { Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
warn!("Unsupported post-auth client auth request"); Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed,
Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed,
Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error,
Err(err) => {
warn!(error = ?err, "Failed to query vault state");
ProtoVaultState::Error
}
};
Ok(ClientResponsePayload::VaultState(state.into()))
}
payload => {
warn!(?payload, "Unsupported post-auth client request");
Err(Status::invalid_argument("Unsupported client request")) Err(Status::invalid_argument("Unsupported client request"))
} }
} }
@@ -93,20 +102,14 @@ async fn dispatch_inner(
pub async fn start(mut conn: ClientConnection, mut bi: GrpcBi<ClientRequest, ClientResponse>) { pub async fn start(mut conn: ClientConnection, mut bi: GrpcBi<ClientRequest, ClientResponse>) {
let mut request_tracker = RequestTracker::default(); let mut request_tracker = RequestTracker::default();
let client_id = match auth::start(&mut conn, &mut bi, &mut request_tracker).await { if let Err(e) = auth::start(&mut conn, &mut bi, &mut request_tracker).await {
Ok(id) => id, let mut transport = auth::AuthTransportAdapter::new(&mut bi, &mut request_tracker);
Err(err) => { let _ = transport.send(Err(e.clone())).await;
let _ = bi warn!(error = ?e, "Client authentication failed");
.send(Err(Status::unauthenticated(format!( return;
"Authentication failed: {err}",
))))
.await;
warn!(error = ?err, "Client authentication failed");
return;
}
}; };
let actor = ClientSession::spawn(ClientSession::new(conn, client_id)); let actor = client::session::ClientSession::spawn(client::session::ClientSession::new(conn));
let actor_for_cleanup = actor.clone(); let actor_for_cleanup = actor.clone();
info!("Client authenticated successfully"); info!("Client authenticated successfully");

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