7 Commits

Author SHA1 Message Date
Skipper
9dbb18ae82 WIP: some things
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-05-20 21:04:16 +02:00
Skipper
a773255935 refactor(server::db): introduced newtype wrappers for entity id's in database 2026-05-04 19:35:27 +02:00
Skipper
3f801abdff housekeeping(server): deps upgrade + diesel migration to AsyncFnOnce
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-05-01 11:22:40 +02:00
Skipper
2b44570ab4 fix(server): MacOS build version
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-19 13:47:47 +02:00
Skipper
1f9b253433 housekeeping(server): removed unused deps 2026-04-19 13:46:49 +02:00
Skipper
a1c3ffd2d1 refactor: rename to to better reflect meaning
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-19 13:41:50 +02:00
Skipper
fd25de32a1 docs: move to folder and update to new challenge payload 2026-04-18 15:17:18 +02:00
79 changed files with 1381 additions and 1413 deletions

2
.gitignore vendored
View File

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

View File

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

150
CLAUDE.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
syntax = "proto3";
package arbiter.user_agent;
package arbiter.operator;
import "user_agent/auth.proto";
import "user_agent/evm.proto";
import "user_agent/sdk_client.proto";
import "user_agent/vault/vault.proto";
import "operator/auth.proto";
import "operator/evm.proto";
import "operator/sdk_client.proto";
import "operator/vault/vault.proto";
message UserAgentRequest {
message OperatorRequest {
int32 id = 16;
oneof payload {
auth.Request auth = 1;
@@ -17,7 +17,7 @@ message UserAgentRequest {
}
}
message UserAgentResponse {
message OperatorResponse {
optional int32 id = 16;
oneof payload {
auth.Response auth = 1;

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
syntax = "proto3";
package arbiter.user_agent.vault.bootstrap;
package arbiter.operator.vault.bootstrap;
message BootstrapEncryptedKey {
bytes nonce = 1;

View File

@@ -1,6 +1,6 @@
syntax = "proto3";
package arbiter.user_agent.vault.unseal;
package arbiter.operator.vault.unseal;
message UnsealStart {
bytes client_pubkey = 1;

View File

@@ -1,11 +1,11 @@
syntax = "proto3";
package arbiter.user_agent.vault;
package arbiter.operator.vault;
import "google/protobuf/empty.proto";
import "shared/vault.proto";
import "user_agent/vault/bootstrap.proto";
import "user_agent/vault/unseal.proto";
import "operator/vault/bootstrap.proto";
import "operator/vault/unseal.proto";
message Request {
oneof payload {

View File

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

View File

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

835
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,30 +6,27 @@ resolver = "3"
[workspace.dependencies]
alloy = "2.0.0"
alloy = "2.0.4"
async-trait = "0.1.89"
base64 = "0.22.1"
chrono = { version = "0.4.44", features = ["serde"] }
ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] }
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"] }
ml-dsa = { version = "0.1.0-rc.9", features = ["zeroize"] }
mutants = "0.0.4"
prost = "0.14.3"
prost-types = { version = "0.14.3", features = ["chrono"] }
rand = "0.10.1"
rcgen = { version = "0.14.7", features = [ "aws_lc_rs", "pem", "x509-parser", "zeroize" ], default-features = false }
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"
rustls = { version = "0.23.40", features = ["aws-lc-rs", "logging", "prefer-post-quantum", "std"], default-features = false }
rustls-pki-types = "1.14.1"
sha2 = "0.11"
smlang = "0.8.0"
spki = "0.8"
thiserror = "2.0.18"
tokio = { version = "1.52.1", features = ["full"] }
tokio-stream = { version = "0.1.18", features = ["full"] }

View File

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

View File

@@ -26,13 +26,13 @@ use chrono::DateTime;
pub enum AuthError {
#[error("Server sent invalid auth challenge")]
InvalidChallenge,
#[error("Client approval denied by User Agent")]
#[error("Client approval denied by Operator")]
ApprovalDenied,
#[error("Auth challenge was not returned by server")]
MissingAuthChallenge,
#[error("No User Agents online to approve client")]
NoUserAgentsOnline,
#[error("No Operators online to approve client")]
NoOperatorsOnline,
#[error("Signing key storage error")]
Storage(#[from] StorageError),
@@ -44,7 +44,7 @@ pub enum AuthError {
fn map_auth_result(code: i32) -> AuthError {
match AuthResult::try_from(code).unwrap_or(AuthResult::Unspecified) {
AuthResult::ApprovalDenied => AuthError::ApprovalDenied,
AuthResult::NoUserAgentsOnline => AuthError::NoUserAgentsOnline,
AuthResult::NoOperatorsOnline => AuthError::NoOperatorsOnline,
AuthResult::Unspecified
| AuthResult::Success
| AuthResult::InvalidKey

View File

@@ -20,3 +20,6 @@ workspace = true
default = ["authn", "safecell"]
authn = ["dep:ml-dsa", "dep:rand"]
safecell = ["dep:memsafe"]
[lib]
doctest = false

View File

@@ -7,7 +7,7 @@ use ml_dsa::{
use rand::RngExt;
pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client";
pub static USERAGENT_CONTEXT: &[u8] = b"arbiter_user_agent";
pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator";
const NONCE_SIZE: usize = 32;
@@ -192,7 +192,7 @@ mod tests {
use crate::authn::AuthChallenge;
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, USERAGENT_CONTEXT};
use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, OPERATOR_CONTEXT};
#[test]
fn public_key_round_trip_decodes() {
@@ -227,7 +227,7 @@ mod tests {
.expect("signature should be created");
assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature));
assert!(!public_key.verify(&challenge, USERAGENT_CONTEXT, &signature));
assert!(!public_key.verify(&challenge, OPERATOR_CONTEXT, &signature));
}
#[test]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,13 +43,24 @@ create table if not exists arbiter_settings (
insert into arbiter_settings (id) values (1) on conflict do nothing;
-- ensure singleton row exists
create table if not exists useragent_client (
create table if not exists operator_identity (
id integer not null primary key,
public_key blob not null,
created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now'))
) STRICT;
create unique index if not exists uniq_useragent_client_public_key on useragent_client (public_key);
create unique index if not exists uniq_operator_identity_public_key on operator_identity (public_key);
create table if not exists operator (
id integer primary key references operator_identity(id) on delete restrict, -- same id as operator_identity
share blob not null,
share_nonce blob not null,
created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now'))
) STRICT;
create table if not exists client_metadata (
id integer not null primary key,

View File

@@ -48,7 +48,7 @@ impl Bootstrapper {
let row_count: i64 = {
let mut conn = db.get().await?;
schema::useragent_client::table
schema::operator::table
.count()
.get_result(&mut conn)
.await?

View File

@@ -3,7 +3,7 @@ use crate::{
crypto::integrity,
db::{
DatabaseError, DatabasePool,
models::{self},
models::{self, EvmWalletId},
schema,
},
evm::{
@@ -116,7 +116,7 @@ impl EvmActor {
}
#[message]
pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
pub async fn list_wallets(&self) -> Result<Vec<(EvmWalletId, Address)>, Error> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
.select(models::EvmWallet::as_select())
@@ -134,7 +134,7 @@ impl EvmActor {
#[messages]
impl EvmActor {
#[message]
pub async fn useragent_create_grant(
pub async fn operator_create_grant(
&mut self,
basic: SharedGrantSettings,
grant: SpecificGrant,
@@ -161,7 +161,7 @@ impl EvmActor {
#[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 vault = self.vault.clone();
@@ -186,7 +186,7 @@ impl EvmActor {
}
#[message]
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
pub async fn operator_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.engine.list_all_grants().await {
Ok(grants) => Ok(grants),
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use crate::{
crypto::{
KeyCell, derive_key,
@@ -6,7 +8,7 @@ use crate::{
},
db::{
self,
models::{self, RootKeyHistory},
models::{self, OperatorId, OperatorIdentityId, RootKeyHistory, RootKeyHistoryId},
schema::{self},
},
};
@@ -15,17 +17,17 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _};
use chrono::Utc;
use diesel::{
ExpressionMethods as _, OptionalExtension, QueryDsl, SelectableHelper,
dsl::{insert_into, update},
dsl::{count, insert_into, update},
select,
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::{KeyInit as _, Mac as _};
use hmac::{KeyInit as _, Mac as _, digest::common};
use kameo::{Actor, Reply, actor::ActorRef, messages};
use kameo_actors::message_bus::{MessageBus, Publish};
use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info};
pub mod events {
#[derive(Clone, Copy)]
pub struct Bootstrapped;
@@ -63,8 +65,17 @@ pub enum Error {
BrokenDatabase,
}
#[derive(Debug, thiserror::Error)]
pub enum UnsealError {}
#[derive(Debug, thiserror::Error)]
pub enum BootstrapError {
#[error("That operator already contributed his share")]
AlreadyContributed,
}
struct Unsealed {
root_key_history_id: i32,
root_key_history_id: RootKeyHistoryId,
root_key: KeyCell,
}
@@ -73,8 +84,16 @@ struct Unsealed {
enum State {
#[default]
Unbootstrapped,
Bootstrapping {
declared_operators: u64,
current_passphrases: HashMap<OperatorIdentityId, SafeCell<Vec<u8>>>,
},
Sealed {
root_key_history_id: i32,
threshold: u64, // basically, quorum size
root_key_history_id: RootKeyHistoryId,
current_shares: HashMap<OperatorId, SafeCell<Vec<u8>>>,
},
Unsealed(Unsealed),
}
@@ -90,7 +109,6 @@ pub struct Vault {
events: ActorRef<MessageBus>,
}
#[messages]
impl Vault {
pub async fn new(db: db::DatabasePool, events: ActorRef<MessageBus>) -> Result<Self, Error> {
let state = {
@@ -103,9 +121,17 @@ impl Vault {
.await?;
match root_key_history {
Some(root_key_history) => State::Sealed {
Some(root_key_history) => {
let operator_count: i64 = schema::operator::table
.count()
.get_result(&mut conn)
.await?;
State::Sealed {
root_key_history_id: root_key_history.id,
},
current_shares: HashMap::default(),
threshold: shamir_threshold(operator_count.cast_unsigned()), // invariant: db couldn't return negative number of rows
}
}
None => State::Unbootstrapped,
}
};
@@ -115,21 +141,23 @@ impl Vault {
// Exclusive transaction to avoid race condtions if multiple vaults write
// additional layer of protection against nonce-reuse
async fn get_new_nonce(pool: &db::DatabasePool, root_key_id: i32) -> Result<Nonce, Error> {
async fn get_new_nonce(
pool: &db::DatabasePool,
root_key_id: RootKeyHistoryId,
) -> Result<Nonce, Error> {
let mut conn = pool.get().await?;
let nonce = conn
.exclusive_transaction(|conn| {
Box::pin(async move {
.exclusive_transaction(async |conn| {
let current_nonce: Vec<u8> = schema::root_key_history::table
.filter(schema::root_key_history::id.eq(root_key_id))
.select(schema::root_key_history::data_encryption_nonce)
.first(conn)
.first(&mut *conn)
.await?;
let mut nonce = Nonce::try_from(current_nonce.as_slice()).map_err(|()| {
error!(
"Broken database: invalid nonce for root key history id={}",
"Broken database: invalid nonce for root key history id={:#?}",
root_key_id
);
Error::BrokenDatabase
@@ -139,12 +167,11 @@ impl Vault {
update(schema::root_key_history::table)
.filter(schema::root_key_history::id.eq(root_key_id))
.set(schema::root_key_history::data_encryption_nonce.eq(nonce.to_vec()))
.execute(conn)
.execute(&mut *conn)
.await?;
Result::<_, Error>::Ok(nonce)
})
})
.await?;
Ok(nonce)
@@ -153,19 +180,28 @@ impl Vault {
const fn expect_unsealed(state: &mut State) -> Result<&mut Unsealed, Error> {
match state {
State::Unsealed(unsealed) => Ok(unsealed),
State::Bootstrapping { .. } => Err(Error::NotBootstrapped),
State::Unbootstrapped => Err(Error::NotBootstrapped),
State::Sealed { .. } => Err(Error::Sealed),
}
}
#[message]
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if !matches!(self.state, State::Unbootstrapped) {
pub async fn finalize_bootstrap(&mut self) -> Result<(), Error> {
let State::Bootstrapping {
declared_operators,
current_passphrases,
} = &mut self.state
else {
return Err(Error::AlreadyBootstrapped);
}
let salt = v1::generate_salt();
let mut seal_key = derive_key(seal_key_raw, &salt);
};
let mut root_key = KeyCell::new_secure_random();
let root_key_salt = v1::generate_salt();
let mut seal_key = KeyCell::new_secure_random();
let shares = seal_key.0.read_inline(|seal_key| {
generate_shamir_shares(current_passphrases.len() as u64, seal_key.as_slice())
});
// Zero nonces are fine because they are one-time
let root_key_nonce = Nonce::default();
@@ -181,32 +217,42 @@ impl Vault {
})
})?;
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let mut conn = self.db.get().await?;
let data_encryption_nonce_bytes = data_encryption_nonce.to_vec();
let root_key_history_id = conn
.transaction(|conn| {
Box::pin(async move {
let root_key_history_id: i32 = insert_into(schema::root_key_history::table)
.transaction(async |conn| {
for ((operator_id, raw_passphrase), raw_share) in
current_passphrases.iter_mut().zip(shares.iter())
{
let salt = v1::generate_salt();
let mut share_seal_key = derive_key(&mut raw_passphrase, &salt);
let share_encryption_nonce = Nonce::default();
let share_key = derive_key(&mut raw_passphrase, &salt);
}
let root_key_history_id = insert_into(schema::root_key_history::table)
.values(&models::NewRootKeyHistory {
ciphertext: root_key_ciphertext,
ciphertext: root_key_ciphertext.clone(),
tag: v1::ROOT_KEY_TAG.to_vec(),
root_key_encryption_nonce: root_key_nonce.to_vec(),
data_encryption_nonce: data_encryption_nonce_bytes,
data_encryption_nonce: data_encryption_nonce_bytes.clone(),
schema_version: 1,
salt: salt.to_vec(),
salt: root_key_salt.to_vec(),
})
.returning(schema::root_key_history::id)
.get_result(conn)
.get_result(&mut *conn)
.await?;
update(schema::arbiter_settings::table)
.set(schema::arbiter_settings::root_key_id.eq(root_key_history_id))
.execute(conn)
.execute(&mut *conn)
.await?;
Result::<_, diesel::result::Error>::Ok(root_key_history_id)
})
Result::<_, diesel::result::Error>::Ok(RootKeyHistoryId::from_raw(
root_key_history_id,
))
})
.await?;
@@ -220,11 +266,59 @@ impl Vault {
Ok(())
}
}
// Seal / unseal / bootstrap stuff. Will be separated into another actor, eventually
#[messages]
impl Vault {
#[message]
pub async fn start_bootstrap(&mut self, declared_operators: u64) -> Result<(), Error> {
if !matches!(&self.state, State::Unbootstrapped) {
return Err(Error::AlreadyBootstrapped);
}
self.state = State::Bootstrapping {
declared_operators,
current_passphrases: HashMap::default(),
};
Ok(())
}
#[message]
pub async fn try_unseal(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
pub async fn contribute_bootstrap(
&mut self,
operator: OperatorIdentityId,
key_raw: SafeCell<Vec<u8>>,
) -> Result<(), Error> {
let State::Bootstrapping {
current_passphrases,
declared_operators,
} = &mut self.state
else {
return Err(Error::AlreadyBootstrapped);
};
if current_passphrases.contains_key(&operator) {
return Err(Error::AlreadyBootstrapped);
}
current_passphrases.insert(operator, key_raw);
if current_passphrases.len() == declared_operators {
return self.finalize_bootstrap(seal_key_raw);
}
Ok(())
}
#[message]
pub async fn contribute_unseal(
&mut self,
operator: OperatorId,
key_raw: SafeCell<Vec<u8>>,
) -> Result<(), Error> {
let State::Sealed {
root_key_history_id,
current_shares,
} = &self.state
else {
return Err(Error::NotBootstrapped);
@@ -245,7 +339,7 @@ impl Vault {
error!("Broken database: invalid salt for root key");
Error::BrokenDatabase
})?;
let mut seal_key = derive_key(seal_key_raw, &salt);
let mut seal_key = derive_key(key_raw, &salt);
let mut root_key = SafeCell::new(current_key.ciphertext.clone());
@@ -276,6 +370,25 @@ impl Vault {
Ok(())
}
#[message]
pub async fn seal(&mut self) -> Result<(), Error> {
let Unsealed {
root_key_history_id,
..
} = Self::expect_unsealed(&mut self.state)?;
self.state = State::Sealed {
root_key_history_id: *root_key_history_id,
current_shares: HashMap::new(),
};
let _ = self.events.tell(Publish(events::VaultResealed)).await;
Ok(())
}
}
// Server-side cryptographic operations
#[messages]
impl Vault {
#[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let Unsealed { root_key, .. } = Self::expect_unsealed(&mut self.state)?;
@@ -344,7 +457,10 @@ impl Vault {
}
#[message]
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
pub fn sign_integrity(
&mut self,
mac_input: Vec<u8>,
) -> Result<(RootKeyHistoryId, Vec<u8>), Error> {
let Unsealed {
root_key,
root_key_history_id,
@@ -356,7 +472,7 @@ impl Vault {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
hmac.update(&root_key_history_id.to_be_bytes());
hmac.update(&root_key_history_id.to_raw().to_be_bytes());
hmac.update(&mac_input);
let mac = hmac.finalize().into_bytes().to_vec();
@@ -368,7 +484,7 @@ impl Vault {
&mut self,
mac_input: Vec<u8>,
expected_mac: Vec<u8>,
key_version: i32,
key_version: RootKeyHistoryId,
) -> Result<bool, Error> {
let Unsealed {
root_key,
@@ -385,25 +501,47 @@ impl Vault {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
});
hmac.update(&key_version.to_be_bytes());
hmac.update(&key_version.to_raw().to_be_bytes());
hmac.update(&mac_input);
Ok(hmac.verify_slice(&expected_mac).is_ok())
}
#[message]
pub async fn seal(&mut self) -> Result<(), Error> {
let Unsealed {
root_key_history_id,
..
} = Self::expect_unsealed(&mut self.state)?;
self.state = State::Sealed {
root_key_history_id: *root_key_history_id,
};
let _ = self.events.tell(Publish(events::VaultResealed)).await;
Ok(())
}
/// According to the spec, the quorum is 50% + 1
/// with exception for 1 and 2 operators, those require exactly the number of operators registered
fn shamir_threshold(comittee_size: u64) -> u64 {
if comittee_size == 2 || comittee_size == 1 {
return comittee_size;
}
let half_comittee = match comittee_size % 2 != 0 {
true => (comittee_size - 1) / 2,
false => comittee_size / 2,
};
half_comittee + 1
}
/// Beware: this function accepts raw key references (without memory protection)
fn generate_shamir_shares(threshold: u64, key: &[u8]) -> Vec<SafeCell<Vec<u8>>> {
use vsss_rs::{shamir, *};
type P256Share = DefaultShare<IdentifierPrimeField<Scalar>, IdentifierPrimeField<Scalar>>;
let mut osrng = rand_core::OsRng::default();
let sk = SecretKey::random(&mut osrng);
let nzs = sk.to_nonzero_scalar();
let shared_secret = IdentifierPrimeField(*nzs.as_ref());
let res = shamir::split_secret::<P256Share>(2, 3, &shared_secret, &mut osrng);
assert!(res.is_ok());
let shares = res.unwrap();
let res = shares.combine();
assert!(res.is_ok());
let scalar = res.unwrap();
let nzs_dup = NonZeroScalar::from_repr(scalar.0.to_repr()).unwrap();
let sk_dup = SecretKey::from(nzs_dup);
assert_eq!(sk_dup.to_bytes(), sk.to_bytes());
}
#[cfg(test)]
@@ -418,7 +556,7 @@ mod tests {
.await
.unwrap();
let seal_key = SafeCell::new(b"test-seal-key".to_vec());
actor.bootstrap(seal_key).await.unwrap();
actor.finalize_bootstrap(seal_key).await.unwrap();
actor
}
@@ -444,8 +582,8 @@ mod tests {
assert!(n2.to_vec() > n1.to_vec(), "nonce must increase");
let mut conn = db.get().await.unwrap();
let root_row: models::RootKeyHistory = schema::root_key_history::table
.select(models::RootKeyHistory::as_select())
let root_row: RootKeyHistory = schema::root_key_history::table
.select(RootKeyHistory::as_select())
.first(&mut conn)
.await
.unwrap();

View File

@@ -174,8 +174,7 @@ impl TlsManager {
{
let mut conn = db.get().await?;
conn.transaction(|conn| {
Box::pin(async {
conn.transaction(async |conn| {
let new_tls_history = NewTlsHistory {
cert: new_cert.cert.pem(),
cert_key: new_cert.cert_key.serialize_pem(),
@@ -186,17 +185,16 @@ impl TlsManager {
let inserted_tls_history: i32 = diesel::insert_into(tls_history::table)
.values(&new_tls_history)
.returning(tls_history::id)
.get_result(conn)
.get_result(&mut *conn)
.await?;
diesel::update(arbiter_settings::table)
.set(arbiter_settings::tls_id.eq(inserted_tls_history))
.execute(conn)
.execute(&mut *conn)
.await?;
Result::<_, diesel::result::Error>::Ok(())
})
})
.await?;
}

View File

@@ -28,7 +28,7 @@ impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
if value.len() != size_of::<Key>() {
return Err(());
}
let cell = SafeCell::new_inline(|cell_write: &mut Key| {
let cell = SafeCell::new_inline_default(|cell_write: &mut Key| {
cell_write.copy_from_slice(&value);
});
Ok(Self(cell))
@@ -37,7 +37,7 @@ impl TryFrom<SafeCell<Vec<u8>>> for KeyCell {
impl KeyCell {
pub fn new_secure_random() -> Self {
let key = SafeCell::new_inline(|key_buffer: &mut Key| {
let key = SafeCell::new_inline_default(|key_buffer: &mut Key| {
let mut rng = StdRng::try_from_rng(&mut SysRng)
.expect("Rng failure is unrecoverable and should panic");
rng.fill_bytes(key_buffer);
@@ -94,7 +94,7 @@ impl KeyCell {
}
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
pub fn derive_key(password: &mut SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
let params = {
#[cfg(debug_assertions)]
{

View File

@@ -79,10 +79,41 @@ pub mod types {
}
}
#[derive(Debug, FromSqlRow, AsExpression, Clone)]
macro_rules! declare_id {
($name:ident) => {
#[derive(Debug, FromSqlRow, AsExpression, Clone, Hash, Copy, PartialEq, Eq)]
#[diesel(sql_type = Integer)]
#[repr(transparent)] // hint compiler to optimize the wrapper struct away
pub struct ChainId(pub i32);
pub struct $name(i32);
impl $name {
pub const fn to_raw(self) -> i32 {
self.0
}
pub const fn from_raw(raw: i32) -> Self {
Self(raw)
}
}
impl FromSql<Integer, Sqlite> for $name {
fn from_sql(
bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>,
) -> diesel::deserialize::Result<Self> {
FromSql::<Integer, Sqlite>::from_sql(bytes).map(Self)
}
}
impl ToSql<Integer, Sqlite> for $name {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
}
}
};
}
declare_id!(ChainId);
#[expect(
clippy::cast_sign_loss,
@@ -103,21 +134,13 @@ pub mod types {
}
};
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 {
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
) -> diesel::serialize::Result {
ToSql::<Integer, Sqlite>::to_sql(&self.0, out)
}
}
declare_id!(OperatorId);
declare_id!(OperatorIdentityId);
declare_id!(AeadEncryptedId);
declare_id!(RootKeyHistoryId);
declare_id!(TlsHistoryId);
declare_id!(EvmWalletId);
declare_id!(ClientId);
}
pub use types::*;
@@ -130,12 +153,12 @@ pub use types::*;
)]
#[diesel(table_name = aead_encrypted, check_for_backend(Sqlite))]
pub struct AeadEncrypted {
pub id: i32,
pub id: AeadEncryptedId,
pub ciphertext: Vec<u8>,
pub tag: Vec<u8>,
pub current_nonce: Vec<u8>,
pub schema_version: i32,
pub associated_root_key_id: i32, // references root_key_history.id
pub associated_root_key_id: RootKeyHistoryId,
pub created_at: SqliteTimestamp,
}
@@ -148,7 +171,7 @@ pub struct AeadEncrypted {
attributes_with = "deriveless"
)]
pub struct RootKeyHistory {
pub id: i32,
pub id: RootKeyHistoryId,
pub ciphertext: Vec<u8>,
pub tag: Vec<u8>,
pub root_key_encryption_nonce: Vec<u8>,
@@ -166,7 +189,7 @@ pub struct RootKeyHistory {
attributes_with = "deriveless"
)]
pub struct TlsHistory {
pub id: i32,
pub id: TlsHistoryId,
pub cert: String,
pub cert_key: String, // PEM Encoded private key
pub ca_cert: String, // PEM Encoded certificate for cert signing
@@ -191,7 +214,7 @@ pub struct ArbiterSettings {
attributes_with = "deriveless"
)]
pub struct EvmWallet {
pub id: i32,
pub id: EvmWalletId,
pub address: Vec<u8>,
pub aead_encrypted_id: i32,
pub created_at: SqliteTimestamp,
@@ -213,7 +236,7 @@ pub struct EvmWallet {
)]
pub struct EvmWalletAccess {
pub id: i32,
pub wallet_id: i32,
pub wallet_id: EvmWalletId,
pub client_id: i32,
pub created_at: SqliteTimestamp,
}
@@ -240,7 +263,7 @@ pub struct ProgramClientMetadataHistory {
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = schema::program_client, check_for_backend(Sqlite))]
pub struct ProgramClient {
pub id: i32,
pub id: ClientId,
pub public_key: Vec<u8>,
pub metadata_id: i32,
pub created_at: SqliteTimestamp,
@@ -248,14 +271,24 @@ pub struct ProgramClient {
}
#[derive(Queryable, Debug)]
#[diesel(table_name = schema::useragent_client, check_for_backend(Sqlite))]
pub struct UseragentClient {
pub id: i32,
#[diesel(table_name = schema::operator_client, check_for_backend(Sqlite))]
pub struct OperatorClient {
pub id: OperatorIdentityId,
pub public_key: Vec<u8>,
pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp,
}
#[derive(Queryable, Debug)]
#[diesel(table_name = schema::operator, check_for_backend(Sqlite))]
pub struct Operator {
pub id: OperatorId,
pub share: Vec<u8>,
pub share_nonce: Vec<u8>,
pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp,
}
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = evm_ether_transfer_limit, check_for_backend(Sqlite))]
#[view(
@@ -399,7 +432,7 @@ pub struct IntegrityEnvelope {
pub entity_kind: String,
pub entity_id: Vec<u8>,
pub payload_version: i32,
pub key_version: i32,
pub key_version: RootKeyHistoryId,
pub mac: Vec<u8>,
pub signed_at: SqliteTimestamp,
pub created_at: SqliteTimestamp,

View File

@@ -152,6 +152,25 @@ diesel::table! {
}
}
diesel::table! {
operator (id) {
id -> Nullable<Integer>,
share -> Binary,
share_nonce -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
operator_identity (id) {
id -> Integer,
public_key -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::table! {
program_client (id) {
id -> Integer,
@@ -185,15 +204,6 @@ diesel::table! {
}
}
diesel::table! {
useragent_client (id) {
id -> Integer,
public_key -> Binary,
created_at -> Integer,
updated_at -> Integer,
}
}
diesel::joinable!(aead_encrypted -> root_key_history (associated_root_key_id));
diesel::joinable!(arbiter_settings -> root_key_history (root_key_id));
diesel::joinable!(arbiter_settings -> tls_history (tls_id));
@@ -212,6 +222,7 @@ diesel::joinable!(evm_transaction_log -> evm_wallet_access (wallet_access_id));
diesel::joinable!(evm_wallet -> aead_encrypted (aead_encrypted_id));
diesel::joinable!(evm_wallet_access -> evm_wallet (wallet_id));
diesel::joinable!(evm_wallet_access -> program_client (client_id));
diesel::joinable!(operator -> operator_identity (id));
diesel::joinable!(program_client -> client_metadata (metadata_id));
diesel::allow_tables_to_appear_in_same_query!(
@@ -230,8 +241,9 @@ diesel::allow_tables_to_appear_in_same_query!(
evm_wallet,
evm_wallet_access,
integrity_envelope,
operator,
operator_identity,
program_client,
root_key_history,
tls_history,
useragent_client,
);

View File

@@ -179,8 +179,7 @@ impl Engine {
}
if run_kind == RunKind::Execution {
conn.transaction(|conn| {
Box::pin(async move {
conn.transaction(async |conn| {
let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog {
grant_id: grant.common_settings_id,
@@ -190,14 +189,13 @@ impl Engine {
signed_at: Utc::now().into(),
})
.returning(evm_transaction_log::id)
.get_result(conn)
.get_result(&mut *conn)
.await?;
P::record_transaction(&context, meaning, log_id, &grant, conn).await?;
P::record_transaction(&context, meaning, log_id, &grant, &mut *conn).await?;
QueryResult::Ok(())
})
})
.await
.map_err(DatabaseError::from)?;
}
@@ -222,8 +220,7 @@ impl Engine {
let vault = self.vault.clone();
let id = conn
.transaction(|conn| {
Box::pin(async move {
.transaction(async |conn| {
use schema::evm_basic_grant;
#[expect(
@@ -259,18 +256,17 @@ impl Engine {
revoked_at: None,
})
.returning(evm_basic_grant::all_columns)
.get_result(conn)
.get_result(&mut *conn)
.await?;
P::create_grant(&basic_grant, &full_grant.specific, conn).await?;
P::create_grant(&basic_grant, &full_grant.specific, &mut *conn).await?;
integrity::sign_entity(conn, &vault, &full_grant, basic_grant.id)
integrity::sign_entity(&mut *conn, &vault, &full_grant, basic_grant.id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
QueryResult::Ok(basic_grant.id)
})
})
.await?;
Ok(id)
@@ -363,7 +359,8 @@ mod tests {
use crate::db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog,
SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
};
@@ -381,7 +378,7 @@ mod tests {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
wallet_id: EvmWalletId::from_raw(5),
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},

View File

@@ -3,7 +3,8 @@ use crate::{
db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, NewEvmTransactionLog,
SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
},
@@ -31,7 +32,7 @@ fn ctx(to: Address, value: U256) -> EvalContext {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
wallet_id: EvmWalletId::from_raw(10),
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},

View File

@@ -2,7 +2,7 @@ use super::{Settings, TokenTransfer};
use crate::{
db::{
self, DatabaseConnection,
models::{EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, SqliteTimestamp},
models::{EvmBasicGrant, EvmWalletAccess, EvmWalletId, NewEvmBasicGrant, SqliteTimestamp},
schema::evm_basic_grant,
},
evm::{
@@ -45,7 +45,7 @@ fn ctx(to: Address, calldata: Bytes) -> EvalContext {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
wallet_id: EvmWalletId::from_raw(10),
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},

View File

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

View File

@@ -176,8 +176,8 @@ impl Convert for auth::Error {
InvalidChallengeSolution => ProtoAuthResult::InvalidSignature,
ApproveError(auth::ApproveError::Denied) => ProtoAuthResult::ApprovalDenied,
ApproveError(auth::ApproveError::Upstream(
crate::actors::flow_coordinator::ApprovalError::NoUserAgentsConnected,
)) => ProtoAuthResult::NoUserAgentsOnline,
crate::actors::flow_coordinator::ApprovalError::NoOperatorsConnected,
)) => ProtoAuthResult::NoOperatorsOnline,
ApproveError(auth::ApproveError::Internal)
| DatabasePoolUnavailable
| DatabaseOperationFailed

View File

@@ -31,6 +31,7 @@ pub(super) async fn dispatch(
VaultRequestPayload::QueryState(()) => {
let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(VaultState::Bootstrapping) => ProtoVaultState::Boostrapping,
Ok(VaultState::Sealed) => ProtoVaultState::Sealed,
Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed,
Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,10 @@
use crate::{
db::models::{CoreEvmWalletAccess, NewEvmWalletAccess},
db::models::{CoreEvmWalletAccess, EvmWalletId, NewEvmWalletAccess},
evm::policies::{
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer,
token_transfers,
},
grpc::Convert,
grpc::TryConvert,
grpc::{Convert, TryConvert},
};
use arbiter_proto::{
proto::evm::{
@@ -14,7 +13,7 @@ use arbiter_proto::{
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
specific_grant::Grant as ProtoSpecificGrantType,
},
proto::user_agent::sdk_client::{WalletAccess, WalletAccessEntry as SdkClientWalletAccess},
proto::operator::sdk_client::{WalletAccess, WalletAccessEntry as SdkClientWalletAccess},
};
use alloy::primitives::{Address, U256};
@@ -150,7 +149,7 @@ impl Convert for WalletAccess {
fn convert(self) -> Self::Output {
NewEvmWalletAccess {
wallet_id: self.wallet_id,
wallet_id: EvmWalletId::from_raw(self.wallet_id),
client_id: self.sdk_client_id,
}
}
@@ -165,7 +164,7 @@ impl TryConvert for SdkClientWalletAccess {
return Err(Status::invalid_argument("Missing wallet access entry"));
};
Ok(CoreEvmWalletAccess {
wallet_id: access.wallet_id,
wallet_id: EvmWalletId::from_raw(access.wallet_id),
client_id: access.sdk_client_id,
id: self.id,
})

View File

@@ -10,7 +10,7 @@ use arbiter_proto::proto::{
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
specific_grant::Grant as ProtoSpecificGrantType,
},
user_agent::sdk_client::{WalletAccess, WalletAccessEntry as ProtoSdkClientWalletAccess},
operator::sdk_client::{WalletAccess, WalletAccessEntry as ProtoSdkClientWalletAccess},
};
use chrono::{DateTime, Utc};
@@ -103,7 +103,7 @@ impl Convert for EvmWalletAccess {
Self::Output {
id: self.id,
access: Some(WalletAccess {
wallet_id: self.wallet_id,
wallet_id: self.wallet_id.to_raw(),
sdk_client_id: self.client_id,
}),
}

View File

@@ -1,8 +1,8 @@
use crate::{
db::models::NewEvmWalletAccess,
grpc::Convert,
peers::user_agent::{
OutOfBand, UserAgentSession,
peers::operator::{
OperatorSession, OutOfBand,
session::handlers::{
HandleGrantEvmWalletAccess, HandleListWalletAccess, HandleNewClientApprove,
HandleRevokeEvmWalletAccess, HandleSdkClientList,
@@ -11,8 +11,8 @@ use crate::{
};
use arbiter_crypto::authn;
use arbiter_proto::proto::{
shared::ClientInfo as ProtoClientMetadata,
user_agent::{
operator::{
operator_response::Payload as OperatorResponsePayload,
sdk_client::{
self as proto_sdk_client, ConnectionCancel as ProtoSdkClientConnectionCancel,
ConnectionRequest as ProtoSdkClientConnectionRequest,
@@ -24,21 +24,21 @@ use arbiter_proto::proto::{
request::Payload as SdkClientRequestPayload,
response::Payload as SdkClientResponsePayload,
},
user_agent_response::Payload as UserAgentResponsePayload,
},
shared::ClientInfo as ProtoClientMetadata,
};
use kameo::actor::ActorRef;
use tonic::Status;
use tracing::{info, warn};
const fn wrap_sdk_client_response(payload: SdkClientResponsePayload) -> UserAgentResponsePayload {
UserAgentResponsePayload::SdkClient(proto_sdk_client::Response {
const fn wrap_sdk_client_response(payload: SdkClientResponsePayload) -> OperatorResponsePayload {
OperatorResponsePayload::SdkClient(proto_sdk_client::Response {
payload: Some(payload),
})
}
pub(super) fn out_of_band_payload(oob: OutOfBand) -> UserAgentResponsePayload {
pub(super) fn out_of_band_payload(oob: OutOfBand) -> OperatorResponsePayload {
match oob {
OutOfBand::ClientConnectionRequest { profile } => wrap_sdk_client_response(
SdkClientResponsePayload::ConnectionRequest(ProtoSdkClientConnectionRequest {
@@ -59,9 +59,9 @@ pub(super) fn out_of_band_payload(oob: OutOfBand) -> UserAgentResponsePayload {
}
pub(super) async fn dispatch(
actor: &ActorRef<UserAgentSession>,
actor: &ActorRef<OperatorSession>,
req: proto_sdk_client::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> {
) -> Result<Option<OperatorResponsePayload>, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument(
"Missing SDK client request payload",
@@ -87,9 +87,9 @@ pub(super) async fn dispatch(
}
async fn handle_connection_response(
actor: &ActorRef<UserAgentSession>,
actor: &ActorRef<OperatorSession>,
resp: ProtoSdkClientConnectionResponse,
) -> Result<Option<UserAgentResponsePayload>, Status> {
) -> Result<Option<OperatorResponsePayload>, Status> {
let pubkey = authn::PublicKey::try_from(resp.pubkey.as_slice())
.map_err(|()| Status::invalid_argument("Invalid ML-DSA public key"))?;
@@ -108,14 +108,14 @@ async fn handle_connection_response(
}
async fn handle_list(
actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> {
actor: &ActorRef<OperatorSession>,
) -> Result<Option<OperatorResponsePayload>, Status> {
let result = match actor.ask(HandleSdkClientList {}).await {
Ok(clients) => ProtoSdkClientListResult::Clients(ProtoSdkClientList {
clients: clients
.into_iter()
.map(|(client, metadata)| ProtoSdkClientEntry {
id: client.id,
id: client.id.to_raw(),
pubkey: client.public_key.clone(),
info: Some(ProtoClientMetadata {
name: metadata.name,
@@ -144,9 +144,9 @@ async fn handle_list(
}
async fn handle_grant_wallet_access(
actor: &ActorRef<UserAgentSession>,
actor: &ActorRef<OperatorSession>,
req: ProtoSdkClientGrantWalletAccess,
) -> Result<Option<UserAgentResponsePayload>, Status> {
) -> Result<Option<OperatorResponsePayload>, Status> {
let entries: Vec<NewEvmWalletAccess> = req.accesses.into_iter().map(Convert::convert).collect();
match actor.ask(HandleGrantEvmWalletAccess { entries }).await {
Ok(()) => {
@@ -161,9 +161,9 @@ async fn handle_grant_wallet_access(
}
async fn handle_revoke_wallet_access(
actor: &ActorRef<UserAgentSession>,
actor: &ActorRef<OperatorSession>,
req: ProtoSdkClientRevokeWalletAccess,
) -> Result<Option<UserAgentResponsePayload>, Status> {
) -> Result<Option<OperatorResponsePayload>, Status> {
match actor
.ask(HandleRevokeEvmWalletAccess {
entries: req.accesses,
@@ -182,8 +182,8 @@ async fn handle_revoke_wallet_access(
}
async fn handle_list_wallet_access(
actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> {
actor: &ActorRef<OperatorSession>,
) -> Result<Option<OperatorResponsePayload>, Status> {
match actor.ask(HandleListWalletAccess {}).await {
Ok(accesses) => Ok(Some(wrap_sdk_client_response(
SdkClientResponsePayload::ListWalletAccess(ListWalletAccessResponse {

View File

@@ -1,32 +1,32 @@
use crate::{
actors::vault::VaultState,
peers::user_agent::{UserAgentSession, session::handlers::HandleQueryVaultState},
peers::operator::{OperatorSession, session::handlers::HandleQueryVaultState},
};
use arbiter_proto::{
proto::shared::VaultState as ProtoVaultState,
proto::user_agent::{
user_agent_response::Payload as UserAgentResponsePayload,
proto::operator::{
operator_response::Payload as OperatorResponsePayload,
vault::{
self as proto_vault, request::Payload as VaultRequestPayload,
response::Payload as VaultResponsePayload,
},
},
proto::shared::VaultState as ProtoVaultState,
};
use kameo::actor::ActorRef;
use tonic::Status;
use tracing::warn;
const fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload {
UserAgentResponsePayload::Vault(proto_vault::Response {
const fn wrap_vault_response(payload: VaultResponsePayload) -> OperatorResponsePayload {
OperatorResponsePayload::Vault(proto_vault::Response {
payload: Some(payload),
})
}
pub(super) async fn dispatch(
actor: &ActorRef<UserAgentSession>,
actor: &ActorRef<OperatorSession>,
req: proto_vault::Request,
) -> Result<Option<UserAgentResponsePayload>, Status> {
) -> Result<Option<OperatorResponsePayload>, Status> {
let Some(payload) = req.payload else {
return Err(Status::invalid_argument("Missing vault request payload"));
};
@@ -42,11 +42,12 @@ pub(super) async fn dispatch(
}
async fn handle_query_vault_state(
actor: &ActorRef<UserAgentSession>,
) -> Result<Option<UserAgentResponsePayload>, Status> {
actor: &ActorRef<OperatorSession>,
) -> Result<Option<OperatorResponsePayload>, Status> {
let state = match actor.ask(HandleQueryVaultState {}).await {
Ok(VaultState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(VaultState::Sealed) => ProtoVaultState::Sealed,
Ok(VaultState::Bootstrapping) => ProtoVaultState::Boostrapping,
Ok(VaultState::Unsealed) => ProtoVaultState::Unsealed,
Err(err) => {
warn!(error = ?err, "Failed to query vault state");

View File

@@ -1,7 +1,7 @@
use super::auth::AuthTransportAdapter;
use crate::{
grpc::TryConvert,
peers::user_agent::vault_gate::{self as vault_gate},
peers::operator::vault_gate::{self as vault_gate},
};
use arbiter_proto::transport::{Bi, Error as TransportError, Receiver, Sender};
@@ -20,7 +20,7 @@ impl Receiver<vault_gate::Inbound> for AuthTransportAdapter<'_> {
Err(error) => {
warn!(
?error,
"Failed to receive user agent request during vault gate"
"Failed to receive operator request during vault gate"
);
return None;
}

View File

@@ -1,11 +1,11 @@
use crate::{
grpc::{Convert, TryConvert},
peers::user_agent::vault_gate::{
peers::operator::vault_gate::{
self as vault_gate, HandleBootstrapEncryptedKey, HandleHandshake, HandleUnsealEncryptedKey,
},
};
use arbiter_proto::proto::user_agent::{
user_agent_request::Payload as UserAgentRequestPayload,
use arbiter_proto::proto::operator::{
operator_request::Payload as OperatorRequestPayload,
vault::{
self as proto_vault,
bootstrap::{self as proto_bootstrap},
@@ -16,7 +16,7 @@ use arbiter_proto::proto::user_agent::{
use tonic::Status;
impl TryConvert for UserAgentRequestPayload {
impl TryConvert for OperatorRequestPayload {
type Output = vault_gate::Inbound;
type Error = Status;

View File

@@ -1,12 +1,11 @@
use crate::{
actors::vault::VaultState,
grpc::{Convert, TryConvert},
peers::user_agent::vault_gate::{self as vault_gate},
peers::operator::vault_gate::{self as vault_gate},
};
use arbiter_proto::proto::{
shared::VaultState as ProtoVaultState,
user_agent::{
user_agent_response::Payload as UserAgentResponsePayload,
operator::{
operator_response::Payload as OperatorResponsePayload,
vault::{
self as proto_vault,
bootstrap::{self as proto_bootstrap, BootstrapResult as ProtoBootstrapResult},
@@ -17,35 +16,37 @@ use arbiter_proto::proto::{
},
},
},
shared::VaultState as ProtoVaultState,
};
use tonic::Status;
use tracing::warn;
const fn wrap_vault_response(payload: VaultResponsePayload) -> UserAgentResponsePayload {
UserAgentResponsePayload::Vault(proto_vault::Response {
const fn wrap_vault_response(payload: VaultResponsePayload) -> OperatorResponsePayload {
OperatorResponsePayload::Vault(proto_vault::Response {
payload: Some(payload),
})
}
const fn wrap_unseal_response(payload: UnsealResponsePayload) -> UserAgentResponsePayload {
const fn wrap_unseal_response(payload: UnsealResponsePayload) -> OperatorResponsePayload {
wrap_vault_response(VaultResponsePayload::Unseal(proto_unseal::Response {
payload: Some(payload),
}))
}
fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> UserAgentResponsePayload {
fn wrap_bootstrap_response(result: ProtoBootstrapResult) -> OperatorResponsePayload {
wrap_vault_response(VaultResponsePayload::Bootstrap(proto_bootstrap::Response {
result: result.into(),
}))
}
impl Convert for VaultState {
type Output = UserAgentResponsePayload;
type Output = OperatorResponsePayload;
fn convert(self) -> UserAgentResponsePayload {
fn convert(self) -> OperatorResponsePayload {
let proto_state = match self {
Self::Unbootstrapped => ProtoVaultState::Unbootstrapped,
Self::Bootstrapping => ProtoVaultState::Boostrapping,
Self::Sealed => ProtoVaultState::Sealed,
Self::Unsealed => ProtoVaultState::Unsealed,
};
@@ -54,9 +55,9 @@ impl Convert for VaultState {
}
impl Convert for vault_gate::HandshakeResponse {
type Output = UserAgentResponsePayload;
type Output = OperatorResponsePayload;
fn convert(self) -> UserAgentResponsePayload {
fn convert(self) -> OperatorResponsePayload {
wrap_unseal_response(UnsealResponsePayload::Start(
proto_unseal::UnsealStartResponse {
server_pubkey: self.server_pubkey.as_bytes().to_vec(),
@@ -66,10 +67,10 @@ impl Convert for vault_gate::HandshakeResponse {
}
impl TryConvert for vault_gate::Outbound {
type Output = UserAgentResponsePayload;
type Output = OperatorResponsePayload;
type Error = Status;
fn try_convert(self) -> Result<UserAgentResponsePayload, Status> {
fn try_convert(self) -> Result<OperatorResponsePayload, Status> {
match self {
Self::HandleVaultState(result) => result
.map_err(|err| {

View File

@@ -54,7 +54,7 @@ impl From<diesel::result::Error> for Error {
pub enum ApproveError {
#[error("Internal error")]
Internal,
#[error("Client connection denied by user agents")]
#[error("Client connection denied by operators")]
Denied,
#[error("Upstream error: {0}")]
Upstream(flow_coordinator::ApprovalError),
@@ -171,10 +171,7 @@ async fn insert_client(
Error::DatabasePoolUnavailable
})?;
conn.exclusive_transaction(|conn| {
let vault = vault.clone();
let pubkey = pubkey.clone();
Box::pin(async move {
conn.exclusive_transaction(async |conn| {
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
@@ -182,7 +179,7 @@ async fn insert_client(
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(conn)
.get_result::<i32>(&mut *conn)
.await?;
let client_id = insert_into(program_client::table)
@@ -192,12 +189,12 @@ async fn insert_client(
))
.on_conflict_do_nothing()
.returning(program_client::id)
.get_result::<i32>(conn)
.get_result::<i32>(&mut *conn)
.await?;
integrity::sign_entity(
conn,
&vault,
&mut *conn,
vault,
&ClientCredentials {
pubkey: pubkey.clone(),
},
@@ -211,7 +208,6 @@ async fn insert_client(
Ok(client_id)
})
})
.await
}
@@ -229,18 +225,15 @@ async fn sync_client_metadata(
Error::DatabasePoolUnavailable
})?;
conn.exclusive_transaction(|conn| {
let metadata = metadata.clone();
Box::pin(async move {
let (current_metadata_id, current): (i32, ProgramClientMetadata) =
program_client::table
conn.exclusive_transaction(async |conn| {
let (current_metadata_id, current): (i32, ProgramClientMetadata) = program_client::table
.find(client_id)
.inner_join(client_metadata::table)
.select((
program_client::metadata_id,
ProgramClientMetadata::as_select(),
))
.first(conn)
.first(&mut *conn)
.await?;
let unchanged = current.name == metadata.name
@@ -255,7 +248,7 @@ async fn sync_client_metadata(
client_metadata_history::metadata_id.eq(current_metadata_id),
client_metadata_history::client_id.eq(client_id),
))
.execute(conn)
.execute(&mut *conn)
.await?;
let metadata_id = insert_into(client_metadata::table)
@@ -265,7 +258,7 @@ async fn sync_client_metadata(
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(conn)
.get_result::<i32>(&mut *conn)
.await?;
update(program_client::table.find(client_id))
@@ -273,12 +266,11 @@ async fn sync_client_metadata(
program_client::metadata_id.eq(metadata_id),
program_client::updated_at.eq(now),
))
.execute(conn)
.execute(&mut *conn)
.await?;
Ok::<(), diesel::result::Error>(())
})
})
.await
.map_err(|e| {
error!(error = ?e, "Database error");

View File

@@ -1,2 +1,2 @@
pub mod client;
pub mod user_agent;
pub mod operator;

View File

@@ -1,4 +1,4 @@
use super::{Credentials, UserAgentConnection};
use super::{Credentials, OperatorConnection};
use arbiter_crypto::authn::{self, AuthChallenge};
use arbiter_proto::transport::Bi;
@@ -69,7 +69,7 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents {
}
pub async fn authenticate<T>(
props: &mut UserAgentConnection,
props: &mut OperatorConnection,
transport: &mut T,
) -> Result<Credentials, Error>
where

View File

@@ -1,13 +1,13 @@
use super::{
super::{Credentials, UserAgentConnection},
super::{Credentials, OperatorConnection},
Error,
};
use crate::{
actors::bootstrap::ConsumeToken,
db::{DatabasePool, schema::useragent_client},
peers::user_agent::auth::Outbound,
db::{DatabasePool, schema::operator_identity},
peers::operator::auth::Outbound,
};
use arbiter_crypto::authn::{self, AuthChallenge, USERAGENT_CONTEXT};
use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT};
use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl};
@@ -44,9 +44,9 @@ async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<O
Error::internal("Database unavailable")
})?;
useragent_client::table
.filter(useragent_client::public_key.eq(pubkey.to_bytes()))
.select(useragent_client::id)
operator_identity::table
.filter(operator_identity::public_key.eq(pubkey.to_bytes()))
.select(operator_identity::id)
.first::<i32>(&mut conn)
.await
.optional()
@@ -63,9 +63,9 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i3
Error::internal("Database unavailable")
})?;
let id: i32 = diesel::insert_into(useragent_client::table)
.values((useragent_client::public_key.eq(pubkey_bytes),))
.returning(useragent_client::id)
let id: i32 = diesel::insert_into(operator_identity::table)
.values((operator_identity::public_key.eq(pubkey_bytes),))
.returning(operator_identity::id)
.get_result(&mut conn)
.await
.map_err(|e| {
@@ -77,12 +77,12 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result<i3
}
pub(super) struct AuthContext<'a, T: ?Sized> {
pub(super) conn: &'a mut UserAgentConnection,
pub(super) conn: &'a mut OperatorConnection,
pub(super) transport: &'a mut T,
}
impl<'a, T: ?Sized> AuthContext<'a, T> {
pub(super) const fn new(conn: &'a mut UserAgentConnection, transport: &'a mut T) -> Self {
pub(super) const fn new(conn: &'a mut OperatorConnection, transport: &'a mut T) -> Self {
Self { conn, transport }
}
}
@@ -143,7 +143,7 @@ where
Error::InvalidChallengeSolution
})?;
let valid = pubkey.verify(challenge, USERAGENT_CONTEXT, &signature);
let valid = pubkey.verify(challenge, OPERATOR_CONTEXT, &signature);
if !valid {
self.transport

View File

@@ -17,7 +17,7 @@ use tokio::sync::oneshot;
use tracing::{error, warn};
pub use auth::authenticate;
pub use session::UserAgentSession;
pub use session::OperatorSession;
pub mod auth;
pub mod session;
@@ -30,10 +30,10 @@ pub struct Credentials {
}
impl Integrable for Credentials {
const KIND: &'static str = "useragent_credentials";
const KIND: &'static str = "operator_credentials";
}
// Messages, sent by user agent to connection client without having a request
// Messages, sent by operator to connection client without having a request
#[derive(Debug)]
pub enum OutOfBand {
ClientConnectionRequest { profile: ClientProfile },
@@ -41,12 +41,12 @@ pub enum OutOfBand {
}
#[derive(Clone)]
pub struct UserAgentConnection {
pub struct OperatorConnection {
pub(crate) db: DatabasePool,
pub(crate) actors: GlobalActors,
}
impl UserAgentConnection {
impl OperatorConnection {
pub const fn new(db: DatabasePool, actors: GlobalActors) -> Self {
Self { db, actors }
}
@@ -106,7 +106,7 @@ async fn should_run_gate(vault: &ActorRef<Vault>) -> Result<bool, Error> {
}
async fn run_vault_gate<T>(
props: &UserAgentConnection,
props: &OperatorConnection,
transport: &mut T,
auth_creds: Credentials,
) -> Result<(), Error>
@@ -160,10 +160,10 @@ where
}
pub async fn start<T>(
props: &mut UserAgentConnection,
props: &mut OperatorConnection,
mut transport: T,
oob_sender: Box<dyn Sender<OutOfBand>>,
) -> Result<ActorRef<UserAgentSession>, Error>
) -> Result<ActorRef<OperatorSession>, Error>
where
T: Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> + Send,
T: Bi<vault_gate::Inbound, Result<vault_gate::Outbound, vault_gate::Error>> + Send,
@@ -178,7 +178,7 @@ where
// checking the integrity
verify_integrity(&props.db, &props.actors.vault, &creds).await?;
Ok(UserAgentSession::spawn(UserAgentSession::new(
Ok(OperatorSession::spawn(OperatorSession::new(
props.clone(),
oob_sender,
)))

View File

@@ -1,12 +1,16 @@
use super::{Error, UserAgentSession};
use super::{Error, OperatorSession};
use crate::{
actors::evm::{
ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError,
UseragentCreateGrant, UseragentListGrants,
actors::{
evm::{
ClientSignTransaction, Generate, ListWallets, OperatorCreateGrant, OperatorListGrants,
SignTransactionError as EvmSignError,
},
flow_coordinator::client_connect_approval::ClientApprovalAnswer,
vault::VaultState,
},
db::models::{
EvmWalletAccess, EvmWalletId, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
},
actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer,
actors::vault::VaultState,
db::models::{EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata},
evm::policies::{Grant, SpecificGrant},
};
use arbiter_crypto::authn;
@@ -36,7 +40,7 @@ pub enum GrantMutationError {
}
#[messages]
impl UserAgentSession {
impl OperatorSession {
#[message]
pub(crate) async fn handle_query_vault_state(&mut self) -> Result<VaultState, Error> {
use crate::actors::vault::GetState;
@@ -44,7 +48,7 @@ impl UserAgentSession {
let vault_state = match self.props.actors.vault.ask(GetState {}).await {
Ok(state) => state,
Err(err) => {
error!(?err, actor = "useragent", "vault.query.failed");
error!(?err, actor = "operator", "vault.query.failed");
return Err(Error::internal("Vault is in broken state"));
}
};
@@ -54,7 +58,7 @@ impl UserAgentSession {
}
#[messages]
impl UserAgentSession {
impl OperatorSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
match self.props.actors.evm.ask(Generate {}).await {
@@ -70,7 +74,9 @@ impl UserAgentSession {
}
#[message]
pub(crate) async fn handle_evm_wallet_list(&mut self) -> Result<Vec<(i32, Address)>, Error> {
pub(crate) async fn handle_evm_wallet_list(
&mut self,
) -> Result<Vec<(EvmWalletId, Address)>, Error> {
match self.props.actors.evm.ask(ListWallets {}).await {
Ok(wallets) => Ok(wallets),
Err(err) => {
@@ -82,10 +88,10 @@ impl UserAgentSession {
}
#[messages]
impl UserAgentSession {
impl OperatorSession {
#[message]
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
match self.props.actors.evm.ask(UseragentListGrants {}).await {
match self.props.actors.evm.ask(OperatorListGrants {}).await {
Ok(grants) => Ok(grants),
Err(err) => {
error!(?err, "EVM grant list failed");
@@ -104,7 +110,7 @@ impl UserAgentSession {
.props
.actors
.evm
.ask(UseragentCreateGrant { basic, grant })
.ask(OperatorCreateGrant { basic, grant })
.await
{
Ok(grant_id) => Ok(grant_id),
@@ -121,7 +127,7 @@ impl UserAgentSession {
// .props
// .actors
// .evm
// .ask(UseragentDeleteGrant { grant_id })
// .ask(OperatorDeleteGrant { grant_id })
// .await
// {
// Ok(()) => Ok(()),
@@ -157,7 +163,7 @@ impl UserAgentSession {
Err(SignTransactionError::Vet(vet_error))
}
Err(err) => {
error!(?err, "EVM sign transaction failed in user-agent session");
error!(?err, "EVM sign transaction failed in operator session");
Err(SignTransactionError::Internal)
}
}
@@ -169,21 +175,19 @@ impl UserAgentSession {
entries: Vec<NewEvmWalletAccess>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
conn.transaction(async |conn| {
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)
.execute(&mut *conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
@@ -194,19 +198,17 @@ impl UserAgentSession {
entries: Vec<i32>,
) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
conn.transaction(|conn| {
Box::pin(async move {
conn.transaction(async |conn| {
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)
.execute(&mut *conn)
.await?;
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
@@ -226,7 +228,7 @@ impl UserAgentSession {
}
#[messages]
impl UserAgentSession {
impl OperatorSession {
#[message(ctx)]
pub(crate) async fn handle_new_client_approve(
&mut self,

View File

@@ -1,8 +1,8 @@
use super::{OutOfBand, UserAgentConnection};
use super::{OutOfBand, OperatorConnection};
use crate::{
actors::{
flow_coordinator::client_connect_approval::ClientApprovalController,
useragent_registry::ConnectUseragent,
operator_registry::ConnectOperator,
},
peers::client::ClientProfile,
};
@@ -49,8 +49,8 @@ pub struct PendingClientApproval {
controller: ActorRef<ClientApprovalController>,
}
pub struct UserAgentSession {
props: UserAgentConnection,
pub struct OperatorSession {
props: OperatorConnection,
sender: Box<dyn Sender<OutOfBand>>,
pending_client_approvals: HashMap<Vec<u8>, PendingClientApproval>,
@@ -58,8 +58,8 @@ pub struct UserAgentSession {
pub mod handlers;
impl UserAgentSession {
pub(crate) fn new(props: UserAgentConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self {
impl OperatorSession {
pub(crate) fn new(props: OperatorConnection, sender: Box<dyn Sender<OutOfBand>>) -> Self {
Self {
props,
sender,
@@ -69,7 +69,7 @@ impl UserAgentSession {
}
#[messages]
impl UserAgentSession {
impl OperatorSession {
#[message]
pub async fn begin_new_client_approval(
&mut self,
@@ -85,7 +85,7 @@ impl UserAgentSession {
{
error!(
?e,
actor = "user_agent",
actor = "operator",
event = "failed to announce new client connection"
);
return;
@@ -101,7 +101,7 @@ impl UserAgentSession {
}
}
impl Actor for UserAgentSession {
impl Actor for OperatorSession {
type Args = Self;
type Error = Error;
@@ -109,17 +109,17 @@ impl Actor for UserAgentSession {
async fn on_start(args: Self::Args, this: ActorRef<Self>) -> Result<Self, Self::Error> {
args.props
.actors
.useragent_registry
.ask(ConnectUseragent {
.operator_registry
.ask(ConnectOperator {
actor: this.clone(),
})
.await
.map_err(|err| {
error!(
?err,
"Failed to register user agent connection with user agent registry"
"Failed to register operator connection with operator registry"
);
Error::internal("Failed to register user agent connection with user agent registry")
Error::internal("Failed to register operator connection with operator registry")
})?;
Ok(args)
}
@@ -149,7 +149,7 @@ impl Actor for UserAgentSession {
{
error!(
?e,
actor = "user_agent",
actor = "operator",
event = "failed to announce client connection cancellation"
);
}

View File

@@ -79,22 +79,22 @@ fn sign_client_challenge(key: &SigningKey<MlDsa87>, challenge: &AuthChallenge) -
.into()
}
async fn insert_bootstrap_sentinel_useragent(db: &db::DatabasePool) {
async fn insert_bootstrap_sentinel_operator(db: &db::DatabasePool) {
let mut conn = db.get().await.unwrap();
let sentinel_key = verifying_key(&MlDsa87::key_gen(&mut rand::rng()))
.encode()
.0
.to_vec();
insert_into(schema::useragent_client::table)
.values((schema::useragent_client::public_key.eq(sentinel_key),))
insert_into(schema::operator_identity::table)
.values((schema::operator_identity::public_key.eq(sentinel_key),))
.execute(&mut conn)
.await
.unwrap();
}
async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors {
insert_bootstrap_sentinel_useragent(db).await;
insert_bootstrap_sentinel_operator(db).await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors

View File

@@ -0,0 +1,6 @@
mod common;
#[path = "operator/auth.rs"]
mod auth;
#[path = "operator/unseal.rs"]
mod unseal;

View File

@@ -1,6 +1,6 @@
use super::common::ChannelTransport;
use arbiter_crypto::{
authn::{self, AuthChallenge, USERAGENT_CONTEXT},
authn::{self, AuthChallenge, OPERATOR_CONTEXT},
safecell::{SafeCell, SafeCellHandle as _},
};
use arbiter_proto::transport::{Error as TransportError, Receiver, Sender};
@@ -8,7 +8,7 @@ use arbiter_server::{
actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap},
crypto::integrity,
db::{self, schema},
peers::user_agent::{self, Credentials, UserAgentConnection, auth, vault_gate},
peers::operator::{self, Credentials, OperatorConnection, auth, vault_gate},
};
use async_trait::async_trait;
@@ -21,13 +21,13 @@ fn verifying_key(key: &SigningKey<MlDsa87>) -> VerifyingKey<MlDsa87> {
<SigningKey<MlDsa87> as Keypair>::verifying_key(key)
}
fn sign_useragent_challenge(
fn sign_operator_challenge(
key: &SigningKey<MlDsa87>,
challenge: &AuthChallenge,
) -> authn::Signature {
let challenge = challenge.format();
key.signing_key()
.sign_deterministic(&challenge, USERAGENT_CONTEXT)
.sign_deterministic(&challenge, OPERATOR_CONTEXT)
.unwrap()
.into()
}
@@ -41,8 +41,8 @@ fn tamper_challenge(challenge: &AuthChallenge) -> AuthChallenge {
struct NullOobSender;
#[async_trait]
impl Sender<user_agent::OutOfBand> for NullOobSender {
async fn send(&mut self, _item: user_agent::OutOfBand) -> Result<(), TransportError> {
impl Sender<operator::OutOfBand> for NullOobSender {
async fn send(&mut self, _item: operator::OutOfBand) -> Result<(), TransportError> {
Ok(())
}
}
@@ -166,7 +166,7 @@ pub async fn bootstrap_token_auth() {
let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
let mut props = OperatorConnection::new(db_for_task, actors);
auth::authenticate(&mut props, &mut server_transport).await
});
@@ -188,7 +188,7 @@ pub async fn bootstrap_token_auth() {
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = sign_useragent_challenge(&new_key, &challenge);
let signature = sign_operator_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
@@ -206,8 +206,8 @@ pub async fn bootstrap_token_auth() {
task.await.unwrap().unwrap();
let mut conn = db.get().await.unwrap();
let stored_pubkey: Vec<u8> = schema::useragent_client::table
.select(schema::useragent_client::public_key)
let stored_pubkey: Vec<u8> = schema::operator_identity::table
.select(schema::operator_identity::public_key)
.first::<Vec<u8>>(&mut conn)
.await
.unwrap();
@@ -223,7 +223,7 @@ pub async fn bootstrap_invalid_token_auth() {
let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
let mut props = OperatorConnection::new(db_for_task, actors);
auth::authenticate(&mut props, &mut server_transport).await
});
@@ -245,7 +245,7 @@ pub async fn bootstrap_invalid_token_auth() {
other => panic!("Expected AuthChallenge, got {other:?}"),
};
let signature = sign_useragent_challenge(&new_key, &challenge);
let signature = sign_operator_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
signature: signature.to_bytes(),
@@ -259,7 +259,7 @@ pub async fn bootstrap_invalid_token_auth() {
));
let mut conn = db.get().await.unwrap();
let count: i64 = schema::useragent_client::table
let count: i64 = schema::operator_identity::table
.count()
.get_result::<i64>(&mut conn)
.await
@@ -285,9 +285,9 @@ pub async fn challenge_auth() {
{
let mut conn = db.get().await.unwrap();
let id: i32 = insert_into(schema::useragent_client::table)
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::useragent_client::id)
let id: i32 = insert_into(schema::operator_identity::table)
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::operator_identity::id)
.get_result(&mut conn)
.await
.unwrap();
@@ -307,7 +307,7 @@ pub async fn challenge_auth() {
let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
let mut props = OperatorConnection::new(db_for_task, actors);
auth::authenticate(&mut props, &mut server_transport).await
});
@@ -331,7 +331,7 @@ pub async fn challenge_auth() {
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
let signature = sign_useragent_challenge(&new_key, &challenge);
let signature = sign_operator_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
@@ -371,8 +371,8 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
{
let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table)
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),))
insert_into(schema::operator_identity::table)
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
.execute(&mut conn)
.await
.unwrap();
@@ -381,8 +381,8 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
let (server_transport, mut test_transport) = start_transport_pair();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
user_agent::start(&mut props, server_transport, Box::new(NullOobSender)).await
let mut props = OperatorConnection::new(db_for_task, actors);
operator::start(&mut props, server_transport, Box::new(NullOobSender)).await
});
test_transport
@@ -405,7 +405,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
let signature = sign_useragent_challenge(&new_key, &challenge);
let signature = sign_operator_challenge(&new_key, &challenge);
test_transport
.send(auth::Inbound::AuthChallengeSolution {
@@ -422,7 +422,7 @@ pub async fn challenge_auth_rejects_integrity_tag_mismatch_when_unsealed() {
assert!(matches!(
task.await.unwrap(),
Err(user_agent::Error::Internal(_))
Err(operator::Error::Internal(_))
));
}
@@ -444,9 +444,9 @@ pub async fn challenge_auth_rejects_invalid_signature() {
{
let mut conn = db.get().await.unwrap();
let id: i32 = insert_into(schema::useragent_client::table)
.values((schema::useragent_client::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::useragent_client::id)
let id: i32 = insert_into(schema::operator_identity::table)
.values((schema::operator_identity::public_key.eq(pubkey_bytes.clone()),))
.returning(schema::operator_identity::id)
.get_result(&mut conn)
.await
.unwrap();
@@ -466,7 +466,7 @@ pub async fn challenge_auth_rejects_invalid_signature() {
let (mut server_transport, mut test_transport) = ChannelTransport::new();
let db_for_task = db.clone();
let task = tokio::spawn(async move {
let mut props = UserAgentConnection::new(db_for_task, actors);
let mut props = OperatorConnection::new(db_for_task, actors);
auth::authenticate(&mut props, &mut server_transport).await
});
@@ -490,7 +490,7 @@ pub async fn challenge_auth_rejects_invalid_signature() {
Err(err) => panic!("Expected Ok response, got Err({err:?})"),
};
let signature = sign_useragent_challenge(&new_key, &tamper_challenge(&challenge));
let signature = sign_operator_challenge(&new_key, &tamper_challenge(&challenge));
test_transport
.send(auth::Inbound::AuthChallengeSolution {

View File

@@ -8,7 +8,7 @@ use arbiter_server::{
vault::{Bootstrap, Seal},
},
db,
peers::user_agent::{
peers::operator::{
Credentials,
vault_gate::{
Error as VaultGateError, HandleHandshake, HandleUnsealEncryptedKey, VaultGate,

View File

@@ -1,6 +0,0 @@
mod common;
#[path = "user_agent/auth.rs"]
mod auth;
#[path = "user_agent/unseal.rs"]
mod unseal;

View File

@@ -5,3 +5,7 @@ edition = "2024"
[dependencies]
alloy.workspace = true
[lib]
test = false
doctest = false